2004 lines
88 KiB
TypeScript
2004 lines
88 KiB
TypeScript
import { json, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
|
||
import { Link, useFetcher, useLoaderData, useRevalidator } from '@remix-run/react';
|
||
import type React from 'react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Button } from '~/components/ui/Button';
|
||
import { Card } from '~/components/ui/Card';
|
||
import { Table } from '~/components/ui/Table';
|
||
import { Tag, type TagColor } from '~/components/ui/Tag';
|
||
import { getUserSession } from '~/api/login/auth.server';
|
||
import { API_BASE_URL } from '~/config/api-config';
|
||
import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server';
|
||
import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, 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 = () => [
|
||
{ rel: 'stylesheet', href: styles }
|
||
];
|
||
|
||
export const meta: MetaFunction = () => [
|
||
{ title: '评查点详情 - 智慧法务' }
|
||
];
|
||
|
||
type LoaderData = {
|
||
pack: RuleYamlPack;
|
||
requestedRuleId: string;
|
||
versions: RuleVersionItem[];
|
||
};
|
||
|
||
type ActionData = {
|
||
success: boolean;
|
||
message: string;
|
||
intent: 'save' | 'publish' | 'rollback';
|
||
versionId?: number;
|
||
versionNo?: string;
|
||
};
|
||
|
||
type EditorState =
|
||
| { kind: 'rule'; mode: 'create' | 'edit'; id?: string }
|
||
| { 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'> & {
|
||
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';
|
||
if (risk === 'low') return 'green';
|
||
return 'gray';
|
||
}
|
||
|
||
function riskLabel(risk: string): string {
|
||
if (risk === 'high') return '高';
|
||
if (risk === 'medium') return '中';
|
||
if (risk === 'low') return '低';
|
||
return risk || '-';
|
||
}
|
||
|
||
function uniqueOptions(values: Array<string | undefined>): string[] {
|
||
return Array.from(new Set(values.map(value => value?.trim()).filter(Boolean) as string[]));
|
||
}
|
||
|
||
function uniqueDependencyOptions(options: DependencyOption[]): DependencyOption[] {
|
||
const seen = new Set<string>();
|
||
return options.filter(option => {
|
||
if (!option.value || seen.has(option.value)) {
|
||
return false;
|
||
}
|
||
seen.add(option.value);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function ruleKey(rule: Pick<RuleSummary, 'id' | 'ruleId'>): string {
|
||
return rule.ruleId || rule.id;
|
||
}
|
||
|
||
function ruleTypeLabel(type: string): string {
|
||
const labels: Record<string, string> = {
|
||
deterministic: '确定性检查',
|
||
ai_rule: '智能语义检查',
|
||
rule_group: '规则组合',
|
||
llm: '智能语义检查',
|
||
manual: '人工复核'
|
||
};
|
||
return labels[type] ? `${labels[type]} (${type})` : type || '-';
|
||
}
|
||
|
||
function checkTypeLabel(type: string): string {
|
||
const labels: Record<string, string> = {
|
||
required: '必填',
|
||
ai: '智能判断',
|
||
contains: '包含',
|
||
match: '匹配',
|
||
format: '格式',
|
||
compare: '比较',
|
||
amount_match: '金额一致',
|
||
visual: '视觉要素',
|
||
assert: '断言'
|
||
};
|
||
return labels[type] ? `${labels[type]} (${type})` : type;
|
||
}
|
||
|
||
function phaseLabel(phase: string): string {
|
||
const labels: Record<string, string> = {
|
||
draft: '草稿',
|
||
executed: '已执行'
|
||
};
|
||
return labels[phase] ? `${labels[phase]} (${phase})` : phase;
|
||
}
|
||
|
||
function 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();
|
||
if (!value) return false;
|
||
if (deps.has(value)) return true;
|
||
return Array.from(deps).some((dependency) => dependency.startsWith(`${value}.`));
|
||
});
|
||
}
|
||
|
||
function isStepReferenced(logic: string, stepId: string): boolean {
|
||
if (!logic.trim()) return false;
|
||
return new RegExp(`(^|[^\\w-])${stepId}([^\\w-]|$)`).test(logic);
|
||
}
|
||
|
||
function fallbackDependencyOption(value: string, optionMap?: Map<string, DependencyOption>): DependencyOption {
|
||
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||
return { value, label: value, source: '常量', group: '常量' };
|
||
}
|
||
if (value.startsWith('derived.')) {
|
||
return { value, label: value.replace(/^derived\./, ''), source: '派生字段', group: '派生字段' };
|
||
}
|
||
if (value.startsWith('visual.')) {
|
||
return { value, label: value.replace(/^visual\./, ''), source: '视觉要素引用', group: '视觉要素' };
|
||
}
|
||
if (value.includes('[*].')) {
|
||
return { value, label: value, source: '多实体字段', group: value.split('[*].')[0] };
|
||
}
|
||
const prefix = value.split('.')[0];
|
||
const parent = value.includes('.') ? optionMap?.get(prefix) : undefined;
|
||
if (parent) {
|
||
return { value, label: value, source: `${parent.source} / 子项未显式定义`, group: parent.group };
|
||
}
|
||
return {
|
||
value,
|
||
label: value,
|
||
source: '未匹配',
|
||
group: '未匹配'
|
||
};
|
||
}
|
||
|
||
function resolveRuleDependencies(
|
||
rule: RuleSummary | undefined,
|
||
rulesById: Map<string, RuleSummary>,
|
||
visited = new Set<string>(),
|
||
): string[] {
|
||
if (!rule) return [];
|
||
const key = rule.ruleId || rule.id;
|
||
if (visited.has(key)) return [];
|
||
visited.add(key);
|
||
|
||
const merged = new Set<string>((rule.dependencies || []).filter(Boolean));
|
||
|
||
(rule.subRuleIds || []).forEach((ruleId) => {
|
||
const referenced = rulesById.get(ruleId);
|
||
resolveRuleDependencies(referenced, rulesById, visited).forEach((dependency) => {
|
||
if (dependency) merged.add(dependency);
|
||
});
|
||
});
|
||
|
||
return Array.from(merged);
|
||
}
|
||
|
||
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'),
|
||
ruleId: '',
|
||
name: '',
|
||
group,
|
||
risk: 'medium',
|
||
score: '1',
|
||
type: 'deterministic',
|
||
checkTypes: [],
|
||
logic: '',
|
||
subRules: [],
|
||
subRuleIds: [],
|
||
prompt: '',
|
||
description: '',
|
||
dependencies: []
|
||
};
|
||
}
|
||
|
||
function emptyFieldDraft(group = '基础信息'): FieldDraft {
|
||
return {
|
||
id: makeId('field'),
|
||
group,
|
||
name: '',
|
||
type: 'verbatim',
|
||
multipleEntities: false,
|
||
allowed: [],
|
||
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',
|
||
requiredFrom: '',
|
||
signerRoles: [],
|
||
signatureTypes: [],
|
||
privateSealRestricted: false,
|
||
expectedMatchField: '',
|
||
expectedMatchAlternatives: [],
|
||
prompt: '',
|
||
};
|
||
}
|
||
|
||
function issueColor(severity: ValidationIssue['severity']): TagColor {
|
||
return severity === 'error' ? 'red' : 'orange';
|
||
}
|
||
|
||
function renderYamlLine(line: string, index: number) {
|
||
const indent = line.match(/^\s*/)?.[0] || '';
|
||
const content = line.slice(indent.length);
|
||
const listMatch = content.match(/^(-\s+)([^:]+:)(.*)$/);
|
||
const keyMatch = content.match(/^([^:]+:)(.*)$/);
|
||
|
||
if (!content) {
|
||
return <span key={index} className="yaml-line"> </span>;
|
||
}
|
||
|
||
if (content.startsWith('#')) {
|
||
return (
|
||
<span key={index} className="yaml-line">
|
||
<span className="yaml-value">{content}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (listMatch) {
|
||
return (
|
||
<span key={index} className="yaml-line">
|
||
<span className="yaml-indent">{indent}</span>
|
||
<span className="yaml-marker">{listMatch[1]}</span>
|
||
<span className="yaml-key">{listMatch[2]}</span>
|
||
<YamlValue value={listMatch[3]} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (keyMatch) {
|
||
return (
|
||
<span key={index} className="yaml-line">
|
||
<span className="yaml-indent">{indent}</span>
|
||
<span className="yaml-key">{keyMatch[1]}</span>
|
||
<YamlValue value={keyMatch[2]} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<span key={index} className="yaml-line">
|
||
<span className="yaml-indent">{indent}</span>
|
||
<span>{content}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function YamlValue({ value }: { value: string }) {
|
||
const trimmed = value.trim();
|
||
const className = /^'.*'$|^".*"$/.test(trimmed)
|
||
? 'yaml-string'
|
||
: /^(true|false|null)$/i.test(trimmed)
|
||
? 'yaml-boolean'
|
||
: /^-?\d+(\.\d+)?$/.test(trimmed)
|
||
? 'yaml-number'
|
||
: 'yaml-value';
|
||
|
||
return <span className={className}>{value}</span>;
|
||
}
|
||
|
||
function validateRule(rule: RuleSummary | undefined, dependencyOptions: DependencyOption[]): ValidationIssue[] {
|
||
if (!rule) {
|
||
return [{
|
||
id: 'rule-missing',
|
||
severity: 'error',
|
||
area: '评查规则',
|
||
target: '未找到评查点',
|
||
message: '当前链接没有匹配到评查点,请从规则列表重新进入。'
|
||
}];
|
||
}
|
||
|
||
const issues: ValidationIssue[] = [];
|
||
const dependencyValues = new Set(dependencyOptions.map(option => option.value));
|
||
const hasKnownDependency = (dependency: string) => {
|
||
if (/^-?\d+(\.\d+)?$/.test(dependency)) return true;
|
||
if (dependencyValues.has(dependency)) return true;
|
||
const prefix = dependency.split('.')[0];
|
||
return dependency.includes('.') && dependencyValues.has(prefix);
|
||
};
|
||
|
||
if (!rule.name.trim()) {
|
||
issues.push({
|
||
id: `rule-name-${rule.id}`,
|
||
severity: 'error',
|
||
area: '评查规则',
|
||
target: rule.ruleId || rule.id,
|
||
message: '评查点名称不能为空。'
|
||
});
|
||
}
|
||
|
||
if (!rule.group.trim()) {
|
||
issues.push({
|
||
id: `rule-group-${rule.id}`,
|
||
severity: 'error',
|
||
area: '评查规则',
|
||
target: rule.name || rule.ruleId || rule.id,
|
||
message: '评查点必须选择规则组。'
|
||
});
|
||
}
|
||
|
||
if (!rule.score.trim() || rule.score === '-') {
|
||
issues.push({
|
||
id: `rule-score-${rule.id}`,
|
||
severity: 'error',
|
||
area: '评查规则',
|
||
target: rule.name || rule.ruleId || rule.id,
|
||
message: '评查点必须设置分值。'
|
||
});
|
||
}
|
||
|
||
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && !rule.prompt.trim()) {
|
||
issues.push({
|
||
id: `rule-prompt-${rule.id}`,
|
||
severity: 'warning',
|
||
area: '评查规则',
|
||
target: rule.name || rule.ruleId || rule.id,
|
||
message: '智能语义检查建议维护提示词。'
|
||
});
|
||
}
|
||
|
||
if (rule.type === 'rule_group' && !rule.logic.trim()) {
|
||
issues.push({
|
||
id: `rule-group-logic-${rule.id}`,
|
||
severity: 'error',
|
||
area: '评查规则',
|
||
target: rule.name || rule.ruleId || rule.id,
|
||
message: '规则组合必须维护逻辑运算式。'
|
||
});
|
||
}
|
||
|
||
rule.dependencies.forEach(dependency => {
|
||
if (!hasKnownDependency(dependency)) {
|
||
issues.push({
|
||
id: `rule-dependency-${rule.id}-${dependency}`,
|
||
severity: 'warning',
|
||
area: '评查规则',
|
||
target: rule.name || rule.ruleId || rule.id,
|
||
message: `依赖字段【${dependency}】未在当前 YAML 的字段配置或视觉要素中找到。`
|
||
});
|
||
}
|
||
});
|
||
|
||
return issues;
|
||
}
|
||
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
const url = new URL(request.url);
|
||
const packId = url.searchParams.get('packId') || url.searchParams.get('id') || '';
|
||
const requestedRuleId = url.searchParams.get('ruleId') || '';
|
||
const pack = packId ? await loadRuleConfigPack(request, packId) : undefined;
|
||
const resolvedPack = pack || (await loadRuleConfigPacks(request))[0];
|
||
|
||
if (!resolvedPack) {
|
||
throw new Response('未找到 YAML 配置', { status: 404 });
|
||
}
|
||
|
||
const versions = await loadRuleConfigVersions(request, resolvedPack.metadata.typeId || '');
|
||
|
||
return Response.json({ pack: resolvedPack, requestedRuleId, versions } satisfies LoaderData);
|
||
}
|
||
|
||
export async function action({ request }: ActionFunctionArgs) {
|
||
const { frontendJWT, userInfo } = await getUserSession(request);
|
||
const { requireRoutePermission } = await import('~/api/auth/check-route-permission.server');
|
||
await requireRoutePermission('/rulesTest/detail', userInfo?.role || '', frontendJWT || undefined);
|
||
if (!frontendJWT) {
|
||
return json<ActionData>({ success: false, intent: 'save', message: '登录已失效,请重新登录后再保存。' }, { status: 401 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const intent = String(formData.get('intent') || 'save').trim() as ActionData['intent'];
|
||
const ruleType = String(formData.get('ruleType') || '').trim();
|
||
const yamlText = String(formData.get('yamlText') || '');
|
||
const changeNote = String(formData.get('changeNote') || '').trim() || 'rulesTest.detail 保存评查点草稿';
|
||
const versionId = Number(formData.get('versionId') || 0);
|
||
|
||
if (!ruleType) {
|
||
return json<ActionData>({ success: false, intent, message: '当前规则类型缺失,无法保存。' }, { status: 400 });
|
||
}
|
||
|
||
if (intent === 'save' && !yamlText.trim()) {
|
||
return json<ActionData>({ success: false, intent, message: '当前 YAML 内容为空,无法保存。' }, { status: 400 });
|
||
}
|
||
|
||
if ((intent === 'publish' || intent === 'rollback') && (!Number.isFinite(versionId) || versionId <= 0)) {
|
||
return json<ActionData>({ success: false, intent, message: '目标版本缺失,无法继续操作。' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
const apiPath = intent === 'save'
|
||
? `/api/rule-sets/${encodeURIComponent(ruleType)}/versions`
|
||
: `/api/rule-sets/${encodeURIComponent(ruleType)}/${intent}`;
|
||
const requestBody = intent === 'save'
|
||
? {
|
||
yamlText,
|
||
changeNote,
|
||
editorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
|
||
}
|
||
: {
|
||
versionId,
|
||
operatorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
|
||
};
|
||
|
||
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${frontendJWT}`,
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
},
|
||
body: JSON.stringify(requestBody),
|
||
});
|
||
|
||
const payload = await response.json();
|
||
const data = payload?.data ?? null;
|
||
const message = String(payload?.message || payload?.msg || (response.ok ? '规则草稿保存成功' : '规则草稿保存失败'));
|
||
|
||
if (!response.ok || !data) {
|
||
return json<ActionData>({ success: false, intent, message }, { status: response.status || 500 });
|
||
}
|
||
|
||
return json<ActionData>({
|
||
success: true,
|
||
intent,
|
||
message,
|
||
versionId: Number(data.id),
|
||
versionNo: String(data.versionNo || ''),
|
||
});
|
||
} catch (error) {
|
||
return json<ActionData>({
|
||
success: false,
|
||
intent,
|
||
message: error instanceof Error ? error.message : '规则草稿保存失败',
|
||
}, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export default function RulesTestDetail() {
|
||
const { pack, requestedRuleId, versions } = useLoaderData<typeof loader>() as LoaderData;
|
||
const saveFetcher = useFetcher<ActionData>();
|
||
const revalidator = useRevalidator();
|
||
const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' });
|
||
const [rules, setRules] = useState<RuleSummary[]>(pack.rules);
|
||
const [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 [editor, setEditor] = useState<EditorState>(null);
|
||
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 [versionItems, setVersionItems] = useState<RuleVersionItem[]>(versions);
|
||
const [selectedRollbackVersionId, setSelectedRollbackVersionId] = useState('');
|
||
const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false);
|
||
const [dependencySearch, setDependencySearch] = useState('');
|
||
const [dependencySelection, setDependencySelection] = useState<string[]>([]);
|
||
const [expandedDependencyGroups, setExpandedDependencyGroups] = useState<string[]>([]);
|
||
const [showValidation, setShowValidation] = useState(false);
|
||
const [showYamlPreview, setShowYamlPreview] = useState(false);
|
||
const [draftSaved, setDraftSaved] = useState(false);
|
||
const [saveMessage, setSaveMessage] = useState('');
|
||
const [saveError, setSaveError] = useState('');
|
||
const promptEditorRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useEffect(() => {
|
||
setRules(pack.rules);
|
||
setFields(pack.fields);
|
||
setSubDocuments(pack.subDocuments);
|
||
setVisualElements(pack.visualElements);
|
||
setVersionItems(versions);
|
||
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
||
setEditor(null);
|
||
setDependencyDialogOpen(false);
|
||
setDependencySearch('');
|
||
setDependencySelection([]);
|
||
setExpandedDependencyGroups([]);
|
||
setShowValidation(false);
|
||
setShowYamlPreview(false);
|
||
setDraftSaved(false);
|
||
setSaveMessage('');
|
||
setSaveError('');
|
||
}, [pack.currentVersionId, pack.fallbackVersionId, pack.id, pack.resolvedVersionId, pack.yamlSource, requestedRuleId, versions]);
|
||
|
||
const currentRule = useMemo(() => {
|
||
return rules.find(rule => rule.id === selectedRuleKey || rule.ruleId === selectedRuleKey) || rules[0];
|
||
}, [rules, selectedRuleKey]);
|
||
|
||
const editableConfig: EditableRuleConfig = useMemo(() => ({
|
||
metadata: pack.metadata,
|
||
yamlSource: pack.yamlSource,
|
||
documentType: pack.documentType,
|
||
mainType: pack.mainType,
|
||
subtype: pack.subtype,
|
||
fields,
|
||
subDocuments,
|
||
visualElements,
|
||
rules
|
||
}), [fields, pack, rules, subDocuments, visualElements]);
|
||
|
||
const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]);
|
||
const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]);
|
||
const validationIssues = useMemo(() => validateRule(currentRule, dependencyOptions), [currentRule, dependencyOptions]);
|
||
const yamlPreview = useMemo(() => currentRule ? buildRuleYamlPreview(editableConfig, currentRule) : '', [currentRule, editableConfig]);
|
||
const ruleGroups = useMemo(() => Array.from(new Set(rules.map(rule => rule.group || '未分组'))), [rules]);
|
||
const ruleTypeOptions = useMemo(() => uniqueOptions([
|
||
...rules.map(rule => rule.type),
|
||
'deterministic',
|
||
'ai_rule',
|
||
'rule_group'
|
||
]), [rules]);
|
||
const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]);
|
||
const selectedDependencyOptions = useMemo(() => {
|
||
return ruleDraft.dependencies.map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
|
||
}, [dependencyOptionMap, ruleDraft.dependencies]);
|
||
const resolvedCurrentDependencies = useMemo(() => {
|
||
return resolveRuleDependencies(currentRule, rulesById);
|
||
}, [currentRule, rulesById]);
|
||
const currentRuleWithResolvedDependencies = useMemo(() => (
|
||
currentRule ? { ...currentRule, dependencies: resolvedCurrentDependencies } : undefined
|
||
), [currentRule, resolvedCurrentDependencies]);
|
||
const currentDependencyRows = useMemo(() => {
|
||
return resolvedCurrentDependencies.map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
|
||
}, [dependencyOptionMap, resolvedCurrentDependencies]);
|
||
const currentRuleFields = useMemo(
|
||
() => fields.filter((field) => matchesCurrentRuleDependency(currentRuleWithResolvedDependencies, [field.name])),
|
||
[currentRuleWithResolvedDependencies, fields],
|
||
);
|
||
const currentRuleSubDocuments = useMemo(
|
||
() => subDocuments.filter((document) => matchesCurrentRuleDependency(currentRuleWithResolvedDependencies, [document.name, document.id])),
|
||
[currentRuleWithResolvedDependencies, subDocuments],
|
||
);
|
||
const currentRuleVisualElements = useMemo(
|
||
() => visualElements.filter((item) => matchesCurrentRuleDependency(currentRuleWithResolvedDependencies, [
|
||
item.id,
|
||
item.name,
|
||
`visual.${item.id}`,
|
||
`visual.${item.name || item.id}`,
|
||
item.type,
|
||
])),
|
||
[currentRuleWithResolvedDependencies, visualElements],
|
||
);
|
||
const dialogDependencyOptions = useMemo(() => {
|
||
const selectedValues = new Set(ruleDraft.dependencies);
|
||
return uniqueDependencyOptions([
|
||
...selectedDependencyOptions,
|
||
...dependencyOptions
|
||
]).sort((left, right) => {
|
||
const selectedDelta = Number(selectedValues.has(right.value)) - Number(selectedValues.has(left.value));
|
||
if (selectedDelta !== 0) return selectedDelta;
|
||
return left.label.localeCompare(right.label, 'zh-CN');
|
||
});
|
||
}, [dependencyOptions, ruleDraft.dependencies, selectedDependencyOptions]);
|
||
const filteredDependencyOptions = useMemo(() => {
|
||
const keyword = dependencySearch.trim().toLowerCase();
|
||
return dialogDependencyOptions.filter(option => {
|
||
if (!keyword) return true;
|
||
return [option.value, option.label, option.source, option.group]
|
||
.some(text => text.toLowerCase().includes(keyword));
|
||
});
|
||
}, [dialogDependencyOptions, dependencySearch]);
|
||
const dependencyGroups = useMemo(() => {
|
||
const groups = new Map<string, typeof filteredDependencyOptions>();
|
||
filteredDependencyOptions.forEach(option => {
|
||
const current = groups.get(option.group) || [];
|
||
current.push(option);
|
||
groups.set(option.group, current);
|
||
});
|
||
return Array.from(groups.entries());
|
||
}, [filteredDependencyOptions]);
|
||
const isDependencySearching = Boolean(dependencySearch.trim());
|
||
const defaultExpandedDependencyGroups = useMemo(() => {
|
||
return getDefaultExpandedDependencyGroups(dialogDependencyOptions, dependencySelection);
|
||
}, [dialogDependencyOptions, dependencySelection]);
|
||
const dependencyDialogEmptyText = dependencySearch.trim() ? '没有匹配的字段。' : '当前文档类型暂无可追加字段。';
|
||
const hasErrors = validationIssues.some(issue => issue.severity === 'error');
|
||
const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]);
|
||
const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error');
|
||
const 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 saveButtonBusy = saveFetcher.state !== 'idle';
|
||
const latestDraftVersion = useMemo(
|
||
() => versionItems.find((item) => !['published', 'rollback'].includes(item.status)),
|
||
[versionItems],
|
||
);
|
||
const rollbackVersionOptions = useMemo(
|
||
() => versionItems,
|
||
[versionItems],
|
||
);
|
||
const rollbackOptions = useMemo(
|
||
() => rollbackVersionOptions.filter((item) => item.id !== pack.currentVersionId),
|
||
[rollbackVersionOptions, pack.currentVersionId],
|
||
);
|
||
const packFilterMainType = pack.businessType || pack.mainType;
|
||
const currentResolvedVersion = useMemo(
|
||
() => {
|
||
if (pack.currentVersionId) {
|
||
return versionItems.find((item) => item.id === pack.currentVersionId) || null;
|
||
}
|
||
if (pack.fallbackVersionId) {
|
||
return versionItems.find((item) => item.id === pack.fallbackVersionId) || null;
|
||
}
|
||
return null;
|
||
},
|
||
[pack.currentVersionId, pack.fallbackVersionId, versionItems],
|
||
);
|
||
const versionStatusLabel = (status: string | undefined) => {
|
||
const normalized = String(status || '').trim().toLowerCase();
|
||
if (normalized === 'published') return '已发布';
|
||
if (normalized === 'rollback') return '被回滚替换';
|
||
if (normalized === 'draft') return '草稿';
|
||
if (normalized === 'deprecated') return '历史废弃版本';
|
||
return status || '-';
|
||
};
|
||
|
||
useEffect(() => {
|
||
const hasSelectedVersion = rollbackOptions.some((item) => String(item.id) === selectedRollbackVersionId);
|
||
const selectedIsCurrent = selectedRollbackVersionId !== '' && String(pack.currentVersionId || '') === selectedRollbackVersionId;
|
||
if (hasSelectedVersion && !selectedIsCurrent) {
|
||
return;
|
||
}
|
||
setSelectedRollbackVersionId(rollbackOptions[0] ? String(rollbackOptions[0].id) : '');
|
||
}, [pack.currentVersionId, rollbackOptions, selectedRollbackVersionId]);
|
||
|
||
useEffect(() => {
|
||
if (!saveFetcher.data) return;
|
||
if (saveFetcher.data.success) {
|
||
setDraftSaved(saveFetcher.data.intent === 'save');
|
||
setSaveError('');
|
||
if (saveFetcher.data.intent === 'save') {
|
||
if (saveFetcher.data.versionId) {
|
||
setVersionItems((current) => {
|
||
const nextVersion: RuleVersionItem = {
|
||
id: saveFetcher.data?.versionId || 0,
|
||
ruleSetId: current[0]?.ruleSetId || 0,
|
||
versionNo: saveFetcher.data?.versionNo || '-',
|
||
status: 'draft',
|
||
ossUrl: current.find((item) => item.id === saveFetcher.data?.versionId)?.ossUrl || '',
|
||
changeNote: 'rulesTest.detail 保存评查点草稿',
|
||
publishedAt: current.find((item) => item.id === saveFetcher.data?.versionId)?.publishedAt || null,
|
||
};
|
||
const existed = current.some((item) => item.id === nextVersion.id);
|
||
if (existed) {
|
||
return current.map((item) => (item.id === nextVersion.id ? { ...item, ...nextVersion } : item));
|
||
}
|
||
return [nextVersion, ...current];
|
||
});
|
||
}
|
||
setSaveMessage(saveFetcher.data.versionNo
|
||
? `规则草稿已保存为版本 ${saveFetcher.data.versionNo}`
|
||
: saveFetcher.data.message || '规则草稿已保存');
|
||
return;
|
||
}
|
||
if (saveFetcher.data.intent === 'publish') {
|
||
setSaveMessage(saveFetcher.data.message || (saveFetcher.data.intent === 'publish' ? '规则版本已发布' : '规则版本已回滚'));
|
||
revalidator.revalidate();
|
||
return;
|
||
}
|
||
setSaveMessage(saveFetcher.data.message || '规则版本已回滚');
|
||
revalidator.revalidate();
|
||
return;
|
||
}
|
||
setDraftSaved(false);
|
||
setSaveMessage('');
|
||
setSaveError(saveFetcher.data.message || '规则草稿保存失败');
|
||
}, [revalidator, saveFetcher.data]);
|
||
|
||
const openRuleEditor = (rule?: RuleSummary) => {
|
||
setRuleDraft(rule ? { ...rule } : emptyRuleDraft(ruleGroups[0]));
|
||
setDependencyDialogOpen(false);
|
||
setDependencySearch('');
|
||
setDependencySelection(rule?.dependencies || []);
|
||
setExpandedDependencyGroups([]);
|
||
setEditor({ kind: 'rule', mode: rule ? 'edit' : 'create', id: rule?.id });
|
||
};
|
||
|
||
const openDependencyDialog = () => {
|
||
setDependencySelection(ruleDraft.dependencies);
|
||
setDependencySearch('');
|
||
setExpandedDependencyGroups(getDefaultExpandedDependencyGroups(dialogDependencyOptions, ruleDraft.dependencies));
|
||
setDependencyDialogOpen(true);
|
||
};
|
||
|
||
const updateDependencySearch = (value: string) => {
|
||
setDependencySearch(value);
|
||
if (!value.trim()) {
|
||
setExpandedDependencyGroups(defaultExpandedDependencyGroups);
|
||
}
|
||
};
|
||
|
||
const toggleDependencyGroup = (group: string) => {
|
||
setExpandedDependencyGroups(current => (
|
||
current.includes(group)
|
||
? current.filter(item => item !== group)
|
||
: [...current, group]
|
||
));
|
||
};
|
||
|
||
const applyDependencySelection = () => {
|
||
setRuleDraft({ ...ruleDraft, dependencies: dependencySelection });
|
||
setDependencyDialogOpen(false);
|
||
};
|
||
|
||
const insertDependencyVariable = (value: string) => {
|
||
const variable = `{{${value}}}`;
|
||
const textarea = promptEditorRef.current;
|
||
const prompt = ruleDraft.prompt || '';
|
||
const start = textarea?.selectionStart ?? prompt.length;
|
||
const end = textarea?.selectionEnd ?? prompt.length;
|
||
const prefix = prompt.slice(0, start);
|
||
const suffix = prompt.slice(end);
|
||
const nextPrompt = `${prefix}${variable}${suffix}`;
|
||
|
||
setRuleDraft({ ...ruleDraft, prompt: nextPrompt });
|
||
|
||
window.setTimeout(() => {
|
||
if (!promptEditorRef.current) return;
|
||
const nextCursor = start + variable.length;
|
||
promptEditorRef.current.focus();
|
||
promptEditorRef.current.setSelectionRange(nextCursor, nextCursor);
|
||
}, 0);
|
||
};
|
||
|
||
const removeDependency = (value: string) => {
|
||
setRuleDraft({
|
||
...ruleDraft,
|
||
dependencies: ruleDraft.dependencies.filter(dependency => dependency !== value)
|
||
});
|
||
};
|
||
|
||
const 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 removeCurrentRuleDependencies = (targets: string[]) => {
|
||
if (!currentRule) return;
|
||
const normalizedTargets = targets.map((item) => String(item || '').trim()).filter(Boolean);
|
||
if (normalizedTargets.length === 0) return;
|
||
setRules((current) => current.map((rule) => {
|
||
if (rule.id !== currentRule.id) return rule;
|
||
return {
|
||
...rule,
|
||
dependencies: rule.dependencies.filter((dependency) => (
|
||
!normalizedTargets.some((target) => dependency === target || dependency.startsWith(`${target}.`))
|
||
)),
|
||
};
|
||
}));
|
||
};
|
||
|
||
const saveRule = () => {
|
||
if (!editor || editor.kind !== 'rule') return;
|
||
const existingRule = editor.id ? rules.find(rule => rule.id === editor.id) : undefined;
|
||
const normalizedRule: RuleSummary = {
|
||
...ruleDraft,
|
||
id: ruleDraft.id || makeId('rule'),
|
||
ruleId: ruleDraft.ruleId || ruleDraft.id,
|
||
group: ruleDraft.group || '未分组',
|
||
checkTypes: ruleDraft.type === 'ai_rule' ? uniqueOptions([...ruleDraft.checkTypes, 'ai']) : ruleDraft.checkTypes,
|
||
appliesIn: existingRule?.appliesIn || [],
|
||
scope: existingRule?.scope || [],
|
||
stageCount: existingRule?.stageCount || ruleDraft.subRules.length
|
||
};
|
||
setRules(current => editor.mode === 'edit'
|
||
? current.map(rule => rule.id === editor.id ? normalizedRule : rule)
|
||
: [...current, normalizedRule]);
|
||
setSelectedRuleKey(ruleKey(normalizedRule));
|
||
setDraftSaved(false);
|
||
setSaveMessage('');
|
||
setSaveError('');
|
||
setEditor(null);
|
||
};
|
||
|
||
const 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',
|
||
allowed: fieldDraft.type === 'enum' ? (fieldDraft.allowed || []).map((item) => String(item || '').trim()).filter(Boolean) : [],
|
||
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 previousDocument = editor.mode === 'edit'
|
||
? subDocuments.find((document) => document.id === editor.id)
|
||
: undefined;
|
||
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',
|
||
allowed: field.type === 'enum' ? (field.allowed || []).map((item) => String(item || '').trim()).filter(Boolean) : [],
|
||
})),
|
||
};
|
||
setSubDocuments((current) => editor.mode === 'edit'
|
||
? current.map((document) => (document.id === editor.id ? normalizedDocument : document))
|
||
: [...current, normalizedDocument]);
|
||
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);
|
||
};
|
||
|
||
const removeDocument = (documentId: string) => {
|
||
const target = subDocuments.find((document) => document.id === documentId);
|
||
setSubDocuments((current) => current.filter((document) => document.id !== documentId));
|
||
removeCurrentRuleDependencies([
|
||
documentId,
|
||
target?.name || '',
|
||
]);
|
||
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,
|
||
allowed: [],
|
||
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 previousVisual = editor.mode === 'edit'
|
||
? visualElements.find((item) => item.id === editor.id)
|
||
: undefined;
|
||
const normalizedType = visualDraft.type || '签章';
|
||
const normalizedVisual: VisualElementSummary = {
|
||
...visualDraft,
|
||
id: visualDraft.id || makeId('visual'),
|
||
name: visualDraft.name || visualDraft.id,
|
||
type: normalizedType,
|
||
required: visualDraft.required || 'true',
|
||
requiredFrom: visualDraft.requiredFrom || '',
|
||
signerRoles: normalizedType === '签名' ? (visualDraft.signerRoles || []).filter(Boolean) : [],
|
||
signatureTypes: (visualDraft.signatureTypes || []).filter(Boolean),
|
||
privateSealRestricted: normalizedType === '签名' ? Boolean(visualDraft.privateSealRestricted) : false,
|
||
expectedMatchField: visualDraft.expectedMatchField || '',
|
||
expectedMatchAlternatives: (visualDraft.expectedMatchAlternatives || []).filter(Boolean),
|
||
prompt: normalizedType === '骑缝章' ? (visualDraft.prompt || '') : '',
|
||
};
|
||
setVisualElements((current) => editor.mode === 'edit'
|
||
? current.map((item) => (item.id === editor.id ? normalizedVisual : item))
|
||
: [...current, normalizedVisual]);
|
||
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.name || normalizedVisual.id}`],
|
||
);
|
||
setEditor(null);
|
||
};
|
||
|
||
const removeVisual = (visualId: string) => {
|
||
const target = visualElements.find((item) => item.id === visualId);
|
||
setVisualElements((current) => current.filter((item) => item.id !== visualId));
|
||
removeCurrentRuleDependencies([
|
||
visualId,
|
||
target?.name || '',
|
||
`visual.${visualId}`,
|
||
`visual.${target?.name || visualId}`,
|
||
]);
|
||
setDraftSaved(false);
|
||
};
|
||
|
||
const saveDraftToServer = () => {
|
||
if (hasConfigErrors) {
|
||
setShowValidation(true);
|
||
setSaveError('当前规则配置仍有必改问题,请先处理后再保存。');
|
||
setSaveMessage('');
|
||
return;
|
||
}
|
||
if (serializedYamlResult.error) {
|
||
setSaveError(serializedYamlResult.error);
|
||
setSaveMessage('');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('ruleType', pack.metadata.typeId || '');
|
||
formData.append('yamlText', prepareDraftYamlForSave(fullYamlText));
|
||
formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`);
|
||
formData.append('intent', 'save');
|
||
saveFetcher.submit(formData, { method: 'post' });
|
||
};
|
||
|
||
const publishDraftVersion = () => {
|
||
if (!latestDraftVersion) {
|
||
setSaveError('当前没有可发布的新版本,请先保存规则配置。');
|
||
setSaveMessage('');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('intent', 'publish');
|
||
formData.append('ruleType', pack.metadata.typeId || '');
|
||
formData.append('versionId', String(latestDraftVersion.id));
|
||
saveFetcher.submit(formData, { method: 'post' });
|
||
};
|
||
|
||
const rollbackRuleVersion = () => {
|
||
const rollbackTargetVersion = rollbackOptions.find((item) => String(item.id) === selectedRollbackVersionId) || null;
|
||
if (!rollbackTargetVersion) {
|
||
setSaveError('当前没有可回滚的历史可用版本。');
|
||
setSaveMessage('');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('intent', 'rollback');
|
||
formData.append('ruleType', pack.metadata.typeId || '');
|
||
formData.append('versionId', String(rollbackTargetVersion.id));
|
||
saveFetcher.submit(formData, { method: 'post' });
|
||
};
|
||
|
||
const resetDraft = () => {
|
||
setRules(pack.rules);
|
||
setFields(pack.fields);
|
||
setSubDocuments(pack.subDocuments);
|
||
setVisualElements(pack.visualElements);
|
||
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
||
setDependencyDialogOpen(false);
|
||
setDependencySearch('');
|
||
setDependencySelection([]);
|
||
setShowValidation(false);
|
||
setShowYamlPreview(false);
|
||
setDraftSaved(false);
|
||
setSaveMessage('');
|
||
setSaveError('');
|
||
};
|
||
|
||
const dependencyColumns = [
|
||
{
|
||
title: '依赖字段',
|
||
key: 'label',
|
||
width: '38%',
|
||
render: (_: unknown, record: DependencyOption) => (
|
||
<div className="rule-name">
|
||
<strong>{record.label}</strong>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
title: '来源',
|
||
dataIndex: 'source' as keyof DependencyOption,
|
||
key: 'source',
|
||
width: '26%'
|
||
},
|
||
{
|
||
title: '分组',
|
||
dataIndex: 'group' as keyof DependencyOption,
|
||
key: 'group',
|
||
width: '36%'
|
||
}
|
||
];
|
||
|
||
const backLink = `/rulesTest/list?documentType=${encodeURIComponent(pack.documentType)}&mainType=${encodeURIComponent(packFilterMainType)}&subtype=${encodeURIComponent(pack.subtype)}`;
|
||
|
||
return (
|
||
<div className="rules-test-page rules-page">
|
||
<div className="yaml-layout-single">
|
||
<Card className="ant-card config-toolbar-card">
|
||
<div className="config-toolbar">
|
||
<div>
|
||
<div className="config-toolbar-title">{currentRule?.name || '未找到评查点'}</div>
|
||
<div className="config-toolbar-desc">
|
||
{pack.documentType} / {pack.mainType} / {pack.subtype} / {currentRule?.ruleId || '-'}
|
||
</div>
|
||
<div className="config-toolbar-desc">
|
||
当前版本:{currentResolvedVersion?.versionNo || '-'} / 当前状态:{versionStatusLabel(currentResolvedVersion?.status)}
|
||
</div>
|
||
</div>
|
||
<div className="config-toolbar-actions">
|
||
<Link to={backLink} className="ant-btn ant-btn-default">
|
||
<i className="ri-arrow-left-line mr-1.5"></i>返回列表
|
||
</Link>
|
||
<button type="button" className="ant-btn ant-btn-default" onClick={() => setShowValidation(current => !current)}>
|
||
<i className="ri-shield-check-line mr-1.5"></i>校验评查点
|
||
</button>
|
||
<button type="button" className="ant-btn ant-btn-default" onClick={() => setShowYamlPreview(current => !current)}>
|
||
<i className="ri-file-code-line mr-1.5"></i>{showYamlPreview ? '收起片段' : 'YAML 片段'}
|
||
</button>
|
||
<button type="button" className="ant-btn ant-btn-default" onClick={resetDraft}>
|
||
<i className="ri-refresh-line mr-1.5"></i>重置修改
|
||
</button>
|
||
<button type="button" className="ant-btn ant-btn-primary" disabled={saveButtonBusy} onClick={saveDraftToServer}>
|
||
<i className={`${saveButtonBusy ? 'ri-loader-4-line' : 'ri-save-line'} mr-1.5`}></i>
|
||
{saveButtonBusy ? '保存中...' : '保存规则配置'}
|
||
</button>
|
||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !latestDraftVersion} onClick={publishDraftVersion}>
|
||
<i className="ri-upload-cloud-line mr-1.5"></i>发布版本
|
||
</button>
|
||
<select
|
||
className="rules-version-select"
|
||
value={selectedRollbackVersionId}
|
||
onChange={(event) => setSelectedRollbackVersionId(event.target.value)}
|
||
disabled={saveButtonBusy || rollbackVersionOptions.length === 0}
|
||
>
|
||
{rollbackVersionOptions.length === 0 ? (
|
||
<option value="">暂无可回滚版本</option>
|
||
) : rollbackVersionOptions.map((item) => (
|
||
<option key={item.id} value={item.id} disabled={item.id === pack.currentVersionId}>
|
||
{item.versionNo} · {versionStatusLabel(item.status)}{item.id === pack.currentVersionId ? ' · 当前版本' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !selectedRollbackVersionId} onClick={rollbackRuleVersion}>
|
||
<i className="ri-history-line mr-1.5"></i>回滚版本
|
||
</button>
|
||
<button type="button" className="ant-btn ant-btn-primary" onClick={() => currentRule ? openRuleEditor(currentRule) : openRuleEditor()}>
|
||
<i className={`${currentRule ? 'ri-edit-line' : 'ri-add-line'} mr-1.5`}></i>{currentRule ? '编辑评查点' : '新增评查点'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{draftSaved && (
|
||
<div className="draft-tip">
|
||
<i className="ri-checkbox-circle-line"></i>
|
||
草稿已保存。
|
||
</div>
|
||
)}
|
||
{saveMessage && (
|
||
<div className="draft-tip">
|
||
<i className="ri-checkbox-circle-line"></i>
|
||
{saveMessage}
|
||
</div>
|
||
)}
|
||
{saveError && (
|
||
<div className="draft-tip danger">
|
||
<i className="ri-error-warning-line"></i>
|
||
{saveError}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{showValidation && (
|
||
<Card className="ant-card validation-card" title="评查点校验">
|
||
<div className="validation-summary">
|
||
<Tag color={hasErrors ? 'red' : 'green'}>{hasErrors ? '存在必改问题' : '可提交验证'}</Tag>
|
||
<span>当前评查点发现 {validationIssues.length} 项提示,其中 {validationIssues.filter(issue => issue.severity === 'error').length} 项需要处理。</span>
|
||
</div>
|
||
<div className="validation-list">
|
||
{validationIssues.length === 0 ? (
|
||
<div className="empty-state">未发现配置问题。</div>
|
||
) : validationIssues.map(issue => (
|
||
<div key={issue.id} className={`validation-item ${issue.severity}`}>
|
||
<Tag color={issueColor(issue.severity)}>{issue.severity === 'error' ? '必改' : '提醒'}</Tag>
|
||
<strong>{issue.area}</strong>
|
||
<span>{issue.target}</span>
|
||
<p>{issue.message}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{showYamlPreview && currentRule && (
|
||
<Card className="ant-card" title="当前评查点 YAML 片段">
|
||
<pre className="yaml-source yaml-source-highlighted">
|
||
<code>{yamlPreview.split('\n').map(renderYamlLine)}</code>
|
||
</pre>
|
||
</Card>
|
||
)}
|
||
|
||
{currentRule ? (
|
||
<>
|
||
<Card className="ant-card" title="评查点定义">
|
||
<div className="rule-detail-grid">
|
||
<div className="info-box">
|
||
<label>评查点编码</label>
|
||
<div>{currentRule.ruleId || '-'}</div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>规则组</label>
|
||
<div>{currentRule.group || '-'}</div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>检查方式</label>
|
||
<div>{ruleTypeLabel(currentRule.type)}</div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>业务阶段</label>
|
||
<div>{currentRule.appliesIn.length > 0 ? currentRule.appliesIn.map(phaseLabel).join('、') : '全部阶段'}</div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>风险等级</label>
|
||
<div><Tag color={riskColor(currentRule.risk)} size="sm">{riskLabel(currentRule.risk)}</Tag></div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>分值</label>
|
||
<div>{currentRule.score || '-'}</div>
|
||
</div>
|
||
<div className="info-box">
|
||
<label>检查方法</label>
|
||
<div>{currentRule.checkTypes.length > 0 ? currentRule.checkTypes.map(checkTypeLabel).join('、') : '-'}</div>
|
||
</div>
|
||
</div>
|
||
{currentRule.description && (
|
||
<div className="rule-description-block">
|
||
<label>规则说明</label>
|
||
<p>{currentRule.description}</p>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{(fields.length > 0 || subDocuments.length > 0 || visualElements.length > 0) && (
|
||
<>
|
||
{fields.length > 0 && <span id="fields" className="section-anchor" aria-hidden="true"></span>}
|
||
{subDocuments.length > 0 && <span id="sub-documents" 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}</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
|
||
className="ant-card"
|
||
title={`依赖字段 (${resolvedCurrentDependencies.length}项)`}
|
||
>
|
||
<Table
|
||
className="rules-test-table"
|
||
columns={dependencyColumns}
|
||
dataSource={currentDependencyRows}
|
||
rowKey="value"
|
||
emptyText={<div className="empty-state">当前评查点暂未配置依赖字段。</div>}
|
||
/>
|
||
</Card>
|
||
|
||
<span id="rules" className="section-anchor" aria-hidden="true"></span>
|
||
<Card className="ant-card" title="评查规则">
|
||
<div className="rule-content-stack">
|
||
{currentRule.subRules.length > 0 && (
|
||
<div className="drawer-subsection">
|
||
<div className="drawer-subsection-header">
|
||
<div>
|
||
<strong>{currentRule.type === 'rule_group' ? `子规则与逻辑(${currentRule.subRules.length}步)` : `规则步骤(${currentRule.subRules.length}步)`}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="subrule-list">
|
||
{currentRule.subRules.map(subRule => (
|
||
<div key={subRule.id} className="subrule-item">
|
||
<span className="subrule-index">{subRule.id}</span>
|
||
<div>
|
||
<strong>
|
||
{checkTypeLabel(subRule.check)}
|
||
{currentRule.logic && (
|
||
<Tag color={isStepReferenced(currentRule.logic, subRule.id) ? 'green' : 'orange'} size="sm">
|
||
{isStepReferenced(currentRule.logic, subRule.id) ? '参与逻辑' : '未参与逻辑'}
|
||
</Tag>
|
||
)}
|
||
</strong>
|
||
<span>{subRule.content}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{currentRule.logic && (
|
||
<div className="logic-expression">
|
||
<label>逻辑运算式</label>
|
||
<div>{currentRule.logic}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{currentRule.type === 'rule_group' && currentRule.subRules.length === 0 && (
|
||
<div className="drawer-subsection">
|
||
<div className="drawer-subsection-header">
|
||
<div>
|
||
<strong>子规则与逻辑</strong>
|
||
</div>
|
||
</div>
|
||
<div className="subrule-list">
|
||
{currentRule.subRuleIds.length > 0 ? currentRule.subRuleIds.map(ruleId => {
|
||
const referencedRule = rulesById.get(ruleId);
|
||
return (
|
||
<div key={ruleId} className="subrule-item">
|
||
<span className="subrule-index">{ruleId}</span>
|
||
<div>
|
||
<strong>{referencedRule?.name || '引用规则'}</strong>
|
||
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}) : (
|
||
<div className="drawer-empty">当前规则组合还没有子规则内容。</div>
|
||
)}
|
||
</div>
|
||
<div className="logic-expression">
|
||
<label>逻辑运算式</label>
|
||
<div>{currentRule.logic || '-'}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(currentRule.type === 'ai_rule' || currentRule.checkTypes.includes('ai')) && (
|
||
<div className="drawer-subsection">
|
||
<div className="drawer-subsection-header">
|
||
<div>
|
||
<strong>智能语义检查提示词</strong>
|
||
</div>
|
||
</div>
|
||
<pre className="rule-prompt-preview">{currentRule.prompt || '当前评查点尚未维护提示词。'}</pre>
|
||
</div>
|
||
)}
|
||
|
||
{currentRule.subRules.length === 0 && currentRule.type !== 'rule_group' && currentRule.type !== 'ai_rule' && !currentRule.checkTypes.includes('ai') && (
|
||
<div className="rule-description-block compact">
|
||
<label>规则内容</label>
|
||
<p>{currentRule.description || '当前评查点没有额外规则内容。'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Card className="ant-card">
|
||
<div className="empty-state">
|
||
当前子类型还没有正式评查点。你可以先新增评查点,或先维护抽取字段 / 子文档 / 视觉要素后再回到这里补规则。
|
||
</div>
|
||
</Card>
|
||
|
||
<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>
|
||
{fields.length > 0 ? (
|
||
<div className="config-item-list">
|
||
{fields.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.type === 'enum' && field.allowed && field.allowed.length > 0 ? ` · ${field.allowed.join('、')}` : ''}
|
||
{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>
|
||
{subDocuments.length > 0 ? (
|
||
<div className="config-item-list">
|
||
{subDocuments.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>
|
||
{visualElements.length > 0 ? (
|
||
<div className="config-item-list">
|
||
{visualElements.map((item) => (
|
||
<div key={item.id} className="config-item-card">
|
||
<div className="config-item-main">
|
||
<strong>{item.name || item.id}</strong>
|
||
<span>{item.type}</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">当前还没有配置视觉要素。</div>
|
||
)}
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{editor && (
|
||
<div className="rules-drawer-shell">
|
||
<button className="rules-drawer-mask" type="button" aria-label="关闭编辑抽屉" onClick={() => setEditor(null)}></button>
|
||
<aside className="rules-drawer" aria-label="评查点编辑">
|
||
<div className="rules-drawer-header">
|
||
<div>
|
||
<h3>
|
||
{editor.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>
|
||
<button type="button" className="drawer-close" onClick={() => setEditor(null)}><i className="ri-close-line"></i></button>
|
||
</div>
|
||
|
||
{editor.kind === 'rule' && (
|
||
<div className="drawer-form">
|
||
<label>
|
||
<span>评查点名称</span>
|
||
<input value={ruleDraft.name} onChange={event => setRuleDraft({ ...ruleDraft, name: event.target.value })} placeholder="如:合同金额不得为空" />
|
||
</label>
|
||
<label>
|
||
<span>评查点编码</span>
|
||
<input value={ruleDraft.ruleId} onChange={event => setRuleDraft({ ...ruleDraft, ruleId: event.target.value })} placeholder="如:CONTRACT-001" />
|
||
</label>
|
||
<label>
|
||
<span>规则组</span>
|
||
<select value={ruleDraft.group} onChange={event => setRuleDraft({ ...ruleDraft, group: event.target.value })}>
|
||
{uniqueOptions([ruleDraft.group, ...ruleGroups, '未分组']).map(group => (
|
||
<option key={group} value={group}>{group}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="drawer-grid">
|
||
<label>
|
||
<span>风险等级</span>
|
||
<select value={ruleDraft.risk} onChange={event => setRuleDraft({ ...ruleDraft, risk: event.target.value })}>
|
||
<option value="high">高</option>
|
||
<option value="medium">中</option>
|
||
<option value="low">低</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>分值</span>
|
||
<input value={ruleDraft.score} onChange={event => setRuleDraft({ ...ruleDraft, score: event.target.value })} />
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span>检查方式</span>
|
||
<select
|
||
value={ruleDraft.type}
|
||
onChange={event => {
|
||
const nextType = event.target.value;
|
||
setRuleDraft({
|
||
...ruleDraft,
|
||
type: nextType,
|
||
checkTypes: nextType === 'ai_rule'
|
||
? uniqueOptions([...ruleDraft.checkTypes, 'ai'])
|
||
: ruleDraft.checkTypes.filter(type => type !== 'ai')
|
||
});
|
||
}}
|
||
>
|
||
{uniqueOptions([ruleDraft.type, ...ruleTypeOptions]).map(type => (
|
||
<option key={type} value={type}>{ruleTypeLabel(type)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{isSmartRuleDraft && (
|
||
<label>
|
||
<span>提示词</span>
|
||
<textarea
|
||
ref={promptEditorRef}
|
||
className="prompt-editor"
|
||
value={ruleDraft.prompt}
|
||
onChange={event => setRuleDraft({ ...ruleDraft, prompt: event.target.value })}
|
||
placeholder="用自然语言描述智能语义检查的判断标准,可引用 {{字段名}} 或 {{文书名称.字段名}}。"
|
||
/>
|
||
</label>
|
||
)}
|
||
{isRuleGroupDraft && (
|
||
<div className="drawer-subsection">
|
||
<div className="drawer-subsection-header">
|
||
<div>
|
||
<strong>规则内容</strong>
|
||
</div>
|
||
</div>
|
||
<div className="subrule-list">
|
||
{ruleDraft.subRules.length > 0 ? ruleDraft.subRules.map(subRule => (
|
||
<div key={subRule.id} className="subrule-item">
|
||
<span className="subrule-index">{subRule.id}</span>
|
||
<div>
|
||
<strong>{checkTypeLabel(subRule.check)}</strong>
|
||
<span>{subRule.content}</span>
|
||
</div>
|
||
</div>
|
||
)) : ruleDraft.subRuleIds.length > 0 ? ruleDraft.subRuleIds.map(ruleId => {
|
||
const referencedRule = rulesById.get(ruleId);
|
||
return (
|
||
<div key={ruleId} className="subrule-item">
|
||
<span className="subrule-index">{ruleId}</span>
|
||
<div>
|
||
<strong>{referencedRule?.name || '引用规则'}</strong>
|
||
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}) : (
|
||
<div className="drawer-empty">当前规则还没有子规则内容。</div>
|
||
)}
|
||
</div>
|
||
<label>
|
||
<span>逻辑运算式</span>
|
||
<input
|
||
value={ruleDraft.logic}
|
||
onChange={event => setRuleDraft({ ...ruleDraft, logic: event.target.value })}
|
||
placeholder="如:1 AND 2,或 JK-002 AND JK-005"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<label>
|
||
<span>依赖字段</span>
|
||
<div className="selected-dependencies">
|
||
{selectedDependencyOptions.length === 0 ? (
|
||
<div className="drawer-empty">暂未添加依赖字段。</div>
|
||
) : selectedDependencyOptions.map(option => (
|
||
<span
|
||
key={option.value}
|
||
className="dependency-variable-button"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="dependency-variable-main"
|
||
onClick={() => insertDependencyVariable(option.value)}
|
||
title={`插入变量 {{${option.value}}}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="dependency-variable-remove"
|
||
onClick={() => removeDependency(option.value)}
|
||
aria-label={`移除依赖字段 ${option.label}`}
|
||
>
|
||
<i className="ri-close-line"></i>
|
||
</button>
|
||
</span>
|
||
))}
|
||
<Button size="small" type="default" icon="ri-add-line" onClick={openDependencyDialog}>追加字段</Button>
|
||
</div>
|
||
</label>
|
||
<label>
|
||
<span>规则说明</span>
|
||
<textarea value={ruleDraft.description} onChange={event => setRuleDraft({ ...ruleDraft, description: event.target.value })} placeholder="用业务语言描述该评查点如何判断" />
|
||
</label>
|
||
<div className="drawer-actions">
|
||
<Button type="default" onClick={() => setEditor(null)}>取消</Button>
|
||
<Button type="primary" onClick={saveRule}>保存评查点</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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, allowed: event.target.value === 'enum' ? (fieldDraft.allowed || []) : [] })}>
|
||
{['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>
|
||
{fieldDraft.type === 'enum' && (
|
||
<label>
|
||
<span>可选值</span>
|
||
<input
|
||
value={(fieldDraft.allowed || []).join(',')}
|
||
onChange={(event) => setFieldDraft({ ...fieldDraft, allowed: event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) })}
|
||
placeholder="如:有,无;男,女"
|
||
/>
|
||
</label>
|
||
)}
|
||
<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>
|
||
</label>
|
||
<label>
|
||
<span>内部字段</span>
|
||
<div className="document-draft-fields">
|
||
{(documentDraft.fields || []).map((field) => (
|
||
<div key={field.id} className="document-draft-field-row">
|
||
<input
|
||
value={field.group || ''}
|
||
onChange={(event) => updateDocumentField(field.id, { group: event.target.value })}
|
||
placeholder="分组"
|
||
/>
|
||
<input
|
||
value={field.name || ''}
|
||
onChange={(event) => updateDocumentField(field.id, { name: event.target.value })}
|
||
placeholder="字段名称"
|
||
/>
|
||
<select
|
||
value={field.type || 'verbatim'}
|
||
onChange={(event) => updateDocumentField(field.id, { type: event.target.value, allowed: event.target.value === 'enum' ? (field.allowed || []) : [] })}
|
||
>
|
||
{['verbatim', 'string', 'money', 'date', 'enum', 'number'].map((type) => (
|
||
<option key={type} value={type}>{fieldTypeLabel(type)}</option>
|
||
))}
|
||
</select>
|
||
{field.type === 'enum' && (
|
||
<input
|
||
value={(field.allowed || []).join(',')}
|
||
onChange={(event) => updateDocumentField(field.id, { allowed: event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) })}
|
||
placeholder="可选值"
|
||
/>
|
||
)}
|
||
<input
|
||
value={field.description || ''}
|
||
onChange={(event) => updateDocumentField(field.id, { description: event.target.value })}
|
||
placeholder="字段说明"
|
||
/>
|
||
<button type="button" className="ant-btn ant-btn-default" onClick={() => removeDocumentField(field.id)}>删除</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="ant-btn ant-btn-default" onClick={addDocumentField}>
|
||
<i className="ri-add-line mr-1.5"></i>新增内部字段
|
||
</button>
|
||
</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>
|
||
<span>要素类型</span>
|
||
<select
|
||
value={visualDraft.type}
|
||
onChange={(event) => setVisualDraft((current) => ({
|
||
...current,
|
||
type: event.target.value,
|
||
signerRoles: event.target.value === '签名' ? current.signerRoles || [] : [],
|
||
privateSealRestricted: event.target.value === '签名' ? Boolean(current.privateSealRestricted) : false,
|
||
prompt: event.target.value === '骑缝章' ? current.prompt || '' : '',
|
||
}))}
|
||
>
|
||
{['签章', '签名', '骑缝章'].map((type) => <option key={type} value={type}>{type}</option>)}
|
||
</select>
|
||
</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>
|
||
</div>
|
||
<label>
|
||
<span>{visualDraft.type === '签名' ? '签名类型(逗号分隔)' : '签章类型(逗号分隔)'}</span>
|
||
<input
|
||
value={(visualDraft.signatureTypes || []).join(',')}
|
||
onChange={(event) => setVisualDraft({ ...visualDraft, signatureTypes: event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) })}
|
||
placeholder={visualDraft.type === '签名' ? '如:签名,私章' : '如:合同专用章,公章'}
|
||
/>
|
||
</label>
|
||
{visualDraft.type === '签名' && (
|
||
<>
|
||
<label>
|
||
<span>签署角色(逗号分隔)</span>
|
||
<input
|
||
value={(visualDraft.signerRoles || []).join(',')}
|
||
onChange={(event) => setVisualDraft({ ...visualDraft, signerRoles: event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) })}
|
||
placeholder="如:甲方,乙方,承办人"
|
||
/>
|
||
</label>
|
||
<label className="drawer-checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(visualDraft.privateSealRestricted)}
|
||
onChange={(event) => setVisualDraft({ ...visualDraft, privateSealRestricted: event.target.checked })}
|
||
/>
|
||
<span>限制为私章 / 私人签章场景</span>
|
||
</label>
|
||
</>
|
||
)}
|
||
<div className="drawer-actions">
|
||
<Button type="default" onClick={() => setEditor(null)}>取消</Button>
|
||
<Button type="primary" onClick={saveVisual}>保存视觉要素</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
{dependencyDialogOpen && (
|
||
<div className="dependency-dialog-shell" role="dialog" aria-modal="true" aria-label="追加依赖字段">
|
||
<button className="dependency-dialog-mask" type="button" aria-label="关闭依赖字段选择" onClick={() => setDependencyDialogOpen(false)}></button>
|
||
<div className="dependency-dialog">
|
||
<div className="dependency-dialog-header">
|
||
<div>
|
||
<h3>追加依赖字段</h3>
|
||
</div>
|
||
<button type="button" className="drawer-close" onClick={() => setDependencyDialogOpen(false)}><i className="ri-close-line"></i></button>
|
||
</div>
|
||
<div className="dependency-dialog-search">
|
||
<i className="ri-search-line"></i>
|
||
<input value={dependencySearch} onChange={event => updateDependencySearch(event.target.value)} placeholder="搜索字段、文书、字段组" />
|
||
</div>
|
||
<div className="dependency-dialog-body">
|
||
{dependencyGroups.length === 0 ? (
|
||
<div className="drawer-empty">{dependencyDialogEmptyText}</div>
|
||
) : dependencyGroups.map(([group, options]) => {
|
||
const isExpanded = isDependencySearching || expandedDependencyGroups.includes(group);
|
||
return (
|
||
<div key={group} className={`dependency-option-group${isExpanded ? ' expanded' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="dependency-option-group-title"
|
||
aria-expanded={isExpanded}
|
||
onClick={() => toggleDependencyGroup(group)}
|
||
>
|
||
<i className="ri-arrow-right-s-line"></i>
|
||
<span>{group}</span>
|
||
<em>{options.length} 项</em>
|
||
</button>
|
||
{isExpanded && (
|
||
<div className="dependency-option-list">
|
||
{options.map(option => (
|
||
<label key={`${option.group}-${option.value}`} className="dependency-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={dependencySelection.includes(option.value)}
|
||
onChange={event => {
|
||
const nextSelection = event.target.checked
|
||
? uniqueOptions([...dependencySelection, option.value])
|
||
: dependencySelection.filter(value => value !== option.value);
|
||
setDependencySelection(nextSelection);
|
||
}}
|
||
/>
|
||
<span className="dependency-option-main">
|
||
<span className="dependency-option-name">{option.label}</span>
|
||
<em>{option.source}</em>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="dependency-dialog-actions">
|
||
<span>已选择 {dependencySelection.length} 项</span>
|
||
<div>
|
||
<Button type="default" onClick={() => setDependencyDialogOpen(false)}>取消</Button>
|
||
<Button type="primary" onClick={applyDependencySelection}>确认追加</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|