This commit is contained in:
dengqichen 2025-10-21 17:27:10 +08:00
parent 697f0a69bf
commit 63fc41dede
5 changed files with 563 additions and 238 deletions

View File

@ -87,16 +87,16 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
// 检查表达式是否包含变量引用 // 检查表达式是否包含变量引用
if (values.type === 'EXPRESSION' && values.expression) { if (values.type === 'EXPRESSION' && values.expression) {
const hasVariable = /\$\{[\w.]+\}/.test(values.expression); const hasVariable = /\$\{[\w.]+\}/.test(values.expression);
if (!hasVariable) { if (!hasVariable) {
toast({ toast({
title: '提示', title: '提示',
description: '表达式建议包含变量引用,格式:${变量名}', description: '表达式建议包含变量引用,格式:${变量名}',
}); });
}
} }
}
onOk(edge.id, values); onOk(edge.id, values);
handleClose(); handleClose();
}; };
@ -174,8 +174,8 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="请输入条件表达式,如:${amount} > 1000" placeholder="请输入条件表达式,如:${amount} > 1000"
rows={4} rows={4}
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -194,16 +194,16 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
<FormField <FormField
control={form.control} control={form.control}
name="priority" name="priority"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={1} min={1}
max={999} max={999}
placeholder="请输入优先级" placeholder="请输入优先级"
{...field} {...field}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(Number(e.target.value))}
/> />
@ -225,7 +225,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,28 +1,20 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormItem, Input, NumberPicker, Select, FormLayout, Switch } from '@formily/antd-v5'; import { useForm } from 'react-hook-form';
import { createForm } from '@formily/core'; import { zodResolver } from '@hookform/resolvers/zod';
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
import { Save, RotateCcw } from 'lucide-react'; import { Save, RotateCcw } from 'lucide-react';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button'; 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 { useToast } from '@/components/ui/use-toast';
import type { FlowNode, FlowNodeData } from '../types'; import type { FlowNode, FlowNodeData } from '../types';
import type { WorkflowNodeDefinition } from '../nodes/types'; import type { WorkflowNodeDefinition, JSONSchema } from '../nodes/types';
import { isConfigurableNode } from '../nodes/types'; import { isConfigurableNode } from '../nodes/types';
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
// 创建Schema组件 import { loadDataSource, DataSourceType, type DataSourceOption } from '../utils/dataSourceLoader';
const SchemaField = createSchemaField({
components: {
FormItem,
Input,
NumberPicker,
Select,
FormLayout,
Switch,
'Input.TextArea': Input.TextArea,
},
});
interface NodeConfigModalProps { interface NodeConfigModalProps {
visible: boolean; visible: boolean;
@ -40,229 +32,328 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
// 动态数据源缓存
const [dataSourceCache, setDataSourceCache] = useState<Record<string, DataSourceOption[]>>({});
const [loadingDataSources, setLoadingDataSources] = useState(false);
// 获取节点定义 // 获取节点定义
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null; const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
// ✅ 根据节点 ID 重新创建表单实例(修复切换节点时数据不更新的问题) // ✅ 生成 Zod Schema
const configForm = useMemo(() => createForm(), [node?.id]); const configSchema = useMemo(() => {
const inputForm = useMemo(() => createForm(), [node?.id]); 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(() => { useEffect(() => {
if (visible && node && nodeDefinition) { if (visible && node && nodeDefinition) {
const nodeData = node.data || {}; const nodeData = node.data || {};
// 准备默认配置 // 设置基本配置默认值
const defaultConfig = { const defaultConfig = {
nodeName: nodeDefinition.nodeName, nodeName: nodeDefinition.nodeName,
nodeCode: nodeDefinition.nodeCode, nodeCode: nodeDefinition.nodeCode,
description: nodeDefinition.description description: nodeDefinition.description || ''
}; };
configForm.reset({ ...defaultConfig, ...(nodeData.configs || {}) });
// 设置表单初始值 // 设置输入映射默认值
configForm.setInitialValues({ ...defaultConfig, ...(nodeData.configs || {}) });
configForm.reset();
if (isConfigurableNode(nodeDefinition)) { if (isConfigurableNode(nodeDefinition)) {
inputForm.setInitialValues(nodeData.inputMapping || {}); inputForm.reset(nodeData.inputMapping || {});
inputForm.reset();
} }
} }
}, [visible, node, nodeDefinition, configForm, inputForm]); }, [visible, node, nodeDefinition, configForm, inputForm]);
// 递归处理表单值将JSON字符串转换为对象 // ✅ 保存配置
const processFormValues = (values: Record<string, any>, schema: ISchema | undefined): Record<string, any> => { const handleSave = () => {
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; if (!node || !nodeDefinition) return;
try { // 使用 handleSubmit 验证并获取数据
configForm.handleSubmit(async (configData) => {
setLoading(true); 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> = { const updatedData: Partial<FlowNodeData> = {
label: configs.nodeName || node.data.label, label: (configData as any).nodeName || nodeDefinition.nodeName,
configs, configs: configData as Record<string, any>,
inputMapping, inputMapping: inputData,
// outputs 保留原值(不修改,因为它是只读的输出能力定义) outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : []
outputs: node.data.outputs || [],
}; };
onOk(node.id, updatedData); onOk(node.id, updatedData);
toast({
title: "保存成功", toast({
description: "节点配置已成功保存", title: '保存成功',
}); description: '节点配置已更新'
});
// 关闭抽屉
onCancel(); onCancel();
} catch (error) { } catch (error) {
if (error instanceof Error) { console.error('保存节点配置失败:', error);
toast({ toast({
variant: "destructive", title: '保存失败',
title: "保存失败", description: '请检查必填字段是否填写完整',
description: error.message, variant: 'destructive'
}); });
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
})();
}; };
// ✅ 重置表单
const handleReset = () => { const handleReset = () => {
configForm.reset(); if (!node || !nodeDefinition) return;
inputForm.reset();
const defaultConfig = {
nodeName: nodeDefinition.nodeName,
nodeCode: nodeDefinition.nodeCode,
description: nodeDefinition.description || ''
};
configForm.reset(defaultConfig);
inputForm.reset({});
toast({ toast({
title: "已重置", title: '已重置',
description: "表单已重置为初始值", description: '表单已恢复为默认值'
}); });
}; };
// 将JSON Schema转换为Formily Schema扩展配置 // ✅ 渲染字段控件
const convertToFormilySchema = (jsonSchema: ISchema): ISchema => { const renderFieldControl = useCallback((prop: any, field: any, key: string) => {
const schema: ISchema = { // 动态数据源
type: 'object', if (prop['x-dataSource']) {
properties: {} 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) { switch (prop.type) {
case 'string': case 'string':
if (prop.enum) { if (prop.format === 'textarea' || key.includes('description')) {
field['x-component'] = 'Select'; return (
field['x-component-props'] = { <Textarea
style: { width: '100%' }, // 统一宽度 {...field}
options: prop.enum.map((v: any, i: number) => ({ disabled={loading}
label: prop.enumNames?.[i] || v, placeholder={prop.description || `请输入${prop.title || ''}`}
value: v rows={3}
})) />
}; );
} else if (prop.format === 'password') { }
field['x-component'] = 'Input'; return (
field['x-component-props'] = { <Input
type: 'password', {...field}
style: { width: '100%' } // 统一宽度 type={prop.format === 'password' ? 'password' : 'text'}
}; disabled={loading}
} else { placeholder={prop.description || `请输入${prop.title || ''}`}
field['x-component'] = 'Input'; />
field['x-component-props'] = { );
style: { width: '100%' } // 统一宽度
};
}
break;
case 'number': case 'number':
case 'integer': case 'integer':
field['x-component'] = 'NumberPicker'; return (
field['x-component-props'] = { <Input
min: prop.minimum, {...field}
max: prop.maximum, type="number"
style: { width: '100%' } // 统一宽度 disabled={loading}
}; placeholder={prop.description || `请输入${prop.title || ''}`}
break; onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? undefined : Number(value));
}}
/>
);
case 'boolean': case 'boolean':
field['x-component'] = 'Switch'; return (
break; <input
case 'object': type="checkbox"
field['x-component'] = 'Input.TextArea'; checked={field.value || false}
field['x-component-props'] = { disabled={loading}
rows: 4, onChange={(e) => field.onChange(e.target.checked)}
style: { width: '100%' }, // 统一宽度 className="h-4 w-4"
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: default:
field['x-component'] = 'Input'; return (
field['x-component-props'] = { <Input
style: { width: '100%' } // 统一宽度 {...field}
}; disabled={loading}
} placeholder={prop.description || `请输入${prop.title || ''}`}
/>
);
}
}, [dataSourceCache, loadingDataSources, loading]);
// 设置默认值非object类型 // ✅ 渲染表单字段
if (prop.type !== 'object' && prop.default !== undefined) { const renderFormFields = useCallback((
field.default = prop.default; 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]) => {
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) { const fieldName = prefix ? `${prefix}.${key}` : key;
field.required = true; const isRequired = Array.isArray(schema.required) && schema.required.includes(key);
}
(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>
)}
/>
);
}); });
}, [renderFieldControl]);
return schema;
};
if (!nodeDefinition) { if (!nodeDefinition) {
return null; return null;
} }
return ( return (
<Sheet open={visible} onOpenChange={(open) => !open && onCancel()}> <Sheet open={visible} onOpenChange={(open) => !open && onCancel()}>
<SheetContent className="w-[600px] sm:max-w-[600px] flex flex-col p-0"> <SheetContent className="w-[600px] sm:max-w-[600px] flex flex-col p-0">
{/* Header - 固定在顶部 */} {/* Header - 固定在顶部 */}
<div className="px-6 pt-6 pb-4 border-b"> <div className="px-6 pt-6 pb-4 border-b">
<SheetHeader className="space-y-2"> <SheetHeader className="space-y-2">
<SheetTitle className="text-xl font-semibold"> <SheetTitle className="text-xl font-semibold">
- {nodeDefinition.nodeName} - {nodeDefinition.nodeName}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-sm text-muted-foreground"> <SheetDescription>
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@ -270,38 +361,33 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
{/* Content - 可滚动区域 */} {/* Content - 可滚动区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
{/* 使用 Accordion 支持多个面板同时展开,默认展开基本配置 */}
<Accordion <Accordion
type="multiple" type="multiple"
defaultValue={["config"]} defaultValue={["config"]}
className="w-full" className="w-full"
> >
{/* 基本配置 - 始终显示 */} {/* 基本配置 */}
<AccordionItem value="config" className="border-b"> <AccordionItem value="config" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline"> <AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-1"> <AccordionContent className="px-1 space-y-4">
<FormProvider form={configForm}> <Form {...configForm}>
<FormLayout layout="vertical" colon={false}> {renderFormFields(nodeDefinition.configSchema, configForm)}
<SchemaField schema={convertToFormilySchema(nodeDefinition.configSchema)} /> </Form>
</FormLayout>
</FormProvider>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
{/* 输入映射 - 条件显示 */} {/* 输入映射 */}
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && ( {isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
<AccordionItem value="input" className="border-b"> <AccordionItem value="input" className="border-b">
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline"> <AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-1"> <AccordionContent className="px-1 space-y-4">
<FormProvider form={inputForm}> <Form {...inputForm}>
<FormLayout layout="vertical" colon={false}> {renderFormFields(nodeDefinition.inputMappingSchema, inputForm)}
<SchemaField schema={convertToFormilySchema(nodeDefinition.inputMappingSchema)} /> </Form>
</FormLayout>
</FormProvider>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
)} )}
@ -315,7 +401,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
<AccordionContent className="px-1"> <AccordionContent className="px-1">
<div className="text-sm text-muted-foreground mb-4 p-3 bg-muted/50 rounded-md"> <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> 💡 <code className="px-1 py-0.5 bg-background rounded">{'${upstream.字段名}'}</code>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{nodeDefinition.outputs.map((output) => ( {nodeDefinition.outputs.map((output) => (
<div key={output.name} className="p-3 border rounded-md bg-background"> <div key={output.name} className="p-3 border rounded-md bg-background">
@ -326,7 +412,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
<code className="px-1.5 py-0.5 text-xs bg-muted rounded">{output.name}</code> <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"> <span className="text-xs text-muted-foreground border border-border px-1.5 py-0.5 rounded">
{output.type} {output.type}
</span> </span>
{output.required && ( {output.required && (
<span className="text-xs text-red-500">*</span> <span className="text-xs text-red-500">*</span>
)} )}
@ -362,37 +448,37 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
</div> </div>
{/* Footer - 固定在底部 */} {/* Footer - 固定在底部 */}
<div className="px-6 py-4 border-t bg-background"> <SheetFooter className="px-6 py-4 border-t bg-background">
<SheetFooter className="flex-row justify-between gap-2"> <div className="flex justify-between w-full">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={handleReset} onClick={handleReset}
className="gap-2" disabled={loading}
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
>
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={loading} disabled={loading}
className="gap-2"
> >
<Save className="h-4 w-4" />
</Button>
<Button
type="button"
onClick={handleSave}
disabled={loading || loadingDataSources}
>
<Save className="mr-2 h-4 w-4" />
{loading ? '保存中...' : '保存配置'} {loading ? '保存中...' : '保存配置'}
</Button> </Button>
</div>
</SheetFooter>
</div> </div>
</div>
</SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );

View File

@ -1,4 +1,5 @@
import {ConfigurableNodeDefinition, NodeType, NodeCategory} from './types'; import {ConfigurableNodeDefinition, NodeType, NodeCategory} from './types';
import {DataSourceType} from '../utils/dataSourceLoader';
/** /**
* Jenkins构建节点定义 * Jenkins构建节点定义
@ -61,15 +62,14 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
description: "节点的详细说明", description: "节点的详细说明",
default: "通过Jenkins执行构建任务" default: "通过Jenkins执行构建任务"
}, },
jenkinsUrl: { jenkinsServerId: {
type: "string", type: "number",
title: "Jenkins服务器地址", title: "Jenkins服务器",
description: "Jenkins服务器的完整URL地址", description: "选择要使用的Jenkins服务器",
default: "http://jenkins.example.com:8080", 'x-dataSource': DataSourceType.JENKINS_SERVERS
format: "uri"
} }
}, },
required: ["nodeName", "nodeCode", "jenkinsUrl"] required: ["nodeName", "nodeCode", "jenkinsServerId"]
}, },
// ✅ 输出能力定义(只读展示,传递给后端) // ✅ 输出能力定义(只读展示,传递给后端)
outputs: [ outputs: [

View 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[]>);
};

View 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;
};