401 lines
18 KiB
TypeScript
401 lines
18 KiB
TypeScript
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; |