1
This commit is contained in:
parent
697f0a69bf
commit
63fc41dede
@ -1,28 +1,20 @@
|
||||
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 React, { useState, useEffect, useMemo, useCallback } 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 } from '../types';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
import type { WorkflowNodeDefinition, JSONSchema } from '../nodes/types';
|
||||
import { isConfigurableNode } from '../nodes/types';
|
||||
|
||||
// 创建Schema组件
|
||||
const SchemaField = createSchemaField({
|
||||
components: {
|
||||
FormItem,
|
||||
Input,
|
||||
NumberPicker,
|
||||
Select,
|
||||
FormLayout,
|
||||
Switch,
|
||||
'Input.TextArea': Input.TextArea,
|
||||
},
|
||||
});
|
||||
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
|
||||
import { loadDataSource, DataSourceType, type DataSourceOption } from '../utils/dataSourceLoader';
|
||||
|
||||
interface NodeConfigModalProps {
|
||||
visible: boolean;
|
||||
@ -40,214 +32,313 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 动态数据源缓存
|
||||
const [dataSourceCache, setDataSourceCache] = useState<Record<string, DataSourceOption[]>>({});
|
||||
const [loadingDataSources, setLoadingDataSources] = useState(false);
|
||||
|
||||
// 获取节点定义
|
||||
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
|
||||
|
||||
// ✅ 根据节点 ID 重新创建表单实例(修复切换节点时数据不更新的问题)
|
||||
const configForm = useMemo(() => createForm(), [node?.id]);
|
||||
const inputForm = useMemo(() => createForm(), [node?.id]);
|
||||
// ✅ 生成 Zod Schema
|
||||
const configSchema = useMemo(() => {
|
||||
if (!nodeDefinition?.configSchema) return null;
|
||||
return convertJsonSchemaToZod(nodeDefinition.configSchema);
|
||||
}, [nodeDefinition?.configSchema]);
|
||||
|
||||
// 初始化表单数据
|
||||
const inputMappingSchema = useMemo(() => {
|
||||
if (!nodeDefinition || !isConfigurableNode(nodeDefinition) || !nodeDefinition.inputMappingSchema) {
|
||||
return null;
|
||||
}
|
||||
return convertJsonSchemaToZod(nodeDefinition.inputMappingSchema);
|
||||
}, [nodeDefinition]);
|
||||
|
||||
// ✅ 创建表单实例(基本配置)
|
||||
const configForm = useForm({
|
||||
resolver: configSchema ? zodResolver(configSchema) : undefined,
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
// ✅ 创建表单实例(输入映射)
|
||||
const inputForm = useForm({
|
||||
resolver: inputMappingSchema ? zodResolver(inputMappingSchema) : undefined,
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
// ✅ 预加载动态数据源
|
||||
useEffect(() => {
|
||||
if (!visible || !nodeDefinition) return;
|
||||
|
||||
const loadDynamicData = async () => {
|
||||
const configTypes = extractDataSourceTypes(nodeDefinition.configSchema);
|
||||
const inputTypes = isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema
|
||||
? extractDataSourceTypes(nodeDefinition.inputMappingSchema)
|
||||
: [];
|
||||
|
||||
const allTypes = [...new Set([...configTypes, ...inputTypes])];
|
||||
|
||||
if (allTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDataSources(true);
|
||||
try {
|
||||
const cache: Record<string, DataSourceOption[]> = {};
|
||||
await Promise.all(
|
||||
allTypes.map(async (type) => {
|
||||
const data = await loadDataSource(type as DataSourceType);
|
||||
cache[type] = data;
|
||||
})
|
||||
);
|
||||
setDataSourceCache(cache);
|
||||
} catch (error) {
|
||||
console.error('加载动态数据源失败:', error);
|
||||
toast({
|
||||
title: '数据加载失败',
|
||||
description: '无法加载部分选项数据,请刷新重试',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setLoadingDataSources(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDynamicData();
|
||||
}, [visible, nodeDefinition, toast]);
|
||||
|
||||
// ✅ 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (visible && node && nodeDefinition) {
|
||||
const nodeData = node.data || {};
|
||||
|
||||
// 准备默认配置
|
||||
// 设置基本配置默认值
|
||||
const defaultConfig = {
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description
|
||||
description: nodeDefinition.description || ''
|
||||
};
|
||||
configForm.reset({ ...defaultConfig, ...(nodeData.configs || {}) });
|
||||
|
||||
// 设置表单初始值
|
||||
configForm.setInitialValues({ ...defaultConfig, ...(nodeData.configs || {}) });
|
||||
configForm.reset();
|
||||
|
||||
// 设置输入映射默认值
|
||||
if (isConfigurableNode(nodeDefinition)) {
|
||||
inputForm.setInitialValues(nodeData.inputMapping || {});
|
||||
inputForm.reset();
|
||||
inputForm.reset(nodeData.inputMapping || {});
|
||||
}
|
||||
}
|
||||
}, [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 () => {
|
||||
// ✅ 保存配置
|
||||
const handleSave = () => {
|
||||
if (!node || !nodeDefinition) return;
|
||||
|
||||
try {
|
||||
// 使用 handleSubmit 验证并获取数据
|
||||
configForm.handleSubmit(async (configData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取输入映射数据
|
||||
let inputData = {};
|
||||
if (isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema) {
|
||||
inputData = inputForm.getValues();
|
||||
}
|
||||
|
||||
// 获取表单值并转换
|
||||
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 || [],
|
||||
label: (configData as any).nodeName || nodeDefinition.nodeName,
|
||||
configs: configData as Record<string, any>,
|
||||
inputMapping: inputData,
|
||||
outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : []
|
||||
};
|
||||
|
||||
onOk(node.id, updatedData);
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "节点配置已成功保存",
|
||||
title: '保存成功',
|
||||
description: '节点配置已更新'
|
||||
});
|
||||
|
||||
// 关闭抽屉
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('保存节点配置失败:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "保存失败",
|
||||
description: error.message,
|
||||
title: '保存失败',
|
||||
description: '请检查必填字段是否填写完整',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// ✅ 重置表单
|
||||
const handleReset = () => {
|
||||
configForm.reset();
|
||||
inputForm.reset();
|
||||
if (!node || !nodeDefinition) return;
|
||||
|
||||
const defaultConfig = {
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description || ''
|
||||
};
|
||||
configForm.reset(defaultConfig);
|
||||
inputForm.reset({});
|
||||
|
||||
toast({
|
||||
title: "已重置",
|
||||
description: "表单已重置为初始值",
|
||||
title: '已重置',
|
||||
description: '表单已恢复为默认值'
|
||||
});
|
||||
};
|
||||
|
||||
// 将JSON Schema转换为Formily Schema(扩展配置)
|
||||
const convertToFormilySchema = (jsonSchema: ISchema): ISchema => {
|
||||
const schema: ISchema = {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
};
|
||||
// ✅ 渲染字段控件
|
||||
const renderFieldControl = useCallback((prop: any, field: any, key: string) => {
|
||||
// 动态数据源
|
||||
if (prop['x-dataSource']) {
|
||||
const options = dataSourceCache[prop['x-dataSource']] || [];
|
||||
return (
|
||||
<Select
|
||||
disabled={loadingDataSources || loading}
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => {
|
||||
// 如果是数字类型,转换为数字
|
||||
const numValue = Number(value);
|
||||
field.onChange(isNaN(numValue) ? value : numValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`请选择${prop.title || ''}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') return schema;
|
||||
// 静态枚举值
|
||||
if (prop.enum && Array.isArray(prop.enum)) {
|
||||
return (
|
||||
<Select
|
||||
disabled={loading}
|
||||
value={field.value?.toString()}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`请选择${prop.title || ''}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 (
|
||||
<SelectItem key={enumValue} value={enumValue.toString()}>
|
||||
{enumLabel}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
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%' } // 统一宽度
|
||||
};
|
||||
if (prop.format === 'textarea' || key.includes('description')) {
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
type={prop.format === 'password' ? 'password' : 'text'}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'integer':
|
||||
field['x-component'] = 'NumberPicker';
|
||||
field['x-component-props'] = {
|
||||
min: prop.minimum,
|
||||
max: prop.maximum,
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
break;
|
||||
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':
|
||||
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;
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value || false}
|
||||
disabled={loading}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [dataSourceCache, loadingDataSources, loading]);
|
||||
|
||||
// ✅ 渲染表单字段
|
||||
const renderFormFields = useCallback((
|
||||
schema: JSONSchema,
|
||||
form: ReturnType<typeof useForm>,
|
||||
prefix: string = ''
|
||||
) => {
|
||||
if (!schema.properties || typeof schema.properties !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 设置默认值(非object类型)
|
||||
if (prop.type !== 'object' && prop.default !== undefined) {
|
||||
field.default = prop.default;
|
||||
}
|
||||
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);
|
||||
|
||||
// 设置必填
|
||||
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) {
|
||||
field.required = true;
|
||||
}
|
||||
|
||||
(schema.properties as Record<string, any>)[key] = field;
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
}, [renderFieldControl]);
|
||||
|
||||
if (!nodeDefinition) {
|
||||
return null;
|
||||
@ -262,7 +353,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
<SheetTitle className="text-xl font-semibold">
|
||||
编辑节点 - {nodeDefinition.nodeName}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm text-muted-foreground">
|
||||
<SheetDescription>
|
||||
配置节点的参数和映射关系
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
@ -270,38 +361,33 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
|
||||
{/* 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 className="px-1 space-y-4">
|
||||
<Form {...configForm}>
|
||||
{renderFormFields(nodeDefinition.configSchema, configForm)}
|
||||
</Form>
|
||||
</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 className="px-1 space-y-4">
|
||||
<Form {...inputForm}>
|
||||
{renderFormFields(nodeDefinition.inputMappingSchema, inputForm)}
|
||||
</Form>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
@ -362,15 +448,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer - 固定在底部 */}
|
||||
<div className="px-6 py-4 border-t bg-background">
|
||||
<SheetFooter className="flex-row justify-between gap-2">
|
||||
<SheetFooter className="px-6 py-4 border-t bg-background">
|
||||
<div className="flex justify-between w-full">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
@ -378,21 +464,21 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="gap-2"
|
||||
onClick={handleSave}
|
||||
disabled={loading || loadingDataSources}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {ConfigurableNodeDefinition, NodeType, NodeCategory} from './types';
|
||||
import {DataSourceType} from '../utils/dataSourceLoader';
|
||||
|
||||
/**
|
||||
* Jenkins构建节点定义(纯配置)
|
||||
@ -61,15 +62,14 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
|
||||
description: "节点的详细说明",
|
||||
default: "通过Jenkins执行构建任务"
|
||||
},
|
||||
jenkinsUrl: {
|
||||
type: "string",
|
||||
title: "Jenkins服务器地址",
|
||||
description: "Jenkins服务器的完整URL地址",
|
||||
default: "http://jenkins.example.com:8080",
|
||||
format: "uri"
|
||||
jenkinsServerId: {
|
||||
type: "number",
|
||||
title: "Jenkins服务器",
|
||||
description: "选择要使用的Jenkins服务器",
|
||||
'x-dataSource': DataSourceType.JENKINS_SERVERS
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode", "jenkinsUrl"]
|
||||
required: ["nodeName", "nodeCode", "jenkinsServerId"]
|
||||
},
|
||||
// ✅ 输出能力定义(只读展示,传递给后端)
|
||||
outputs: [
|
||||
|
||||
122
frontend/src/pages/Workflow/Design/utils/dataSourceLoader.ts
Normal file
122
frontend/src/pages/Workflow/Design/utils/dataSourceLoader.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 数据源类型枚举
|
||||
*/
|
||||
export enum DataSourceType {
|
||||
JENKINS_SERVERS = 'JENKINS_SERVERS',
|
||||
K8S_CLUSTERS = 'K8S_CLUSTERS',
|
||||
GIT_REPOSITORIES = 'GIT_REPOSITORIES',
|
||||
DOCKER_REGISTRIES = 'DOCKER_REGISTRIES'
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源配置接口
|
||||
*/
|
||||
export interface DataSourceConfig {
|
||||
url: string;
|
||||
params?: Record<string, any>;
|
||||
transform: (data: any) => Array<{ label: string; value: any; [key: string]: any }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源选项接口
|
||||
*/
|
||||
export interface DataSourceOption {
|
||||
label: string;
|
||||
value: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源配置注册表
|
||||
*/
|
||||
export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
|
||||
[DataSourceType.JENKINS_SERVERS]: {
|
||||
url: '/api/v1/external-system/list',
|
||||
params: { type: 'JENKINS', enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url,
|
||||
name: item.name
|
||||
}));
|
||||
}
|
||||
},
|
||||
[DataSourceType.K8S_CLUSTERS]: {
|
||||
url: '/api/v1/k8s-cluster/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
apiServer: item.apiServer
|
||||
}));
|
||||
}
|
||||
},
|
||||
[DataSourceType.GIT_REPOSITORIES]: {
|
||||
url: '/api/v1/git-repo/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
},
|
||||
[DataSourceType.DOCKER_REGISTRIES]: {
|
||||
url: '/api/v1/docker-registry/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载数据源
|
||||
* @param type 数据源类型
|
||||
* @returns 选项列表
|
||||
*/
|
||||
export const loadDataSource = async (type: DataSourceType): Promise<DataSourceOption[]> => {
|
||||
const config = DATA_SOURCE_REGISTRY[type];
|
||||
|
||||
if (!config) {
|
||||
console.error(`数据源类型 ${type} 未配置`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.get(config.url, { params: config.params });
|
||||
// request 拦截器已经提取了 data 字段,response 直接是数组
|
||||
return config.transform(response || []);
|
||||
} catch (error) {
|
||||
console.error(`加载数据源 ${type} 失败:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量加载多个数据源(用于预加载)
|
||||
* @param types 数据源类型列表
|
||||
* @returns 数据源数据映射
|
||||
*/
|
||||
export const loadMultipleDataSources = async (
|
||||
types: DataSourceType[]
|
||||
): Promise<Record<DataSourceType, DataSourceOption[]>> => {
|
||||
const results = await Promise.all(
|
||||
types.map(type => loadDataSource(type))
|
||||
);
|
||||
|
||||
return types.reduce((acc, type, index) => {
|
||||
acc[type] = results[index];
|
||||
return acc;
|
||||
}, {} as Record<DataSourceType, DataSourceOption[]>);
|
||||
};
|
||||
|
||||
117
frontend/src/pages/Workflow/Design/utils/schemaConverter.ts
Normal file
117
frontend/src/pages/Workflow/Design/utils/schemaConverter.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { z } from 'zod';
|
||||
import type { JSONSchema } from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 将 JSON Schema 转换为 Zod Schema
|
||||
* @param jsonSchema JSON Schema 定义
|
||||
* @returns Zod Schema
|
||||
*/
|
||||
export const convertJsonSchemaToZod = (jsonSchema: JSONSchema): z.ZodObject<any> => {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') {
|
||||
return z.object({});
|
||||
}
|
||||
|
||||
Object.entries(jsonSchema.properties).forEach(([key, prop]: [string, any]) => {
|
||||
let fieldSchema: z.ZodTypeAny;
|
||||
|
||||
// 根据类型创建 Zod Schema
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
fieldSchema = z.string();
|
||||
if (prop.format === 'email') {
|
||||
fieldSchema = (fieldSchema as z.ZodString).email(prop.title ? `${prop.title}格式不正确` : '邮箱格式不正确');
|
||||
} else if (prop.format === 'url') {
|
||||
fieldSchema = (fieldSchema as z.ZodString).url(prop.title ? `${prop.title}格式不正确` : 'URL格式不正确');
|
||||
}
|
||||
if (prop.minLength) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).min(prop.minLength, `${prop.title || key}至少需要${prop.minLength}个字符`);
|
||||
}
|
||||
if (prop.maxLength) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).max(prop.maxLength, `${prop.title || key}最多${prop.maxLength}个字符`);
|
||||
}
|
||||
if (prop.pattern) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).regex(new RegExp(prop.pattern), `${prop.title || key}格式不正确`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
case 'integer':
|
||||
fieldSchema = prop.type === 'integer' ? z.number().int() : z.number();
|
||||
if (prop.minimum !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodNumber).min(prop.minimum, `${prop.title || key}不能小于${prop.minimum}`);
|
||||
}
|
||||
if (prop.maximum !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodNumber).max(prop.maximum, `${prop.title || key}不能大于${prop.maximum}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
fieldSchema = z.boolean();
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
fieldSchema = z.array(z.any());
|
||||
if (prop.minItems !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodArray<any>).min(prop.minItems, `${prop.title || key}至少需要${prop.minItems}项`);
|
||||
}
|
||||
if (prop.maxItems !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodArray<any>).max(prop.maxItems, `${prop.title || key}最多${prop.maxItems}项`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
fieldSchema = z.record(z.any());
|
||||
break;
|
||||
|
||||
default:
|
||||
fieldSchema = z.any();
|
||||
}
|
||||
|
||||
// 处理默认值
|
||||
if (prop.default !== undefined) {
|
||||
fieldSchema = fieldSchema.default(prop.default);
|
||||
}
|
||||
|
||||
// 处理枚举
|
||||
if (prop.enum && Array.isArray(prop.enum)) {
|
||||
const enumValues = prop.enum.map((v: any) => (typeof v === 'object' ? v.value : v));
|
||||
fieldSchema = z.enum(enumValues as [string, ...string[]]);
|
||||
}
|
||||
|
||||
// 处理必填字段
|
||||
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) {
|
||||
// 已经是必填的,不需要额外处理
|
||||
} else {
|
||||
// 非必填字段设为 optional
|
||||
fieldSchema = fieldSchema.optional();
|
||||
}
|
||||
|
||||
shape[key] = fieldSchema;
|
||||
});
|
||||
|
||||
return z.object(shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 JSON Schema 中提取需要动态加载的字段
|
||||
* @param jsonSchema JSON Schema 定义
|
||||
* @returns 需要加载的数据源类型列表
|
||||
*/
|
||||
export const extractDataSourceTypes = (jsonSchema: JSONSchema): string[] => {
|
||||
const types: string[] = [];
|
||||
|
||||
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') {
|
||||
return types;
|
||||
}
|
||||
|
||||
Object.values(jsonSchema.properties).forEach((prop: any) => {
|
||||
if (prop['x-dataSource']) {
|
||||
types.push(prop['x-dataSource']);
|
||||
}
|
||||
});
|
||||
|
||||
return types;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user