deploy-ease-platform/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx
dengqichen 697f0a69bf 1
2025-10-21 16:33:44 +08:00

401 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo } from 'react';
import { FormItem, Input, NumberPicker, Select, FormLayout, Switch } from '@formily/antd-v5';
import { createForm } from '@formily/core';
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
import { Save, RotateCcw } from 'lucide-react';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import type { FlowNode, FlowNodeData } from '../types';
import type { WorkflowNodeDefinition } from '../nodes/types';
import { isConfigurableNode } from '../nodes/types';
// 创建Schema组件
const SchemaField = createSchemaField({
components: {
FormItem,
Input,
NumberPicker,
Select,
FormLayout,
Switch,
'Input.TextArea': Input.TextArea,
},
});
interface NodeConfigModalProps {
visible: boolean;
node: FlowNode | null;
onCancel: () => void;
onOk: (nodeId: string, updatedData: Partial<FlowNodeData>) => void;
}
const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
visible,
node,
onCancel,
onOk
}) => {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// 获取节点定义
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
// ✅ 根据节点 ID 重新创建表单实例(修复切换节点时数据不更新的问题)
const configForm = useMemo(() => createForm(), [node?.id]);
const inputForm = useMemo(() => createForm(), [node?.id]);
// 初始化表单数据
useEffect(() => {
if (visible && node && nodeDefinition) {
const nodeData = node.data || {};
// 准备默认配置
const defaultConfig = {
nodeName: nodeDefinition.nodeName,
nodeCode: nodeDefinition.nodeCode,
description: nodeDefinition.description
};
// 设置表单初始值
configForm.setInitialValues({ ...defaultConfig, ...(nodeData.configs || {}) });
configForm.reset();
if (isConfigurableNode(nodeDefinition)) {
inputForm.setInitialValues(nodeData.inputMapping || {});
inputForm.reset();
}
}
}, [visible, node, nodeDefinition, configForm, inputForm]);
// 递归处理表单值将JSON字符串转换为对象
const processFormValues = (values: Record<string, any>, schema: ISchema | undefined): Record<string, any> => {
const result: Record<string, any> = {};
if (!schema?.properties || typeof schema.properties !== 'object') return values;
Object.entries(values).forEach(([key, value]) => {
const propSchema = (schema.properties as Record<string, any>)?.[key];
// 如果是object类型且值是字符串尝试解析
if (propSchema?.type === 'object' && typeof value === 'string') {
try {
result[key] = JSON.parse(value);
} catch {
result[key] = value; // 解析失败保持原值
}
} else {
result[key] = value;
}
});
return result;
};
const handleSubmit = async () => {
if (!node || !nodeDefinition) return;
try {
setLoading(true);
// 获取表单值并转换
const configs = processFormValues(configForm.values, nodeDefinition.configSchema);
const inputMapping = isConfigurableNode(nodeDefinition)
? processFormValues(inputForm.values, nodeDefinition.inputMappingSchema)
: {};
const updatedData: Partial<FlowNodeData> = {
label: configs.nodeName || node.data.label,
configs,
inputMapping,
// outputs 保留原值(不修改,因为它是只读的输出能力定义)
outputs: node.data.outputs || [],
};
onOk(node.id, updatedData);
toast({
title: "保存成功",
description: "节点配置已成功保存",
});
onCancel();
} catch (error) {
if (error instanceof Error) {
toast({
variant: "destructive",
title: "保存失败",
description: error.message,
});
}
} finally {
setLoading(false);
}
};
const handleReset = () => {
configForm.reset();
inputForm.reset();
toast({
title: "已重置",
description: "表单已重置为初始值",
});
};
// 将JSON Schema转换为Formily Schema扩展配置
const convertToFormilySchema = (jsonSchema: ISchema): ISchema => {
const schema: ISchema = {
type: 'object',
properties: {}
};
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') return schema;
Object.entries(jsonSchema.properties as Record<string, any>).forEach(([key, prop]: [string, any]) => {
const field: any = {
title: prop.title || key,
description: prop.description,
'x-decorator': 'FormItem',
'x-decorator-props': {
tooltip: prop.description,
// 垂直布局不需要 labelCol 和 wrapperCol
},
};
// 根据类型设置组件
switch (prop.type) {
case 'string':
if (prop.enum) {
field['x-component'] = 'Select';
field['x-component-props'] = {
style: { width: '100%' }, // 统一宽度
options: prop.enum.map((v: any, i: number) => ({
label: prop.enumNames?.[i] || v,
value: v
}))
};
} else if (prop.format === 'password') {
field['x-component'] = 'Input';
field['x-component-props'] = {
type: 'password',
style: { width: '100%' } // 统一宽度
};
} else {
field['x-component'] = 'Input';
field['x-component-props'] = {
style: { width: '100%' } // 统一宽度
};
}
break;
case 'number':
case 'integer':
field['x-component'] = 'NumberPicker';
field['x-component-props'] = {
min: prop.minimum,
max: prop.maximum,
style: { width: '100%' } // 统一宽度
};
break;
case 'boolean':
field['x-component'] = 'Switch';
break;
case 'object':
field['x-component'] = 'Input.TextArea';
field['x-component-props'] = {
rows: 4,
style: { width: '100%' }, // 统一宽度
placeholder: '请输入JSON格式例如{"key": "value"}'
};
// ✅ 关键修复将object类型的default值转换为JSON字符串
if (prop.default !== undefined && typeof prop.default === 'object') {
field.default = JSON.stringify(prop.default, null, 2);
} else {
field.default = prop.default;
}
// Formily会自动处理object的序列化
field['x-validator'] = (value: any) => {
if (!value) return true;
if (typeof value === 'string') {
try {
JSON.parse(value);
return true;
} catch {
return '请输入有效的JSON格式';
}
}
return true;
};
break;
default:
field['x-component'] = 'Input';
field['x-component-props'] = {
style: { width: '100%' } // 统一宽度
};
}
// 设置默认值非object类型
if (prop.type !== 'object' && prop.default !== undefined) {
field.default = prop.default;
}
// 设置必填
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) {
field.required = true;
}
(schema.properties as Record<string, any>)[key] = field;
});
return schema;
};
if (!nodeDefinition) {
return null;
}
return (
<Sheet open={visible} onOpenChange={(open) => !open && onCancel()}>
<SheetContent className="w-[600px] sm:max-w-[600px] flex flex-col p-0">
{/* Header - 固定在顶部 */}
<div className="px-6 pt-6 pb-4 border-b">
<SheetHeader className="space-y-2">
<SheetTitle className="text-xl font-semibold">
- {nodeDefinition.nodeName}
</SheetTitle>
<SheetDescription className="text-sm text-muted-foreground">
</SheetDescription>
</SheetHeader>
</div>
{/* Content - 可滚动区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* 使用 Accordion 支持多个面板同时展开,默认展开基本配置 */}
<Accordion
type="multiple"
defaultValue={["config"]}
className="w-full"
>
{/* 基本配置 - 始终显示 */}
<AccordionItem value="config" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger>
<AccordionContent className="px-1">
<FormProvider form={configForm}>
<FormLayout layout="vertical" colon={false}>
<SchemaField schema={convertToFormilySchema(nodeDefinition.configSchema)} />
</FormLayout>
</FormProvider>
</AccordionContent>
</AccordionItem>
{/* 输入映射 - 条件显示 */}
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
<AccordionItem value="input" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger>
<AccordionContent className="px-1">
<FormProvider form={inputForm}>
<FormLayout layout="vertical" colon={false}>
<SchemaField schema={convertToFormilySchema(nodeDefinition.inputMappingSchema)} />
</FormLayout>
</FormProvider>
</AccordionContent>
</AccordionItem>
)}
{/* 输出能力 - 只读展示 */}
{isConfigurableNode(nodeDefinition) && nodeDefinition.outputs && nodeDefinition.outputs.length > 0 && (
<AccordionItem value="output" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger>
<AccordionContent className="px-1">
<div className="text-sm text-muted-foreground mb-4 p-3 bg-muted/50 rounded-md">
💡 <code className="px-1 py-0.5 bg-background rounded">{'${upstream.字段名}'}</code>
</div>
<div className="space-y-3">
{nodeDefinition.outputs.map((output) => (
<div key={output.name} className="p-3 border rounded-md bg-background">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{output.title}</span>
<code className="px-1.5 py-0.5 text-xs bg-muted rounded">{output.name}</code>
<span className="text-xs text-muted-foreground border border-border px-1.5 py-0.5 rounded">
{output.type}
</span>
{output.required && (
<span className="text-xs text-red-500">*</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{output.description}
</p>
</div>
</div>
{output.enum && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"></span>
<span className="ml-1 text-foreground">{output.enum.join(', ')}</span>
</div>
)}
{output.example !== undefined && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"></span>
<code className="ml-1 px-1.5 py-0.5 bg-muted rounded text-foreground">
{typeof output.example === 'object'
? JSON.stringify(output.example)
: String(output.example)}
</code>
</div>
)}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
</div>
{/* Footer - 固定在底部 */}
<div className="px-6 py-4 border-t bg-background">
<SheetFooter className="flex-row justify-between gap-2">
<Button
type="button"
variant="outline"
onClick={handleReset}
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
>
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={loading}
className="gap-2"
>
<Save className="h-4 w-4" />
{loading ? '保存中...' : '保存配置'}
</Button>
</div>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
);
};
export default NodeConfigModal;