From 611b14dfea90a775de26b487ffa8eda2dc9b935d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 24 Mar 2026 15:13:50 +0800 Subject: [PATCH] feat(web): if-else node show cases --- .../components/Nodes/ConditionNode.tsx | 68 +++++++++++--- .../components/Properties/CaseList/index.tsx | 46 ++++++---- .../views/Workflow/hooks/useWorkflowGraph.ts | 8 +- web/src/views/Workflow/utils.ts | 90 +++++++++++++++++++ 4 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 web/src/views/Workflow/utils.ts diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 2bacd4ce..1b84b6bf 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -1,26 +1,49 @@ +import { useRef } from 'react' import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; import NodeTools from './NodeTools' +import { useVariableList } from '../Properties/hooks/useVariableList' const caculateIsSet = (item: any, type: string) => { - switch(type) { + switch (type) { case 'categories': return typeof item?.class_name === 'string' && item?.class_name !== '' - case 'cases': - return item.expressions.length > 0 && item.expressions.filter((vo: any) => { - const keys = Object.keys(vo) - return keys.length === 0 || (keys.length > 0 - && ((['not_empty', 'empty'].includes(vo.operator) && (['undefined', 'null'].includes(typeof vo.left) || vo.left === '')) - || (!['not_empty', 'empty'].includes(vo.operator) && (['undefined', 'null'].includes(typeof vo.right) || vo.right === '')))) - }).length === 0 + case 'cases': { + if (!item.left) return false + if (['not_empty', 'empty'].includes(item.operator)) return true + return !!item.left && (!!item.right || typeof item.right === 'boolean') + } } } const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { const data = node?.getData() || {}; const { t } = useTranslation() + const graphRef = useRef(node?.model?.graph) + const variableList = useVariableList(node ?? null, graphRef, []) + + const getLocaleField = (field: string, filedType: string) => { + const key = filedType === 'boolean' ? `workflow.config.if-else..boolean.${field}` : filedType === 'number' ? `workflow.config.if-else.num.${field}` : `workflow.config.if-else.${field}` + const value = t(key) + return value !== key ? value : t(`workflow.config.if-else.num.${field}`) + }; + const labelRender = (value: string) => { + const filterOption = variableList.find(vo => `{{${vo.value}}}` === value) + + if (filterOption) { + return ( + + {`{x}`} {filterOption.label} + + ) + } + return null + } return (
{ {data.type === 'if-else' && {data.config?.cases?.defaultValue.map((item: any, index: number) => ( -
- - {index === 0 ? 'IF' : `ELIF`} - {caculateIsSet(item, 'cases') ? t(`workflow.config.${data.type}.set`) : t(`workflow.config.${data.type}.unset`)} +
0 ? '' : 'rb:mb-1'}> + 0 ? "space-between" : 'end'} className="rb:mb-1"> + {item.expressions.length > 0 && CASE{index + 1}} + {index === 0 ? 'IF' : `ELIF`} + {item.expressions.length > 0 && + {item.expressions.map((expression: any, eIndex: number) => ( +
+ {item.expressions.length > 1 && eIndex > 0 &&
{item.logical_operator.toLocaleUpperCase()}
} + + {caculateIsSet(expression, 'cases') + ? <> + {labelRender(expression.left)} + {getLocaleField(expression.operator, typeof expression.right)} + {!['not_empty', 'empty'].includes(expression.operator) && {typeof expression.right === 'boolean' ? String(expression.right).charAt(0).toUpperCase() + String(expression.right).slice(1) : expression.right}} + + : t(`workflow.config.${data.type}.unset`) + } + +
+ ))} +
}
))} -
+ ELSE -
+
}
diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 0fec5c64..43c18791 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:24:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-06 14:24:57 + * @Last Modified time: 2026-03-24 15:00:46 */ import { type FC } from 'react' import clsx from 'clsx' @@ -12,9 +12,10 @@ import { Form, Button, Select, Space, Divider, InputNumber, type SelectProps, Fl import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import VariableSelect from '../VariableSelect' import Editor from '../../Editor' -import { edgeAttrs, conditionNodeItemHeight, nodeWidth, portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from '../../../constant' +import { edgeAttrs, nodeWidth } from '../../../constant' import RbButton from '@/components/RbButton'; import RadioGroupBtn from '../RadioGroupBtn' +import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../../../utils' interface CaseListProps { value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>; @@ -60,6 +61,16 @@ const CaseList: FC = ({ const { t } = useTranslation(); const form = Form.useFormInstance(); + // Recalculate node height and port Y positions without rebuilding ports + const updateNodeLayout = (cases: any[]) => { + if (!selectedNode || !graphRef?.current) return; + selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) }); + cases.forEach((_c: any, i: number) => { + selectedNode.portProp(`CASE${i + 1}`, 'args/y', getConditionNodeCasePortY(cases, i)); + }); + selectedNode.portProp(`CASE${cases.length + 1}`, 'args/y', getConditionNodeCasePortY(cases, cases.length)); + }; + // Update node ports based on case count changes (add/remove cases) const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { if (!selectedNode || !graphRef?.current) return; @@ -89,19 +100,10 @@ const CaseList: FC = ({ selectedNode.removePort(port.id); } }); - - // Calculate new node height: base height 88px + 30px for each additional port - const totalPorts = caseCount + 1; // IF/ELIF + ELSE - const newHeight = conditionNodeHeight + (totalPorts - 2) * conditionNodeItemHeight; - selectedNode.prop('size', { width: nodeWidth, height: newHeight }) + const cases = form.getFieldValue(name) || []; + selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) }); - // Update right port x position - currentPorts.forEach((port: any) => { - if (port.group === 'right' && port.args) { - selectedNode.portProp(port.id!, 'args/x', nodeWidth); - } - }); // Add ELIF ports for (let i = 0; i < caseCount; i++) { selectedNode.addPort({ @@ -109,7 +111,7 @@ const CaseList: FC = ({ group: 'right', args: { x: nodeWidth, - y: portItemArgsY * i + conditionNodePortItemArgsY, + y: getConditionNodeCasePortY(cases, i), }, }); } @@ -120,7 +122,7 @@ const CaseList: FC = ({ group: 'right', args: { x: nodeWidth, - y: portItemArgsY * caseCount + conditionNodePortItemArgsY, + y: getConditionNodeCasePortY(cases, caseCount), }, }); @@ -351,7 +353,10 @@ const CaseList: FC = ({
removeCondition(conditionField.name)} + onClick={() => { + removeCondition(conditionField.name); + setTimeout(() => updateNodeLayout(form.getFieldValue(name) || []), 100); + }} >
) @@ -360,14 +365,17 @@ const CaseList: FC = ({ - + {caseFields.length > 1 && = ({ > {t('common.remove')} - + } diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 86cb91d5..197e4e4b 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-20 11:26:43 + * @Last Modified time: 2026-03-24 15:01:52 */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -17,6 +17,7 @@ import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' import { useUser } from '@/store/user'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types' +import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils' /** * Props for useWorkflowGraph hook @@ -218,7 +219,6 @@ export const useWorkflowGraph = ({ // Generate ports dynamically for if-else node based on cases if (type === 'if-else' && config.cases && Array.isArray(config.cases)) { const totalPorts = config.cases.length + 1; // IF/ELIF + ELSE - const newHeight = conditionNodeHeight + (totalPorts - 2) * conditionNodeItemHeight; const portItems: PortMetadata[] = [ defaultPortItems[0], @@ -230,7 +230,7 @@ export const useWorkflowGraph = ({ id: `CASE${i + 1}`, args: { x: nodeWidth, - y: portItemArgsY * i + conditionNodePortItemArgsY, + y: getConditionNodeCasePortY(config.cases, i), }, }); } @@ -240,7 +240,7 @@ export const useWorkflowGraph = ({ items: portItems }; - nodeConfig.height = newHeight; + nodeConfig.height = calcConditionNodeTotalHeight(config.cases); } // Generate ports dynamically for question-classifier node based on categories diff --git a/web/src/views/Workflow/utils.ts b/web/src/views/Workflow/utils.ts new file mode 100644 index 00000000..67a913f3 --- /dev/null +++ b/web/src/views/Workflow/utils.ts @@ -0,0 +1,90 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-03-24 15:07:49 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-24 15:07:49 + */ + +import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from './constant' + +/** + * Calculate the total height of a condition (if-else) node based on its cases. + * + * The height is composed of: + * - `conditionNodeHeight`: the base height of the node (header + padding). + * - `(cases.length - 1) * 26`: vertical spacing added for each additional case + * beyond the first (each case separator row is 26px). + * - `exprCount * 20`: each individual expression row occupies 20px. + * - `hasMultiExprCount * 3`: a small extra padding (3px per expression) is added + * for cases that contain more than one expression, to account for the logical + * operator indicator (AND/OR) between expressions. + * + * @param cases - Array of case objects, each containing an `expressions` array. + * @returns The total pixel height for the condition node. + */ +export const calcConditionNodeTotalHeight = (cases: any[]) => { + // Total number of expressions across all cases + const exprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length || 0), 0); + // Sum of expression counts only for cases that have more than one expression + const hasMultiExprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length > 1 ? c?.expressions?.length : 0), 0); + + return conditionNodeHeight + (cases.length - 1) * 26 + exprCount * 20 + hasMultiExprCount * 3; +}; + +/** + * Calculate the Y-coordinate of the right-side output port for a specific case + * in a condition (if-else) node. + * + * The port position is determined by iterating through all preceding cases + * (index 0 to caseIndex - 1) and accumulating their visual heights. Several + * pixel-level corrections are applied to align ports with the rendered UI: + * + * 1. **Base offset**: starts at `conditionNodePortItemArgsY`, which is the Y + * position of the first case port relative to the node top. + * + * 2. **Per-case accumulation**: for each preceding case with `n` expressions, + * add `portItemArgsY * (n + 1)` — this accounts for `n` expression rows + * plus one case header/separator row. + * + * 3. **Single-expression correction**: cases with exactly 1 expression render + * slightly shorter than the generic formula predicts. Subtract + * `singleExprCount * 7 + 2` to compensate for the reduced row height when + * no logical operator row is shown. + * + * 4. **Multi-expression correction**: cases with 2+ expressions have a compact + * logical operator row. Subtract `multiExprCount * 9` to offset the + * over-estimated spacing. + * + * 5. **Extra expression correction**: for cases with more than 2 expressions, + * each additional expression beyond the second introduces a minor spacing + * discrepancy. Subtract `(extraExprs + 1) * 2` to fine-tune alignment. + * + * @param cases - Array of case objects, each containing an `expressions` array. + * @param caseIndex - The zero-based index of the target case whose port Y is needed. + * @returns The Y-coordinate (in pixels) for the output port of the given case. + */ +export const getConditionNodeCasePortY = (cases: any[], caseIndex: number) => { + let y = conditionNodePortItemArgsY; + let singleExprCount = 0; + let multiExprCount = 0; + let extraExprs = 0; + + for (let i = 0; i < caseIndex; i++) { + const n = cases[i]?.expressions?.length || 0; + y += portItemArgsY * (n + 1); + if (n === 1) singleExprCount++; + else if (n >= 2) { + multiExprCount++; + if (n > 2) extraExprs += n - 2; + } + } + + // Correction for single-expression cases (slightly shorter rendered height) + if (singleExprCount > 0) y -= singleExprCount * 7 + 2; + // Correction for multi-expression cases (compact logical operator row) + y -= multiExprCount * 9; + // Correction for cases with more than 2 expressions (minor spacing drift) + if (extraExprs > 0) y -= (extraExprs + 1) * 2; + + return y; +};