diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index 9d8c362..e8f4b5e 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -30,6 +30,7 @@ type RulesTestDetailData = { pack?: { documentType?: string; mainType?: string; + businessType?: string; fields?: unknown[]; subDocuments?: 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 detailPack = rulesTestDetailData?.pack; const detailPackFilterMainType = detailPack?.businessType || detailPack?.mainType || ''; - const isContractDetail = !!detailPack?.documentType?.includes('合同'); - const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷'); - const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0; - const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0; + const showFieldNav = (detailPack?.fields?.length || 0) > 0; + const showSubDocumentNav = (detailPack?.subDocuments?.length || 0) > 0; const showVisualNav = (detailPack?.visualElements?.length || 0) > 0; const rulesListHref = detailPack?.documentType ? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPackFilterMainType ? `&mainType=${encodeURIComponent(detailPackFilterMainType)}` : ''}` diff --git a/app/routes/rulesTest.detail.tsx b/app/routes/rulesTest.detail.tsx index 1e9ab81..46ede72 100644 --- a/app/routes/rulesTest.detail.tsx +++ b/app/routes/rulesTest.detail.tsx @@ -9,8 +9,8 @@ import { Tag, type TagColor } from '~/components/ui/Tag'; import { getUserSession } from '~/api/login/auth.server'; import { API_BASE_URL } from '~/config/api-config'; import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server'; -import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor'; -import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server'; +import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, prepareDraftYamlForSave, serializeEditableRuleConfig, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue, type VisualElementSummary } from '~/utils/rules-config-editor'; +import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from '~/utils/rules-yaml-mock.server'; import styles from '~/styles/pages/rules_test.css?url'; export const links = () => [ @@ -35,13 +35,22 @@ type ActionData = { 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 & { checkTypes: string[]; dependencies: string[]; }; +type FieldDraft = ExtractFieldSummary; +type DocumentDraft = SubDocumentSummary; +type VisualDraft = VisualElementSummary; + function riskColor(risk: string): TagColor { if (risk === 'high') return 'red'; if (risk === 'medium') return 'orange'; @@ -109,6 +118,40 @@ function phaseLabel(phase: string): string { return labels[phase] ? `${labels[phase]} (${phase})` : phase; } +function fieldTypeLabel(type: string): string { + const labels: Record = { + verbatim: '原文', + string: '文本', + money: '金额', + date: '日期', + enum: '枚举', + number: '数字', + multi_entity: '多实体', + }; + return labels[type] || type || '-'; +} + +function requiredFromLabel(value: string): string { + const labels: Record = { + draft: '草稿阶段', + executed: '签署后阶段', + '-': '未限定', + true: '必需', + false: '可选', + conditional: '条件必需', + }; + return labels[value] || value || '-'; +} + +function matchesCurrentRuleDependency(currentRule: RuleSummary | undefined, candidates: Array): 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 { if (!logic.trim()) return false; 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 { return severity === 'error' ? 'red' : 'orange'; } @@ -314,21 +393,18 @@ function validateRule(rule: RuleSummary | undefined, dependencyOptions: Dependen export async function loader({ request }: LoaderFunctionArgs) { 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 requestedRuleId = url.searchParams.get('ruleId') || ''; - const packs = await loadRuleConfigPacks(request); - const pack = (packId ? await loadRuleConfigPack(request, packId) : undefined) || packs[0]; + const pack = packId ? await loadRuleConfigPack(request, packId) : undefined; + const resolvedPack = pack || (await loadRuleConfigPacks(request))[0]; - if (!pack) { + if (!resolvedPack) { 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) { @@ -413,9 +489,16 @@ export default function RulesTestDetail() { const revalidator = useRevalidator(); const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }); const [rules, setRules] = useState(pack.rules); + const [fields, setFields] = useState(pack.fields); + const [subDocuments, setSubDocuments] = useState(pack.subDocuments); + const [visualElements, setVisualElements] = useState(pack.visualElements); const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey); const [editor, setEditor] = useState(null); const [ruleDraft, setRuleDraft] = useState(emptyRuleDraft(pack.rules[0]?.group)); + const [fieldDraft, setFieldDraft] = useState(emptyFieldDraft(pack.fields[0]?.group || '基础信息')); + const [documentDraft, setDocumentDraft] = useState(emptyDocumentDraft()); + const [visualDraft, setVisualDraft] = useState(emptyVisualDraft(pack.visualElements[0]?.type || '签章')); + const [selectedRollbackVersionId, setSelectedRollbackVersionId] = useState(''); const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false); const [dependencySearch, setDependencySearch] = useState(''); const [dependencySelection, setDependencySelection] = useState([]); @@ -429,6 +512,9 @@ export default function RulesTestDetail() { useEffect(() => { setRules(pack.rules); + setFields(pack.fields); + setSubDocuments(pack.subDocuments); + setVisualElements(pack.visualElements); setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' })); setEditor(null); setDependencyDialogOpen(false); @@ -448,14 +534,15 @@ export default function RulesTestDetail() { const editableConfig: EditableRuleConfig = useMemo(() => ({ metadata: pack.metadata, + yamlSource: pack.yamlSource, documentType: pack.documentType, mainType: pack.mainType, subtype: pack.subtype, - fields: pack.fields, - subDocuments: pack.subDocuments, - visualElements: pack.visualElements, + fields, + subDocuments, + visualElements, rules - }), [pack, rules]); + }), [fields, pack, rules, subDocuments, visualElements]); const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]); const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]); @@ -474,6 +561,24 @@ export default function RulesTestDetail() { const currentDependencyRows = useMemo(() => { return (currentRule?.dependencies || []).map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, 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 selectedValues = new Set(ruleDraft.dependencies); return uniqueDependencyOptions([ @@ -510,7 +615,17 @@ export default function RulesTestDetail() { const hasErrors = validationIssues.some(issue => issue.severity === 'error'); const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]); const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error'); - const fullYamlText = useMemo(() => buildYamlPreview(editableConfig), [editableConfig]); + const 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 isRuleGroupDraft = ruleDraft.type === 'rule_group'; 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], ); - const rollbackTargetVersion = useMemo( - () => versions.find((item) => ['published', 'rollback'].includes(item.status) && item.id !== pack.currentVersionId), + const rollbackOptions = useMemo( + () => versions.filter((item) => item.id !== 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 currentResolvedVersion = useMemo( () => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null, @@ -537,6 +656,10 @@ export default function RulesTestDetail() { return status || '-'; }; + useEffect(() => { + setSelectedRollbackVersionId(''); + }, [pack.id, pack.currentVersionId, versions.length]); + useEffect(() => { if (!saveFetcher.data) return; if (saveFetcher.data.success) { @@ -643,6 +766,137 @@ export default function RulesTestDetail() { 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) => { + 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 = () => { if (hasConfigErrors) { setShowValidation(true); @@ -650,10 +904,15 @@ export default function RulesTestDetail() { setSaveMessage(''); return; } + if (serializedYamlResult.error) { + setSaveError(serializedYamlResult.error); + setSaveMessage(''); + return; + } const formData = new FormData(); 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('intent', 'save'); saveFetcher.submit(formData, { method: 'post' }); @@ -689,6 +948,9 @@ export default function RulesTestDetail() { const resetDraft = () => { setRules(pack.rules); + setFields(pack.fields); + setSubDocuments(pack.subDocuments); + setVisualElements(pack.visualElements); setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' })); setDependencyDialogOpen(false); setDependencySearch(''); @@ -761,6 +1023,20 @@ export default function RulesTestDetail() { + @@ -859,13 +1135,97 @@ export default function RulesTestDetail() { )} - {(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 && } - {pack.subDocuments.length > 0 && } - {pack.visualElements.length > 0 && } + {fields.length > 0 && } + {subDocuments.length > 0 && } + {visualElements.length > 0 && } )} + +
+ 这里只显示当前评查点实际引用到的抽取字段。 + +
+ {currentRuleFields.length > 0 ? ( +
+ {currentRuleFields.map((field) => ( +
+
+ {field.name} + {field.group || '未分组'} / {fieldTypeLabel(field.type)} + {requiredFromLabel(field.requiredFrom || '-')}{field.description ? ` · ${field.description}` : ''} +
+
+ + +
+
+ ))} +
+ ) : ( +
当前评查点没有引用抽取字段;新增后需在该评查点“依赖字段”中引用,才会显示在这里。
+ )} +
+ + +
+ 这里只显示当前评查点实际引用到的子文档。 + +
+ {currentRuleSubDocuments.length > 0 ? ( +
+ {currentRuleSubDocuments.map((document) => ( +
+
+ {document.name} + {document.id} / {requiredFromLabel(document.required || '-')} + {document.fields.length} 个字段{document.groups.length > 0 ? ` · ${document.groups.join('、')}` : ''} +
+
+ + +
+
+ ))} +
+ ) : ( +
当前评查点没有引用任何子文档;在依赖字段中引用文书名称或 `文书.字段` 后,这里才会显示。
+ )} +
+ + +
+ 这里只显示当前评查点实际引用到的视觉要素。 + +
+ {currentRuleVisualElements.length > 0 ? ( +
+ {currentRuleVisualElements.map((item) => ( +
+
+ {item.name || item.id} + {item.type} / {item.id} + {requiredFromLabel(String(item.required))}{item.signatureTypes && item.signatureTypes.length > 0 ? ` · ${item.signatureTypes.join('、')}` : ''} +
+
+ + +
+
+ ))} +
+ ) : ( +
当前评查点没有引用视觉要素;在依赖字段中引用印章、签名、骑缝章或 `visual.xxx` 后,这里才会显示。
+ )} +
+
-

{editor.mode === 'edit' ? '编辑评查点' : '新增评查点'}

+

+ {editor.kind === 'rule' ? (editor.mode === 'edit' ? '编辑评查点' : '新增评查点') : ''} + {editor.kind === 'field' ? (editor.mode === 'edit' ? '编辑抽取字段' : '新增抽取字段') : ''} + {editor.kind === 'document' ? (editor.mode === 'edit' ? '编辑子文档' : '新增子文档') : ''} + {editor.kind === 'visual' ? (editor.mode === 'edit' ? '编辑视觉要素' : '新增视觉要素') : ''} +

-
- - - -
+ {editor.kind === 'rule' && ( +
+ + +
+ + +
+ + {isSmartRuleDraft && ( +