This commit is contained in:
dengqichen 2025-10-22 16:27:42 +08:00
parent 4fded64d7c
commit ff88de9aa8
7 changed files with 154 additions and 179 deletions

View File

@ -38,6 +38,10 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
// 固定的节点信息(不通过 schema直接管理
const [nodeName, setNodeName] = useState('');
const [description, setDescription] = useState('');
// 动态数据源缓存 // 动态数据源缓存
const [dataSourceCache, setDataSourceCache] = useState<Record<string, DataSourceOption[]>>({}); const [dataSourceCache, setDataSourceCache] = useState<Record<string, DataSourceOption[]>>({});
const [loadingDataSources, setLoadingDataSources] = useState(false); const [loadingDataSources, setLoadingDataSources] = useState(false);
@ -45,12 +49,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
// 获取节点定义 // 获取节点定义
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null; const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
// ✅ 生成 Zod Schema // ✅ 生成 Zod Schema仅输入映射
const configSchema = useMemo(() => {
if (!nodeDefinition?.configSchema) return null;
return convertJsonSchemaToZod(nodeDefinition.configSchema);
}, [nodeDefinition?.configSchema]);
const inputMappingSchema = useMemo(() => { const inputMappingSchema = useMemo(() => {
if (!nodeDefinition || !isConfigurableNode(nodeDefinition) || !nodeDefinition.inputMappingSchema) { if (!nodeDefinition || !isConfigurableNode(nodeDefinition) || !nodeDefinition.inputMappingSchema) {
return null; return null;
@ -58,31 +57,22 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
return convertJsonSchemaToZod(nodeDefinition.inputMappingSchema); return convertJsonSchemaToZod(nodeDefinition.inputMappingSchema);
}, [nodeDefinition]); }, [nodeDefinition]);
// ✅ 创建表单实例(基本配置) // ✅ 创建表单实例(仅输入映射)
const configForm = useForm({
resolver: configSchema ? zodResolver(configSchema) : undefined,
defaultValues: {}
});
// ✅ 创建表单实例(输入映射)
const inputForm = useForm({ const inputForm = useForm({
resolver: inputMappingSchema ? zodResolver(inputMappingSchema) : undefined, resolver: inputMappingSchema ? zodResolver(inputMappingSchema) : undefined,
defaultValues: {} defaultValues: {}
}); });
// ✅ 预加载动态数据源 // ✅ 预加载动态数据源(仅输入映射)
useEffect(() => { useEffect(() => {
if (!visible || !nodeDefinition) return; if (!visible || !nodeDefinition) return;
const loadDynamicData = async () => { const loadDynamicData = async () => {
const configTypes = extractDataSourceTypes(nodeDefinition.configSchema);
const inputTypes = isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema const inputTypes = isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema
? extractDataSourceTypes(nodeDefinition.inputMappingSchema) ? extractDataSourceTypes(nodeDefinition.inputMappingSchema)
: []; : [];
const allTypes = [...new Set([...configTypes, ...inputTypes])]; if (inputTypes.length === 0) {
if (allTypes.length === 0) {
return; return;
} }
@ -90,7 +80,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
try { try {
const cache: Record<string, DataSourceOption[]> = {}; const cache: Record<string, DataSourceOption[]> = {};
await Promise.all( await Promise.all(
allTypes.map(async (type) => { inputTypes.map(async (type) => {
const data = await loadDataSource(type as DataSourceType); const data = await loadDataSource(type as DataSourceType);
cache[type] = data; cache[type] = data;
}) })
@ -116,21 +106,11 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
if (visible && node && nodeDefinition) { if (visible && node && nodeDefinition) {
const nodeData = node.data || {}; const nodeData = node.data || {};
// 设置基本配置默认值 // 设置固定节点信息(从 configs 或 nodeDefinition 获取)
const defaultConfig = { setNodeName(nodeData.configs?.nodeName || nodeDefinition.nodeName);
nodeName: nodeDefinition.nodeName, setDescription(nodeData.configs?.description || nodeDefinition.description || '');
nodeCode: nodeDefinition.nodeCode,
description: nodeDefinition.description || ''
};
// 转换 UUID 格式为显示名称格式 // 设置输入映射默认值(转换 UUID 为显示名称)
const displayConfigs = convertObjectToDisplayName(
{ ...defaultConfig, ...(nodeData.configs || {}) },
allNodes
);
configForm.reset(displayConfigs);
// 设置输入映射默认值(也需要转换)
if (isConfigurableNode(nodeDefinition)) { if (isConfigurableNode(nodeDefinition)) {
const displayInputMapping = convertObjectToDisplayName( const displayInputMapping = convertObjectToDisplayName(
nodeData.inputMapping || {}, nodeData.inputMapping || {},
@ -148,8 +128,18 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
const handleSave = () => { const handleSave = () => {
if (!node || !nodeDefinition) return; if (!node || !nodeDefinition) return;
// 使用 handleSubmit 验证并获取数据 // 验证节点名称(必填)
configForm.handleSubmit(async (configData) => { if (!nodeName || !nodeName.trim()) {
toast({
title: '保存失败',
description: '节点名称不能为空',
variant: 'destructive'
});
return;
}
// 使用 handleSubmit 验证输入映射(如果存在)
const submitForm = async () => {
setLoading(true); setLoading(true);
try { try {
// 获取输入映射数据 // 获取输入映射数据
@ -158,14 +148,20 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
inputData = inputForm.getValues(); inputData = inputForm.getValues();
} }
// 转换显示名称格式为 UUID 格式 // 转换显示名称格式为 UUID 格式(仅输入映射需要)
const uuidConfigs = convertObjectToUUID(configData as Record<string, any>, allNodes);
const uuidInputMapping = convertObjectToUUID(inputData as Record<string, any>, allNodes); 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> = { const updatedData: Partial<FlowNodeData> = {
label: (configData as any).nodeName || nodeDefinition.nodeName, label: nodeName.trim(),
configs: uuidConfigs, configs,
inputMapping: uuidInputMapping, inputMapping: uuidInputMapping,
outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : [] outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : []
}; };
@ -189,19 +185,25 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
})(); };
// 如果有输入映射schema使用表单验证
if (inputMappingSchema) {
inputForm.handleSubmit(submitForm)();
} else {
submitForm();
}
}; };
// ✅ 重置表单 // ✅ 重置表单
const handleReset = () => { const handleReset = () => {
if (!node || !nodeDefinition) return; if (!node || !nodeDefinition) return;
const defaultConfig = { // 重置节点信息
nodeName: nodeDefinition.nodeName, setNodeName(nodeDefinition.nodeName);
nodeCode: nodeDefinition.nodeCode, setDescription(nodeDefinition.description || '');
description: nodeDefinition.description || ''
}; // 重置输入映射
configForm.reset(defaultConfig);
inputForm.reset({}); inputForm.reset({});
toast({ toast({
@ -413,18 +415,62 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
<Accordion <Accordion
type="multiple" type="multiple"
defaultValue={["config"]} defaultValue={["info", "input"]}
className="w-full" className="w-full"
> >
{/* 基本配置 */} {/* 节点信息(固定表单,不通过 schema */}
<AccordionItem value="config" className="border-b"> <AccordionItem value="info" 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 space-y-4"> <AccordionContent className="px-1 space-y-4">
<Form {...configForm}> {/* 节点编码(只读) */}
{renderFormFields(nodeDefinition.configSchema, configForm)} <div className="space-y-2">
</Form> <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> </AccordionContent>
</AccordionItem> </AccordionItem>

View File

@ -35,33 +35,6 @@ export const EndEventNodeDefinition: BaseNodeDefinition = {
showBadge: false, // 结束节点不显示徽章 showBadge: false, // 结束节点不显示徽章
showHoverMenu: false // 结束节点不显示菜单 showHoverMenu: false // 结束节点不显示菜单
} }
},
configSchema: {
type: "object",
title: "基本配置",
description: "节点的基本信息",
properties: {
nodeName: {
type: "string",
title: "节点名称",
description: "节点在流程图中显示的名称",
default: "结束"
},
nodeCode: {
type: "string",
title: "节点编码",
description: "节点的唯一标识符",
default: "END_EVENT"
},
description: {
type: "string",
title: "节点描述",
description: "节点的详细说明",
default: "工作流的结束节点"
}
},
required: ["nodeName", "nodeCode"]
} }
}; };

View File

@ -38,33 +38,8 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
} }
}, },
// 基本配置Schema // 基本配置Schema仅业务配置元数据已移至固定表单
configSchema: { configSchema: undefined,
type: "object",
title: "基本配置",
description: "节点的基本信息和Jenkins构建配置参数",
properties: {
nodeName: {
type: "string",
title: "节点名称",
description: "节点在流程图中显示的名称",
default: "Jenkins构建"
},
nodeCode: {
type: "string",
title: "节点编码",
description: "节点的唯一标识符",
default: "JENKINS_BUILD"
},
description: {
type: "string",
title: "节点描述",
description: "节点的详细说明",
default: "通过Jenkins执行构建任务"
}
},
required: ["nodeName", "nodeCode", "jenkinsServerId"]
},
inputMappingSchema: { inputMappingSchema: {
type: "object", type: "object",
title: "输入", title: "输入",

View File

@ -38,33 +38,8 @@ export const NotificationNodeDefinition: ConfigurableNodeDefinition = {
} }
}, },
// 基本配置 Schema // 基本配置Schema仅业务配置元数据已移至固定表单
configSchema: { configSchema: undefined,
type: "object",
title: "基本配置",
description: "通知节点的基本配置信息",
properties: {
nodeName: {
type: "string",
title: "节点名称",
description: "节点在流程图中显示的名称",
default: "通知"
},
nodeCode: {
type: "string",
title: "节点编码",
description: "节点的唯一标识符",
default: "NOTIFICATION"
},
description: {
type: "string",
title: "节点描述",
description: "节点的详细说明",
default: "发送通知消息到指定渠道"
}
},
required: ["nodeName", "nodeCode", "notificationType", "title", "content"]
},
inputMappingSchema: { inputMappingSchema: {
type: "object", type: "object",
title: "输入", title: "输入",
@ -78,11 +53,17 @@ export const NotificationNodeDefinition: ConfigurableNodeDefinition = {
// enumNames: ["邮件", "钉钉", "企业微信", "短信"], // enumNames: ["邮件", "钉钉", "企业微信", "短信"],
// default: "EMAIL" // default: "EMAIL"
// }, // },
notificationType: { // notificationChannelType: {
// type: "string",
// title: "通知类型",
// description: "选择通知发送的渠道",
// 'x-dataSource': DataSourceType.NOTIFICATION_CHANNEL_TYPES
// },
notificationChannel: {
type: "string", type: "string",
title: "通知类型", title: "通知渠道",
description: "选择通知发送的渠道", description: "选择通知发送的渠道",
'x-dataSource': DataSourceType.NOTIFICATION_CHANNEL_TYPES 'x-dataSource': DataSourceType.NOTIFICATION_CHANNELS
}, },
title: { title: {
type: "string", type: "string",
@ -98,7 +79,7 @@ export const NotificationNodeDefinition: ConfigurableNodeDefinition = {
default: "" default: ""
} }
}, },
required: ["jenkinsServerId"] required: ["notificationChannel", "title", "content"]
}, },
outputs: [{ outputs: [{
name: "status", name: "status",

View File

@ -35,33 +35,6 @@ export const StartEventNodeDefinition: BaseNodeDefinition = {
showBadge: false, // 开始节点不显示徽章 showBadge: false, // 开始节点不显示徽章
showHoverMenu: false // 开始节点不显示菜单 showHoverMenu: false // 开始节点不显示菜单
} }
},
configSchema: {
type: "object",
title: "基本配置",
description: "节点的基本信息",
properties: {
nodeName: {
type: "string",
title: "节点名称",
description: "节点在流程图中显示的名称",
default: "开始"
},
nodeCode: {
type: "string",
title: "节点编码",
description: "节点的唯一标识符",
default: "START_EVENT"
},
description: {
type: "string",
title: "节点描述",
description: "节点的详细说明",
default: "工作流的起始节点"
}
},
required: ["nodeName", "nodeCode"]
} }
}; };

View File

@ -25,6 +25,7 @@ const BaseNode: React.FC<NodeProps> = ({ data, selected }) => {
} }
const schema = definition.inputMappingSchema; const schema = definition.inputMappingSchema;
const inputMapping = nodeData.inputMapping || {};
// 获取所有定义的输入字段(从 schema // 获取所有定义的输入字段(从 schema
if (!schema.properties) { if (!schema.properties) {
@ -33,10 +34,19 @@ const BaseNode: React.FC<NodeProps> = ({ data, selected }) => {
const allInputs = Object.keys(schema.properties).map(key => { const allInputs = Object.keys(schema.properties).map(key => {
const fieldSchema = schema.properties![key]; const fieldSchema = schema.properties![key];
const fieldValue = inputMapping[key];
// 检查字段是否已填写非空、非null、非undefined、非空字符串
const isFilled = fieldValue !== undefined &&
fieldValue !== null &&
fieldValue !== '' &&
(typeof fieldValue !== 'number' || !isNaN(fieldValue));
return { return {
key, key,
title: fieldSchema.title || key, title: fieldSchema.title || key,
description: fieldSchema.description, description: fieldSchema.description,
isFilled,
}; };
}); });
@ -51,13 +61,19 @@ const BaseNode: React.FC<NodeProps> = ({ data, selected }) => {
{/* 输入字段标签 */} {/* 输入字段标签 */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{allInputs.map((input, index) => ( {allInputs.map((input, index) => (
<code <span
key={index} key={index}
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md font-mono inline-flex items-center" className={`
title={input.description} text-xs px-2 py-1 rounded-md font-medium inline-flex items-center
${input.isFilled
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}
`}
title={`${input.description}${input.isFilled ? ' ✓ 已填写' : ' ✗ 未填写'}`}
> >
{input.title} {input.title}
</code> </span>
))} ))}
</div> </div>
</div> </div>
@ -76,13 +92,13 @@ const BaseNode: React.FC<NodeProps> = ({ data, selected }) => {
{/* 输出字段标签 */} {/* 输出字段标签 */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{nodeData.outputs.map((output, index) => ( {nodeData.outputs.map((output, index) => (
<code <span
key={index} key={index}
className="text-xs px-2 py-1 bg-secondary text-secondary-foreground rounded-md font-mono inline-flex items-center" className="text-xs px-2 py-1 bg-secondary text-secondary-foreground rounded-md font-medium inline-flex items-center"
title={output.description} title={output.description}
> >
{output.title || output.name} {output.title || output.name}
</code> </span>
))} ))}
</div> </div>
</div> </div>

View File

@ -8,7 +8,8 @@ export enum DataSourceType {
K8S_CLUSTERS = 'K8S_CLUSTERS', K8S_CLUSTERS = 'K8S_CLUSTERS',
GIT_REPOSITORIES = 'GIT_REPOSITORIES', GIT_REPOSITORIES = 'GIT_REPOSITORIES',
DOCKER_REGISTRIES = 'DOCKER_REGISTRIES', DOCKER_REGISTRIES = 'DOCKER_REGISTRIES',
NOTIFICATION_CHANNEL_TYPES = 'NOTIFICATION_CHANNEL_TYPES' NOTIFICATION_CHANNEL_TYPES = 'NOTIFICATION_CHANNEL_TYPES',
NOTIFICATION_CHANNELS = 'NOTIFICATION_CHANNELS'
} }
/** /**
@ -26,6 +27,7 @@ export interface DataSourceConfig {
export interface DataSourceOption { export interface DataSourceOption {
label: string; label: string;
value: any; value: any;
[key: string]: any; [key: string]: any;
} }
@ -35,7 +37,7 @@ export interface DataSourceOption {
export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = { export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
[DataSourceType.JENKINS_SERVERS]: { [DataSourceType.JENKINS_SERVERS]: {
url: '/api/v1/external-system/list', url: '/api/v1/external-system/list',
params: { type: 'JENKINS', enabled: true }, params: {type: 'JENKINS', enabled: true},
transform: (data: any[]) => { transform: (data: any[]) => {
return data.map((item: any) => ({ return data.map((item: any) => ({
label: `${item.name} (${item.url})`, label: `${item.name} (${item.url})`,
@ -54,9 +56,18 @@ export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
})); }));
} }
}, },
[DataSourceType.NOTIFICATION_CHANNELS]: {
url: '/api/v1/notification-channel/list',
transform: (data: any[]) => {
return data.map((item: any) => ({
label: `(${item.channelType})-${item.name}`,
value: item.id
}));
}
},
[DataSourceType.K8S_CLUSTERS]: { [DataSourceType.K8S_CLUSTERS]: {
url: '/api/v1/k8s-cluster/list', url: '/api/v1/k8s-cluster/list',
params: { enabled: true }, params: {enabled: true},
transform: (data: any[]) => { transform: (data: any[]) => {
return data.map((item: any) => ({ return data.map((item: any) => ({
label: item.name, label: item.name,
@ -67,7 +78,7 @@ export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
}, },
[DataSourceType.GIT_REPOSITORIES]: { [DataSourceType.GIT_REPOSITORIES]: {
url: '/api/v1/git-repo/list', url: '/api/v1/git-repo/list',
params: { enabled: true }, params: {enabled: true},
transform: (data: any[]) => { transform: (data: any[]) => {
return data.map((item: any) => ({ return data.map((item: any) => ({
label: `${item.name} (${item.url})`, label: `${item.name} (${item.url})`,
@ -78,7 +89,7 @@ export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
}, },
[DataSourceType.DOCKER_REGISTRIES]: { [DataSourceType.DOCKER_REGISTRIES]: {
url: '/api/v1/docker-registry/list', url: '/api/v1/docker-registry/list',
params: { enabled: true }, params: {enabled: true},
transform: (data: any[]) => { transform: (data: any[]) => {
return data.map((item: any) => ({ return data.map((item: any) => ({
label: item.name, label: item.name,
@ -96,14 +107,14 @@ export const DATA_SOURCE_REGISTRY: Record<DataSourceType, DataSourceConfig> = {
*/ */
export const loadDataSource = async (type: DataSourceType): Promise<DataSourceOption[]> => { export const loadDataSource = async (type: DataSourceType): Promise<DataSourceOption[]> => {
const config = DATA_SOURCE_REGISTRY[type]; const config = DATA_SOURCE_REGISTRY[type];
if (!config) { if (!config) {
console.error(`数据源类型 ${type} 未配置`); console.error(`数据源类型 ${type} 未配置`);
return []; return [];
} }
try { try {
const response = await request.get(config.url, { params: config.params }); const response = await request.get(config.url, {params: config.params});
// request 拦截器已经提取了 data 字段response 直接是数组 // request 拦截器已经提取了 data 字段response 直接是数组
return config.transform(response || []); return config.transform(response || []);
} catch (error) { } catch (error) {