603 lines
27 KiB
TypeScript
603 lines
27 KiB
TypeScript
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;
|