deploy-ease-platform/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx
dengqichen 337320bfcd 1
2025-10-22 16:46:26 +08:00

603 lines
27 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, useCallback, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import type { FlowNode, FlowNodeData, FlowEdge } from '../types';
import type { WorkflowNodeDefinition, JSONSchema } from '../nodes/types';
import { isConfigurableNode } from '../nodes/types';
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
import { loadDataSource, DataSourceType, type DataSourceOption } from '../utils/dataSourceLoader';
import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion';
import VariableInput from '@/components/VariableInput';
interface NodeConfigModalProps {
visible: boolean;
node: FlowNode | null;
allNodes: FlowNode[];
allEdges: FlowEdge[];
onCancel: () => void;
onOk: (nodeId: string, updatedData: Partial<FlowNodeData>) => void;
}
const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
visible,
node,
allNodes,
allEdges,
onCancel,
onOk
}) => {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// 固定的节点信息(不通过 schema直接管理
const [nodeName, setNodeName] = useState('');
const [description, setDescription] = useState('');
// 动态数据源缓存
const [dataSourceCache, setDataSourceCache] = useState<Record<string, DataSourceOption[]>>({});
const [loadingDataSources, setLoadingDataSources] = useState(false);
// ✅ 性能优化:使用 ref 保存 allNodes 和 allEdges 的最新引用
// 避免这些频繁变化的数组导致 useCallback 重建
const allNodesRef = useRef<FlowNode[]>(allNodes);
const allEdgesRef = useRef<FlowEdge[]>(allEdges);
useEffect(() => {
allNodesRef.current = allNodes;
allEdgesRef.current = allEdges;
}, [allNodes, allEdges]);
// 获取节点定义
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
// ✅ 生成 Zod Schema仅输入映射
const inputMappingSchema = useMemo(() => {
if (!nodeDefinition || !isConfigurableNode(nodeDefinition) || !nodeDefinition.inputMappingSchema) {
return null;
}
return convertJsonSchemaToZod(nodeDefinition.inputMappingSchema);
}, [nodeDefinition]);
// ✅ 创建表单实例(仅输入映射)
const inputForm = useForm({
resolver: inputMappingSchema ? zodResolver(inputMappingSchema) : undefined,
defaultValues: {}
});
// ✅ 预加载动态数据源(仅输入映射)
useEffect(() => {
if (!visible || !nodeDefinition) return;
const loadDynamicData = async () => {
const inputTypes = isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema
? extractDataSourceTypes(nodeDefinition.inputMappingSchema)
: [];
if (inputTypes.length === 0) {
return;
}
setLoadingDataSources(true);
const cache: Record<string, DataSourceOption[]> = {};
let failedCount = 0;
// ✅ 改进:单独处理每个数据源的加载,避免一个失败影响全部
await Promise.allSettled(
inputTypes.map(async (type) => {
try {
const data = await loadDataSource(type as DataSourceType);
cache[type] = data;
} catch (error) {
console.error(`加载数据源 ${type} 失败:`, error);
failedCount++;
cache[type] = []; // 失败时使用空数组
}
})
);
setDataSourceCache(cache);
setLoadingDataSources(false);
// 如果有失败的数据源,显示提示
if (failedCount > 0) {
toast({
title: '部分数据加载失败',
description: `${failedCount} 个选项数据加载失败,请检查网络后重新打开节点配置`,
variant: 'destructive'
});
}
};
loadDynamicData();
}, [visible, nodeDefinition, toast]);
// ✅ 初始化表单数据
useEffect(() => {
if (visible && node && nodeDefinition) {
const nodeData = node.data || {};
// 设置固定节点信息(从 configs 或 nodeDefinition 获取)
setNodeName(nodeData.configs?.nodeName || nodeDefinition.nodeName);
setDescription(nodeData.configs?.description || nodeDefinition.description || '');
// 设置输入映射默认值(转换 UUID 为显示名称)
if (isConfigurableNode(nodeDefinition)) {
const displayInputMapping = convertObjectToDisplayName(
nodeData.inputMapping || {},
allNodes
);
inputForm.reset(displayInputMapping);
}
}
// 只在 visible 或 node.id 改变时重置表单
// allNodes 改变不应该触发重置(用户正在编辑时)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, node?.id]);
// ✅ 保存配置
const handleSave = () => {
if (!node || !nodeDefinition) return;
// 验证节点名称(必填)
if (!nodeName || !nodeName.trim()) {
toast({
title: '保存失败',
description: '节点名称不能为空',
variant: 'destructive'
});
return;
}
// 使用 handleSubmit 验证输入映射(如果存在)
const submitForm = async () => {
setLoading(true);
try {
// 获取输入映射数据
let inputData = {};
if (isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema) {
inputData = inputForm.getValues();
}
// 转换显示名称格式为 UUID 格式(仅输入映射需要)
const uuidInputMapping = convertObjectToUUID(inputData as Record<string, any>, allNodes);
// 构建 configs包含元数据
const configs = {
nodeName: nodeName.trim(),
nodeCode: nodeDefinition.nodeCode, // 只读,从定义获取
description: description.trim()
};
// 构建更新数据
const updatedData: Partial<FlowNodeData> = {
label: nodeName.trim(),
configs,
inputMapping: uuidInputMapping,
outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : []
};
onOk(node.id, updatedData);
toast({
title: '保存成功',
description: '节点配置已更新'
});
// 关闭抽屉
onCancel();
} catch (error) {
console.error('保存节点配置失败:', error);
toast({
title: '保存失败',
description: '请检查必填字段是否填写完整',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
// 如果有输入映射schema使用表单验证
if (inputMappingSchema) {
inputForm.handleSubmit(submitForm)();
} else {
submitForm();
}
};
// ✅ 重置表单
const handleReset = () => {
if (!node || !nodeDefinition) return;
// 重置节点信息
setNodeName(nodeDefinition.nodeName);
setDescription(nodeDefinition.description || '');
// 重置输入映射
inputForm.reset({});
toast({
title: '已重置',
description: '表单已恢复为默认值'
});
};
// ✅ 通用 Select 渲染函数(减少代码重复)
const renderSelect = useCallback((
options: Array<{ label: string; value: any }>,
field: any,
prop: any,
disabled: boolean,
onValueChange?: (value: string) => void
) => (
<Select
disabled={disabled}
value={field.value?.toString()}
onValueChange={onValueChange || field.onChange}
>
<SelectTrigger>
<SelectValue placeholder={`请选择${prop.title || ''}`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
), []);
// ✅ 渲染字段控件
const renderFieldControl = useCallback((prop: any, field: any, key: string) => {
// 动态数据源
if (prop['x-dataSource']) {
const options = dataSourceCache[prop['x-dataSource']] || [];
return renderSelect(
options,
field,
prop,
loadingDataSources || loading,
(value) => {
// 如果是数字类型,转换为数字
const numValue = Number(value);
field.onChange(isNaN(numValue) ? value : numValue);
}
);
}
// 静态枚举值
if (prop.enum && Array.isArray(prop.enum)) {
const options = prop.enum.map((value: any, index: number) => {
const enumValue = typeof value === 'object' ? value.value : value;
const enumLabel = typeof value === 'object' ? value.label : (prop.enumNames?.[index] || value);
return { label: enumLabel, value: enumValue };
});
return renderSelect(options, field, prop, loading);
}
// 根据类型渲染
switch (prop.type) {
case 'string': {
// 黑名单字段:不使用变量输入
const blacklist = ['nodeName', 'nodeCode', 'description'];
const isBlacklisted = blacklist.includes(key);
const isPassword = prop.format === 'password';
// 密码字段或黑名单字段:使用普通输入
if (isPassword || isBlacklisted) {
if (prop.format === 'textarea' || key.includes('description')) {
return (
<Textarea
{...field}
disabled={loading}
placeholder={prop.description || `请输入${prop.title || ''}`}
rows={3}
/>
);
}
return (
<Input
{...field}
type={isPassword ? 'password' : 'text'}
disabled={loading}
placeholder={prop.description || `请输入${prop.title || ''}`}
/>
);
}
// 其他字符串字段:使用 VariableInput
const isTextarea = prop.format === 'textarea' ||
['content', 'body', 'expression', 'script'].includes(key);
return (
<VariableInput
value={field.value || ''}
onChange={field.onChange}
allNodes={allNodesRef.current}
allEdges={allEdgesRef.current}
currentNodeId={node?.id || ''}
variant={isTextarea ? 'textarea' : 'input'}
placeholder={prop.description || `请输入${prop.title || ''}`}
disabled={loading}
rows={3}
/>
);
}
case 'number':
case 'integer':
return (
<Input
{...field}
type="number"
disabled={loading}
placeholder={prop.description || `请输入${prop.title || ''}`}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? undefined : Number(value));
}}
/>
);
case 'boolean':
return (
<input
type="checkbox"
checked={field.value || false}
disabled={loading}
onChange={(e) => field.onChange(e.target.checked)}
className="h-4 w-4"
/>
);
default:
return (
<Input
{...field}
disabled={loading}
placeholder={prop.description || `请输入${prop.title || ''}`}
/>
);
}
}, [dataSourceCache, loadingDataSources, loading, node, renderSelect]);
// ✅ 渲染表单字段
const renderFormFields = useCallback((
schema: JSONSchema,
form: ReturnType<typeof useForm>,
prefix: string = ''
) => {
if (!schema.properties || typeof schema.properties !== 'object') {
return null;
}
return Object.entries(schema.properties).map(([key, prop]: [string, any]) => {
const fieldName = prefix ? `${prefix}.${key}` : key;
const isRequired = Array.isArray(schema.required) && schema.required.includes(key);
return (
<FormField
key={fieldName}
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel>
{prop.title || key}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</FormLabel>
<FormControl>
{renderFieldControl(prop, field, key)}
</FormControl>
{prop.description && (
<FormDescription>{prop.description}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
});
}, [renderFieldControl]);
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>
</SheetDescription>
</SheetHeader>
</div>
{/* Content - 可滚动区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<Accordion
type="multiple"
defaultValue={["info", "input"]}
className="w-full"
>
{/* 节点信息(固定表单,不通过 schema */}
<AccordionItem value="info" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger>
<AccordionContent className="px-1 space-y-4">
{/* 节点编码(只读) */}
<div className="space-y-2">
<label className="text-sm font-medium">
</label>
<Input
value={nodeDefinition.nodeCode}
disabled
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 节点名称 */}
<div className="space-y-2">
<label className="text-sm font-medium">
<span className="text-red-500">*</span>
</label>
<Input
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
placeholder="请输入节点名称"
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 节点描述 */}
<div className="space-y-2">
<label className="text-sm font-medium">
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="请输入节点描述"
disabled={loading}
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</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 space-y-4">
<Form {...inputForm}>
{renderFormFields(nodeDefinition.inputMappingSchema, inputForm)}
</Form>
</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">{'${节点名.字段名}'}</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 - 固定在底部 */}
<SheetFooter className="px-6 py-4 border-t bg-background">
<div className="flex justify-between w-full">
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={loading}
>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
</Button>
<Button
type="button"
onClick={handleSave}
disabled={loading || loadingDataSources}
>
<Save className="mr-2 h-4 w-4" />
{loading ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default NodeConfigModal;