diff --git a/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/dto/AppInputParam.java b/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/dto/AppInputParam.java index 97bb73c59..ec762f4cd 100644 --- a/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/dto/AppInputParam.java +++ b/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/dto/AppInputParam.java @@ -10,18 +10,17 @@ import static modelengine.fitframework.util.ObjectUtils.cast; import static modelengine.fitframework.util.StringUtils.lengthBetween; -import modelengine.fit.jober.aipp.common.exception.AippParamException; -import modelengine.fit.jober.aipp.enums.InputParamType; - import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import modelengine.fitframework.inspection.Validation; -import modelengine.fitframework.util.MapBuilder; +import modelengine.fit.jober.aipp.common.exception.AippParamException; import modelengine.fitframework.util.ObjectUtils; import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -32,76 +31,257 @@ * @since 2024-11-25 */ @Data +@Builder @NoArgsConstructor +@AllArgsConstructor public class AppInputParam { - private int stringMaxLength = 500; - private Map> paramTypePredicateMap; + private static final int DEFAULT_STRING_MAX_LENGTH = 500; + private static final BigDecimal DEFAULT_MIN_NUMBER = new BigDecimal("-999999999.99"); + private static final BigDecimal DEFAULT_MAX_NUMBER = new BigDecimal("999999999.99"); + private static final int MAX_DECIMAL_PLACES = 2; + private String name; private String type; - private String description; + private String displayName; private boolean isRequired; private boolean isVisible; + private Integer stringMaxLength; + private Predicate validator; /** - * 通过键值对构建一个 {@link AppInputParam} 对象. + * 通过键值对构建一个 {@link AppInputParam} 对象 * - * @param rawParam 原始参数. - * @return {@link AppInputParam} 对象. + * @param rawParam 原始参数 + * @return {@link AppInputParam} 对象 */ public static AppInputParam from(Map rawParam) { - AppInputParam appInputParam = new AppInputParam(); - appInputParam.setName(ObjectUtils.cast(rawParam.get("name"))); - appInputParam.setType(ObjectUtils.cast(rawParam.get("type"))); - appInputParam.setDescription(ObjectUtils.cast(rawParam.get("description"))); - appInputParam.setRequired(ObjectUtils.cast(rawParam.getOrDefault("isRequired", true))); - appInputParam.setVisible(ObjectUtils.cast(rawParam.getOrDefault("isVisible", true))); - Integer stringMaxLength = cast(rawParam.getOrDefault("stringMaxLength", 500)); - appInputParam.setStringMaxLength(stringMaxLength); - Map> paramTypePredicateMap - = MapBuilder.>get() - .put(InputParamType.STRING_TYPE, - v -> v instanceof String && lengthBetween(cast(v), 1, stringMaxLength, true, true)) - .put(InputParamType.BOOLEAN_TYPE, v -> v instanceof Boolean) - .put(InputParamType.INTEGER_TYPE, - v -> v instanceof Integer && ObjectUtils.between((int) v, -999999999, 999999999)) - .put(InputParamType.NUMBER_TYPE, AppInputParam::isValidNumber) + if (rawParam == null) { + throw new IllegalArgumentException("rawParam cannot be null"); + } + + AppearanceConfig appearance = AppearanceConfig.from(rawParam); + String type = cast(rawParam.get("type")); + + return AppInputParam.builder() + .name(cast(rawParam.get("name"))) + .type(type) + .displayName(cast(rawParam.get("displayName"))) + .isRequired(cast(rawParam.getOrDefault("isRequired", true))) + .isVisible(cast(rawParam.getOrDefault("isVisible", true))) + .stringMaxLength(appearance.getStringMaxLength()) + .validator(createValidator(appearance, type)) .build(); - appInputParam.setParamTypePredicateMap(paramTypePredicateMap); - return appInputParam; } - private static boolean isValidNumber(Object value) { + /** + * 校验数据 + * + * @param dataMap 数据集合 + * @throws AippParamException 当参数无效时抛出 + */ + public void validate(Map dataMap) { + if (dataMap == null) { + throw new IllegalArgumentException("dataMap cannot be null"); + } + + Object value = dataMap.get(this.name); + + // 校验必填项 + if (this.isRequired && value == null) { + throw new AippParamException(INPUT_PARAM_IS_INVALID, this.displayName); + } + + // 如果值为null且非必填,则跳过后续校验 + if (value == null) { + return; + } + + // 使用预构建的校验器 + if (this.validator != null && !this.validator.test(value)) { + throw new AippParamException(INPUT_PARAM_IS_INVALID, this.displayName); + } + } + + /** + * 根据外观配置创建校验器 + */ + private static Predicate createValidator(AppearanceConfig config, String type) { + String displayType = config.getDisplayType(); + + if (displayType == null) { + return createDefaultValidator(type); + } + + return switch (displayType) { + case "input" -> createStringValidator(config.getStringMaxLength()); + case "number" -> createNumberValidator(config.getMinValue(), config.getMaxValue()); + case "dropdown" -> createDropdownValidator(config.getOptions()); + case "switch" -> createBooleanValidator(); + case "multiselect" -> createArrayValidator(config.getOptions()); + default -> createDefaultValidator(type); + }; + } + + private static Predicate createStringValidator(Integer maxLength) { + return value -> { + if (!(value instanceof String str)) { + return false; + } + // 如果没有设置 maxLength,则不限制长度 + return maxLength == null || lengthBetween(str, 0, maxLength, true, true); + }; + } + + private static Predicate createNumberValidator(BigDecimal minValue, BigDecimal maxValue) { + return value -> { + if (!(value instanceof Number)) { + return false; + } + + try { + BigDecimal numberValue = new BigDecimal(value.toString()); + + if (minValue != null && numberValue.compareTo(minValue) < 0) { + return false; + } + + if (maxValue != null && numberValue.compareTo(maxValue) > 0) { + return false; + } + + return true; + } catch (NumberFormatException e) { + return false; + } + }; + } + + private static Predicate createDropdownValidator(List options) { + return value -> options != null && + !options.isEmpty() && + options.contains(String.valueOf(value)); + } + + private static Predicate createArrayValidator(List options) { + return value -> { + if (options == null || options.isEmpty()) { + return false; + } + + if (!(value instanceof Collection valueCollection)) { + return false; + } + + return valueCollection.stream() + .map(String::valueOf) + .allMatch(options::contains); + }; + } + + private static Predicate createBooleanValidator() { + return value -> value instanceof Boolean; + } + + private static Predicate createDefaultValidator(String type) { + if (type == null) { + return value -> false; // 如果类型未定义,则校验失败 + } + + return switch (type.toLowerCase()) { + case "string" -> value -> { + if (!(value instanceof String str)) { + return false; + } + return lengthBetween(str, 1, DEFAULT_STRING_MAX_LENGTH, true, true); + }; + + case "integer"-> value -> { + if (value instanceof Integer) { + return ObjectUtils.between((int) value, -999999999, 999999999); + } + return false; + }; + + case "number" -> value -> { + if (value instanceof Number) { + return isValidDecimalNumber(value); + } + return false; + }; + + case "boolean" -> value -> value instanceof Boolean; + + default -> value -> true; + }; + } + + private static boolean isValidDecimalNumber(Object value) { if (!(value instanceof Number)) { return false; } - BigDecimal numberValue = new BigDecimal(value.toString()); - if (numberValue.compareTo(new BigDecimal("-999999999.99")) < 0 - || numberValue.compareTo(new BigDecimal("999999999.99")) > 0) { + + try { + BigDecimal numberValue = new BigDecimal(value.toString()); + + // 检查数值范围 + if (numberValue.compareTo(DEFAULT_MIN_NUMBER) < 0 || + numberValue.compareTo(DEFAULT_MAX_NUMBER) > 0) { + return false; + } + + // 检查小数位数 + return numberValue.scale() <= MAX_DECIMAL_PLACES; + } catch (NumberFormatException e) { return false; } - int scale = numberValue.scale(); - return scale <= 2; } /** - * 校验数据. - * - * @param dataMap 数据集合. + * 外观配置内部类,用于封装外观相关参数 */ - public void validate(Map dataMap) { - String paramName = this.getName(); - if (this.isRequired()) { - Validation.notNull(cast(dataMap.get(paramName)), - () -> new AippParamException(INPUT_PARAM_IS_INVALID, paramName)); + @Getter + private static class AppearanceConfig { + private final String displayType; + private final Integer stringMaxLength; + private final BigDecimal minValue; + private final BigDecimal maxValue; + private final List options; + + private AppearanceConfig(String displayType, Integer stringMaxLength, + BigDecimal minValue, BigDecimal maxValue, List options) { + this.displayType = displayType; + this.stringMaxLength = stringMaxLength; + this.minValue = minValue; + this.maxValue = maxValue; + this.options = options; } - if (dataMap.get(paramName) == null) { - return; + + public static AppearanceConfig from(Map rawParam) { + Map appearance = cast(rawParam.get("appearance")); + + if (appearance == null) { + return new AppearanceConfig(null, null, null, null, null); + } + + String displayType = cast(appearance.get("displayType")); + Integer stringMaxLength = cast(appearance.get("maxLength")); + + BigDecimal minValue = parseNumber(appearance.get("minValue")); + BigDecimal maxValue = parseNumber(appearance.get("maxValue")); + List options = cast(appearance.get("options")); + + return new AppearanceConfig(displayType, stringMaxLength, minValue, maxValue, options); } - Object v = dataMap.get(this.getName()); - Predicate predicate = paramTypePredicateMap.get(InputParamType.getParamType(this.getType())); - if (!predicate.test(v)) { - throw new AippParamException(INPUT_PARAM_IS_INVALID, paramName); + private static BigDecimal parseNumber(Object value) { + if (value == null) { + return null; + } + try { + return new BigDecimal(value.toString()); + } catch (NumberFormatException e) { + return null; + } } } } diff --git a/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/util/FlowUtils.java b/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/util/FlowUtils.java index 7d6033ccb..8d439dbf4 100644 --- a/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/util/FlowUtils.java +++ b/app-builder/plugins/aipp-plugin/src/main/java/modelengine/fit/jober/aipp/util/FlowUtils.java @@ -37,7 +37,7 @@ public static List getAppInputParams(FlowsService service, String AppInputParam param = new AppInputParam(); param.setName(ObjectUtils.cast(rawParam.get("name"))); param.setType(ObjectUtils.cast(rawParam.get("type"))); - param.setDescription(ObjectUtils.cast(rawParam.get("description"))); + param.setDisplayName(ObjectUtils.cast(rawParam.get("displayName"))); param.setRequired(ObjectUtils.cast(rawParam.getOrDefault("isRequired", true))); param.setVisible(ObjectUtils.cast(rawParam.getOrDefault("isVisible", true))); return param; diff --git a/app-builder/plugins/aipp-plugin/src/test/java/modelengine/fit/jober/aipp/dto/AppInputParamTest.java b/app-builder/plugins/aipp-plugin/src/test/java/modelengine/fit/jober/aipp/dto/AppInputParamTest.java new file mode 100644 index 000000000..4585bf259 --- /dev/null +++ b/app-builder/plugins/aipp-plugin/src/test/java/modelengine/fit/jober/aipp/dto/AppInputParamTest.java @@ -0,0 +1,803 @@ +package modelengine.fit.jober.aipp.dto; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import modelengine.fit.jober.aipp.common.exception.AippParamException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * {@link AppBuilderSaveConfigDto} 的测试类。 + * + * @author 孙怡菲 + * @since 2025-09-28 + */ +class AppInputParamTest { + @Nested + @DisplayName("from() 方法测试") + class FromMethodTest { + @Test + @DisplayName("应该成功创建基本的 AppInputParam 对象") + void shouldCreateBasicAppInputParam() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "testParam"); + rawParam.put("type", "string"); + rawParam.put("displayName", "测试参数"); + rawParam.put("isRequired", true); + rawParam.put("isVisible", false); + + // When + AppInputParam result = AppInputParam.from(rawParam); + + // Then + assertAll(() -> assertEquals("testParam", result.getName()), + () -> assertEquals("string", result.getType()), + () -> assertEquals("测试参数", result.getDisplayName()), + () -> assertTrue(result.isRequired()), + () -> assertFalse(result.isVisible()), + () -> assertNotNull(result.getValidator())); + } + + @Test + @DisplayName("应该使用默认值创建 AppInputParam 对象") + void shouldCreateAppInputParamWithDefaults() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "testParam"); + rawParam.put("type", "string"); + + // When + AppInputParam result = AppInputParam.from(rawParam); + + // Then + assertAll(() -> assertTrue(result.isRequired()), + () -> assertTrue(result.isVisible()), + () -> assertNull(result.getStringMaxLength())); + } + + @Test + @DisplayName("当 rawParam 为 null 时应该抛出异常") + void shouldThrowExceptionWhenRawParamIsNull() { + // When & Then + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> AppInputParam.from(null)); + assertEquals("rawParam cannot be null", exception.getMessage()); + } + + @Test + @DisplayName("应该正确解析 input 类型的外观配置") + void shouldParseInputAppearanceConfig() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "input"); + appearance.put("maxLength", 100); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "inputParam"); + rawParam.put("type", "string"); + rawParam.put("appearance", appearance); + + // When + AppInputParam result = AppInputParam.from(rawParam); + + // Then + assertEquals(100, result.getStringMaxLength()); + } + + @Test + @DisplayName("应该正确解析 number 类型的外观配置") + void shouldParseNumberAppearanceConfig() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "number"); + appearance.put("minValue", "10.5"); + appearance.put("maxValue", "100.99"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "numberParam"); + rawParam.put("type", "number"); + rawParam.put("appearance", appearance); + + // When + AppInputParam result = AppInputParam.from(rawParam); + + // Then + assertNotNull(result.getValidator()); + } + + @Test + @DisplayName("应该正确解析 dropdown 类型的外观配置") + void shouldParseDropdownAppearanceConfig() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "dropdown"); + appearance.put("options", Arrays.asList("option1", "option2", "option3")); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "dropdownParam"); + rawParam.put("type", "string"); + rawParam.put("appearance", appearance); + + // When + AppInputParam result = AppInputParam.from(rawParam); + + // Then + assertNotNull(result.getValidator()); + } + } + + @Nested + @DisplayName("validate() 方法测试") + class ValidateMethodTest { + private AppInputParam requiredParam; + private AppInputParam optionalParam; + + @BeforeEach + void setUp() { + requiredParam = AppInputParam.builder() + .name("requiredParam") + .displayName("必填参数") + .type("string") + .isRequired(true) + .validator(value -> value instanceof String && ((String) value).length() <= 100) + .build(); + + optionalParam = AppInputParam.builder() + .name("optionalParam") + .displayName("可选参数") + .type("string") + .isRequired(false) + .validator(value -> value instanceof String) + .build(); + } + + @Test + @DisplayName("当 dataMap 为 null 时应该抛出异常") + void shouldThrowExceptionWhenDataMapIsNull() { + // When & Then + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> requiredParam.validate(null)); + assertEquals("dataMap cannot be null", exception.getMessage()); + } + + @Test + @DisplayName("必填参数为 null 时应该抛出 AippParamException") + void shouldThrowAippParamExceptionWhenRequiredParamIsNull() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("requiredParam", null); + + // When & Then + AippParamException exception = + assertThrows(AippParamException.class, () -> requiredParam.validate(dataMap)); + // 这里假设异常信息包含参数展示名 + assertTrue(exception.getMessage().contains("必填参数") || exception.getLocalizedMessage() + .contains("必填参数")); + } + + @Test + @DisplayName("必填参数不存在时应该抛出 AippParamException") + void shouldThrowAippParamExceptionWhenRequiredParamNotExists() { + // Given + Map dataMap = new HashMap<>(); + // 不添加 requiredParam + + // When & Then + assertThrows(AippParamException.class, () -> requiredParam.validate(dataMap)); + } + + @Test + @DisplayName("可选参数为 null 时应该通过校验") + void shouldPassValidationWhenOptionalParamIsNull() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("optionalParam", null); + + // When & Then + assertDoesNotThrow(() -> optionalParam.validate(dataMap)); + } + + @Test + @DisplayName("参数值通过校验器校验时应该成功") + void shouldPassWhenValuePassesValidator() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("requiredParam", "valid string"); + + // When & Then + assertDoesNotThrow(() -> requiredParam.validate(dataMap)); + } + + @Test + @DisplayName("参数值未通过校验器校验时应该抛出异常") + void shouldThrowExceptionWhenValueFailsValidator() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("requiredParam", 123); // 数字类型,但校验器期望字符串 + + // When & Then + assertThrows(AippParamException.class, () -> requiredParam.validate(dataMap)); + } + } + + @Nested + @DisplayName("校验器功能测试") + class ValidatorTest { + @Nested + @DisplayName("字符串校验器测试") + class StringValidatorTest { + private AppInputParam stringParam; + + @BeforeEach + void setUp() { + Map appearance = new HashMap<>(); + appearance.put("displayType", "input"); + appearance.put("maxLength", 10); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "stringParam"); + rawParam.put("type", "string"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + this.stringParam = AppInputParam.from(rawParam); + } + + @ParameterizedTest + @ValueSource(strings = {"", "hello", "1234567890"}) + @DisplayName("有效字符串应该通过校验") + void shouldPassValidStrings(String value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("stringParam", value); + + // When & Then + assertDoesNotThrow(() -> this.stringParam.validate(dataMap)); + } + + @ParameterizedTest + @ValueSource(strings = {"12345678901", "this is too long"}) + @DisplayName("超长字符串应该校验失败") + void shouldFailTooLongStrings(String value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("stringParam", value); + + // When & Then + assertThrows(AippParamException.class, () -> this.stringParam.validate(dataMap)); + } + + @Test + @DisplayName("非字符串类型应该校验失败") + void shouldFailNonStringTypes() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("stringParam", 123); + + // When & Then + assertThrows(AippParamException.class, () -> this.stringParam.validate(dataMap)); + } + + @Test + @DisplayName("没有设置最大长度可以校验成功") + void shouldSuccessWithoutMaxLength() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "input"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "stringParam"); + rawParam.put("type", "String"); + rawParam.put("appearance", appearance); + AppInputParam param = AppInputParam.from(rawParam); + + Map dataMap = new HashMap<>(); + dataMap.put("stringParam", "test"); + + // When & Then + assertDoesNotThrow(() -> param.validate(dataMap)); + } + } + + @Nested + @DisplayName("数字校验器测试") + class NumberValidatorTest { + private AppInputParam numberParam; + + @BeforeEach + void setUp() { + Map appearance = new HashMap<>(); + appearance.put("displayType", "number"); + appearance.put("minValue", "10"); + appearance.put("maxValue", "100"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "numberParam"); + rawParam.put("type", "number"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + this.numberParam = AppInputParam.from(rawParam); + } + + @ParameterizedTest + @ValueSource(doubles = {10.0, 50.5, 100.0}) + @DisplayName("范围内的数字应该通过校验") + void shouldPassValidNumbers(double value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("numberParam", value); + + // When & Then + assertDoesNotThrow(() -> this.numberParam.validate(dataMap)); + } + + @ParameterizedTest + @ValueSource(doubles = {9.9, 100.1, -5, 200}) + @DisplayName("超出范围的数字应该校验失败") + void shouldFailOutOfRangeNumbers(double value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("numberParam", value); + + // When & Then + assertThrows(AippParamException.class, () -> this.numberParam.validate(dataMap)); + } + + @Test + @DisplayName("非数字类型应该校验失败") + void shouldFailNonNumberTypes() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("numberParam", "not a number"); + + // When & Then + assertThrows(AippParamException.class, () -> this.numberParam.validate(dataMap)); + } + + @Test + @DisplayName("没有设置数字范围可以校验成功") + void shouldSuccessWithoutMaxLength() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "number"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "numberParam"); + rawParam.put("type", "Number"); + rawParam.put("appearance", appearance); + AppInputParam param = AppInputParam.from(rawParam); + + // When + Map dataMap = new HashMap<>(); + dataMap.put("numberParam", -1.2); + + // then + assertDoesNotThrow(() -> param.validate(dataMap)); + } + + } + + @Nested + @DisplayName("下拉框校验器测试") + class DropdownValidatorTest { + private AppInputParam dropdownParam; + + @BeforeEach + void setUp() { + Map appearance = new HashMap<>(); + appearance.put("displayType", "dropdown"); + appearance.put("options", Arrays.asList("option1", "option2", "option3")); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "dropdownParam"); + rawParam.put("type", "string"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + this.dropdownParam = AppInputParam.from(rawParam); + } + + @ParameterizedTest + @ValueSource(strings = {"option1", "option2", "option3"}) + @DisplayName("有效选项应该通过校验") + void shouldPassValidOptions(String value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("dropdownParam", value); + + // When & Then + assertDoesNotThrow(() -> this.dropdownParam.validate(dataMap)); + } + + @ParameterizedTest + @ValueSource(strings = {"option4", "invalid", ""}) + @DisplayName("无效选项应该校验失败") + void shouldFailInvalidOptions(String value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("dropdownParam", value); + + // When & Then + assertThrows(AippParamException.class, () -> this.dropdownParam.validate(dataMap)); + } + } + + @Nested + @DisplayName("数组校验器测试") + class ArrayValidatorTest { + private AppInputParam arrayParam; + private List options; + + @BeforeEach + void setUp() { + this.options = Arrays.asList("option1", "option2", "option3", "option4"); + + Map appearance = new HashMap<>(); + appearance.put("displayType", "multiselect"); + appearance.put("options", this.options); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "arrayParam"); + rawParam.put("type", "Array"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + this.arrayParam = AppInputParam.from(rawParam); + } + + @ParameterizedTest + @MethodSource("validArrayValues") + @DisplayName("有效数组值应该通过校验") + void shouldPassValidArrayValues(List value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("arrayParam", value); + + // When & Then + assertDoesNotThrow(() -> this.arrayParam.validate(dataMap)); + } + + private static Stream validArrayValues() { + return Stream.of( + Arguments.of(Arrays.asList("option1")), + Arguments.of(Arrays.asList("option1", "option2")), + Arguments.of(Arrays.asList("option3", "option4")), + Arguments.of(Arrays.asList("option1", "option2", "option3", "option4")), + Arguments.of(Collections.emptyList()) + ); + } + + @ParameterizedTest + @MethodSource("invalidArrayValues") + @DisplayName("无效数组值应该校验失败") + void shouldFailInvalidArrayValues(List value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("arrayParam", value); + + // When & Then + assertThrows(AippParamException.class, () -> this.arrayParam.validate(dataMap)); + } + + private static Stream invalidArrayValues() { + return Stream.of( + Arguments.of(Arrays.asList("invalid")), + Arguments.of(Arrays.asList("option1", "invalid")), + Arguments.of(Arrays.asList("option1", "option5")), + Arguments.of(Arrays.asList("", "option1")), + Arguments.of(Arrays.asList("option1", null)) + ); + } + + @Test + @DisplayName("空选项列表应该校验失败") + void shouldFailWhenOptionsEmpty() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "multiselect"); + appearance.put("options", Collections.emptyList()); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "emptyOptionsParam"); + rawParam.put("type", "Array[String]"); + rawParam.put("appearance", appearance); + + AppInputParam emptyOptionsParam = AppInputParam.from(rawParam); + Map dataMap = new HashMap<>(); + dataMap.put("emptyOptionsParam", Arrays.asList("option1")); + + // When & Then + assertThrows(AippParamException.class, () -> emptyOptionsParam.validate(dataMap)); + } + + @Test + @DisplayName("null值应该校验失败") + void shouldFailWhenValueIsNull() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("arrayParam", null); + + // When & Then + assertThrows(AippParamException.class, () -> this.arrayParam.validate(dataMap)); + } + + @Test + @DisplayName("非集合类型应该校验失败") + void shouldFailWhenValueIsNotCollection() { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("arrayParam", "option1"); + + // When & Then + assertThrows(AippParamException.class, () -> this.arrayParam.validate(dataMap)); + } + } + + @Nested + @DisplayName("布尔校验器测试") + class BooleanValidatorTest { + private AppInputParam booleanParam; + + @BeforeEach + void setUp() { + Map appearance = new HashMap<>(); + appearance.put("displayType", "switch"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "booleanParam"); + rawParam.put("type", "boolean"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + this.booleanParam = AppInputParam.from(rawParam); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + @DisplayName("布尔值应该通过校验") + void shouldPassBooleanValues(boolean value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("booleanParam", value); + + // When & Then + assertDoesNotThrow(() -> this.booleanParam.validate(dataMap)); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false", "1", "0"}) + @DisplayName("字符串类型应该校验失败") + void shouldFailStringValues(String value) { + // Given + Map dataMap = new HashMap<>(); + dataMap.put("booleanParam", value); + + // When & Then + assertThrows(AippParamException.class, () -> this.booleanParam.validate(dataMap)); + } + } + + @Nested + @DisplayName("默认校验器测试") + class DefaultValidatorTest { + @Test + @DisplayName("string 类型应该只接受字符串") + void shouldAcceptOnlyStringForStringType() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "stringParam"); + rawParam.put("type", "String"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + // When & Then - 有效字符串 + Map validData = new HashMap<>(); + validData.put("stringParam", "valid string"); + assertDoesNotThrow(() -> param.validate(validData)); + + // When & Then - 无效类型 + Map invalidData = new HashMap<>(); + invalidData.put("stringParam", 123); + assertThrows(AippParamException.class, () -> param.validate(invalidData)); + } + + @Test + @DisplayName("Integer 类型应该只接受整数") + void shouldAcceptOnlyIntegerForNumberType() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "numberParam"); + rawParam.put("type", "Integer"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + // When & Then - 有效整数 + Map validData = new HashMap<>(); + validData.put("numberParam", 123); + assertDoesNotThrow(() -> param.validate(validData)); + + // When & Then - 无效类型(字符串) + Map invalidData1 = new HashMap<>(); + invalidData1.put("numberParam", "123"); + assertThrows(AippParamException.class, () -> param.validate(invalidData1)); + + // When & Then - 无效类型(浮点数) + Map invalidData2 = new HashMap<>(); + invalidData2.put("numberParam", 123.45); + assertThrows(AippParamException.class, () -> param.validate(invalidData2)); + } + + @Test + @DisplayName("number 类型应该只接受数字类型") + void shouldAcceptOnlyFloatForDecimalType() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "decimalParam"); + rawParam.put("type", "Number"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + // When & Then - 有效浮点数 + Map validData = new HashMap<>(); + validData.put("decimalParam", 123.45); + assertDoesNotThrow(() -> param.validate(validData)); + + // When & Then - 有效整数 + Map invalidData = new HashMap<>(); + invalidData.put("decimalParam", 123); + assertDoesNotThrow(() -> param.validate(invalidData)); + + // When & Then - 超出默认范围的数字 + Map bigNumber = new HashMap<>(); + bigNumber.put("numberParam", 1000000000.0); // + assertThrows(AippParamException.class, () -> param.validate(bigNumber)); + } + + @Test + @DisplayName("Boolean 类型应该只接受布尔值") + void shouldAcceptOnlyBooleanForBooleanType() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "boolParam"); + rawParam.put("type", "Boolean"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + // When & Then - 有效布尔值 + Map validData = new HashMap<>(); + validData.put("boolParam", true); + assertDoesNotThrow(() -> param.validate(validData)); + + // When & Then - 无效类型 + Map invalidData = new HashMap<>(); + invalidData.put("boolParam", "true"); + assertThrows(AippParamException.class, () -> param.validate(invalidData)); + } + + @Test + @DisplayName("未知类型不进行校验") + void shouldFallbackToTypeInferenceForUnknownTypes() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "unknownParam"); + rawParam.put("type", "unknown"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + // When & Then + Map stringData = new HashMap<>(); + stringData.put("unknownParam", "test"); + assertDoesNotThrow(() -> param.validate(stringData)); + } + + @Test + @DisplayName("空字符串应该被拒绝") + void shouldRejectEmptyString() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "stringParam"); + rawParam.put("type", "String"); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + Map dataMap = new HashMap<>(); + dataMap.put("stringParam", ""); + + // When & Then + assertThrows(AippParamException.class, () -> param.validate(dataMap)); + } + + @Test + @DisplayName("null 类型应该导致校验失败") + void shouldFailForNullType() { + // Given + Map rawParam = new HashMap<>(); + rawParam.put("name", "nullTypeParam"); + rawParam.put("type", null); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + Map dataMap = new HashMap<>(); + dataMap.put("nullTypeParam", "anything"); + + // When & Then + assertThrows(AippParamException.class, () -> param.validate(dataMap)); + } + } + } + + @Nested + @DisplayName("边界条件测试") + class EdgeCaseTest { + @Test + @DisplayName("无效的数字格式应该被正确处理") + void shouldHandleInvalidNumberFormat() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "number"); + appearance.put("minValue", "invalid"); + appearance.put("maxValue", "also_invalid"); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "numberParam"); + rawParam.put("type", "number"); + rawParam.put("appearance", appearance); + + // When & Then + assertDoesNotThrow(() -> AppInputParam.from(rawParam)); + } + + @Test + @DisplayName("空的选项列表应该被正确处理") + void shouldHandleEmptyOptionsList() { + // Given + Map appearance = new HashMap<>(); + appearance.put("displayType", "dropdown"); + appearance.put("options", Collections.emptyList()); + + Map rawParam = new HashMap<>(); + rawParam.put("name", "dropdownParam"); + rawParam.put("type", "string"); + rawParam.put("appearance", appearance); + rawParam.put("isRequired", true); + + AppInputParam param = AppInputParam.from(rawParam); + + Map dataMap = new HashMap<>(); + dataMap.put("dropdownParam", "anything"); + + // When & Then + assertThrows(AippParamException.class, () -> param.validate(dataMap)); + } + } +} \ No newline at end of file diff --git a/frontend/src/locale/en_US.json b/frontend/src/locale/en_US.json index 1cc752011..7238d7548 100644 --- a/frontend/src/locale/en_US.json +++ b/frontend/src/locale/en_US.json @@ -1682,5 +1682,23 @@ "guestTips": "Guest mode has no access authentication, there are potential threats such as data leakage, please use with caution.", "wrongAddr": "Access address error", "wrongAddrMsg": "Please visit the correct address" - } + }, + "inputType": "Input", + "numberType": "Number", + "dropdownType": "Dropdown", + "switchType": "Switch", + "multiselectType": "Multi Select", + "maxLength": "Max Length", + "option": "option", + "plsEnterOption": "Please enter an option", + "optionShouldBeUnique": "Options should be unique", + "addOption": "Add option", + "maxValue": "Max Value", + "minValue": "Min Value", + "addParamField": "Add Param", + "editParamField": "Edit Param", + "varName": "Param Name", + "plsEnterVarName": "Please enter param name", + "attributeVarNameMustBeUnique": "Enter a unique param name.", + "noDefaultValue": "No default value" } diff --git a/frontend/src/locale/zh_CN.json b/frontend/src/locale/zh_CN.json index 4172e252b..c9dc41aa0 100644 --- a/frontend/src/locale/zh_CN.json +++ b/frontend/src/locale/zh_CN.json @@ -694,8 +694,8 @@ "access": "访问", "preview": "预览", "displayName": "展示名称", - "paramDisplayNameCannotBeEmpty": "展示名称不能为空", - "attributeDisplayNameMustBeUnique": "字段展示名称不能重复", + "paramDisplayNameCannotBeEmpty": "请输入展示名称", + "attributeDisplayNameMustBeUnique": "展示名称已存在,请重新命名", "pleaseInsertDisplayName": "请输入展示名称", "startNodeNamePopover": "用于表示入参字段的key,

\n

格式要求:只能输入英文、数字及下划线,且不能以数字开头", "startNodeDisplayNamePopover": "用于表示入参字段在对话配置窗口中的展示名称", @@ -1682,5 +1682,23 @@ "guestMode": "游客模式", "guestTips": "游客模式无访问鉴权,存在数据泄露等潜在威胁,请务必谨慎使用。", "wrongAddr": "访问地址错误", - "wrongAddrMsg": "请访问正确的地址" + "wrongAddrMsg": "请访问正确的地址", + "inputType": "文本", + "numberType": "数字", + "dropdownType": "单选框", + "switchType": "开关", + "multiselectType": "多选框", + "maxLength": "最大长度", + "option": "选项", + "plsEnterOption": "请输入选项", + "optionShouldBeUnique": "选项不能重复", + "addOption": "添加选项", + "maxValue": "最大值", + "minValue": "最小值", + "addParamField": "添加变量", + "editParamField": "编辑变量", + "varName": "变量名称", + "plsEnterVarName": "请输入变量名称", + "attributeVarNameMustBeUnique": "名称已存在,请重新命名", + "noDefaultValue": "无默认值" } diff --git a/frontend/src/pages/addFlow/components/elsa-stage.tsx b/frontend/src/pages/addFlow/components/elsa-stage.tsx index 56fc7ff7b..7932626df 100644 --- a/frontend/src/pages/addFlow/components/elsa-stage.tsx +++ b/frontend/src/pages/addFlow/components/elsa-stage.tsx @@ -29,6 +29,7 @@ import { configMap } from '../config'; import { useTranslation } from 'react-i18next'; import i18n from '../../../locale/i18n'; import { cloneDeep } from 'lodash'; +import InputParamModal from '@/pages/configForm/configUi/components/add-input-param'; /** * elsa编排组件 @@ -56,6 +57,8 @@ const Stage = (props) => { } = props; const [showModal, setShowModal] = useState(false); const [showTools, setShowTools] = useState(false); + const [showInputModal, setShowInputModal] = useState(false); + const [inputModalMode, setInputModalMode] = useState('add'); const [showDrawer, setShowDrawer] = useState(false); const [spinning, setSpinning] = useState(false); const [isDrawper, setIsDrawper] = useState(false); @@ -79,6 +82,8 @@ const Stage = (props) => { const modelCallback = useRef(); const knowledgeCallback = useRef(); const pluginCallback = useRef(); + const inputParamCallback = useRef(); + const inputParamRef = useRef({}) const formCallback = useRef(); const currentApp = useRef(); const currentChange = useRef(false); @@ -251,6 +256,18 @@ const Stage = (props) => { setShowTools(true); setModalTypes('parallel'); }); + agent.onAddInputParam(({onAdd, existParam}) => { + inputParamCallback.current = onAdd; + setInputModalMode('add') + setShowInputModal(true); + inputParamRef.current.existParam = existParam; + }) + agent.onEditInputParam(({onEdit, id, selectedParam}) => { + inputParamCallback.current = onEdit; + setInputModalMode('edit') + setShowInputModal(true); + inputParamRef.current.selectedParam = selectedParam; + }) if (readOnly) { agent.readOnly(); } @@ -298,6 +315,10 @@ const Stage = (props) => { searchCallback.current(value); }; + const addParam = (value) => { + inputParamCallback.current(value); + } + // 插件工具流选中 const toolsConfirm = (item) => { let obj = {}; @@ -444,6 +465,14 @@ const Stage = (props) => { taskName={taskName} selectModal={selectModal} /> + {/*input param添加弹窗*/} + {/* 添加知识库弹窗 */} { name={debugType.name} label={debugType.displayName} key={index} - isRequired={debugType.isRequired} /> + isRequired={debugType.isRequired} + appearance={debugType.appearance} + /> ) })} { diff --git a/frontend/src/pages/addFlow/components/render-form-item.tsx b/frontend/src/pages/addFlow/components/render-form-item.tsx index 0e4b476df..305995523 100644 --- a/frontend/src/pages/addFlow/components/render-form-item.tsx +++ b/frontend/src/pages/addFlow/components/render-form-item.tsx @@ -5,9 +5,8 @@ *--------------------------------------------------------------------------------------------*/ import React, { useEffect } from 'react'; -import { Input, Form, InputNumber, Switch } from 'antd'; +import { Input, Form, InputNumber, Switch, Select } from 'antd'; import { useTranslation } from 'react-i18next'; -const QUESTION_NAME = 'Question'; /** * 调试表单渲染 @@ -22,148 +21,163 @@ const QUESTION_NAME = 'Question'; */ const RenderFormItem = (props) => { const { t } = useTranslation(); - const { type, name, label, isRequired, form } = props; + const { type, name, label, isRequired, form, appearance } = props; - useEffect(() => { - formInit(); - }, []); + const displayType = appearance?.displayType; + const actualType = displayType || type; - useEffect(() => { - formInit(); - }, [type]) - - // 初始化数据 - const formInit = () => { - if (type === 'Boolean') { - form.setFieldValue(name, false); - return; - } - form.setFieldValue(name, null); - } - - const customLabel = ( + const getCustomLabel = (typeName) => ( {label} - {type} ); - + const validateNumber = (value, isInteger) => { - if (value === undefined || value === null || value === '') { - return Promise.resolve(); - } - if (isNaN(value)) { - return Promise.reject(new Error(t('plsEnterValidNumber'))); - } - if (isInteger && (value < -999999999 || value > 999999999)) { - return Promise.reject(new Error(t('integerValidateTip'))); - } - if (!isInteger && (value < -999999999.99 || value > 999999999.99)) { - return Promise.reject(new Error(t('numberValidateTip'))); - } + if (value === undefined || value === null || value === '') return Promise.resolve(); + if (isNaN(value)) return Promise.reject(new Error(t('plsEnterValidNumber'))); + const min = appearance?.minValue ?? (isInteger ? -999999999 : -999999999.99); + const max = appearance?.maxValue ?? (isInteger ? 999999999 : 999999999.99); + if (value < min || value > max) return Promise.reject(new Error(t(isInteger ? 'integerValidateTip' : 'numberValidateTip'))); return Promise.resolve(); }; const handleNumberItemBlur = (value, isInteger) => { - if (isNaN(value)) { - form.setFieldValue(name, null); - form.validateFields([name]); - } else if (value === '') { + if (isNaN(value) || value === '') { form.setFieldValue(name, null); } else { - let inputNumber = isInteger ? value : Number(value).toFixed(2); - form.setFieldValue(name, Number(inputNumber)); + form.setFieldValue(name, isInteger ? Number(value) : Number(Number(value).toFixed(2))); } - } + }; const handleStringItemBlur = (value) => { - if (value !== '') { - return; - } - form.setFieldValue(name, null); - } + if (value === '') form.setFieldValue(name, null); + }; - return <> - {type === 'String' && - - handleStringItemBlur(e.target.value)} - /> - - } - {type === 'Integer' && - validateNumber(value, true) } - ]} - className='debug-form-item' - > - handleNumberItemBlur(e.target.value, true)} - parser={(value) => value.replace(/[^\d-]/g, '')} // 仅允许数字和负号 - formatter={(value) => { - if (value === '0') { - return value; - } - return `${Math.floor(value) || ''}`; - } - } // 强制显示整数 - /> - + const isRequiredRule = { required: isRequired !== false, message: t('plsEnter') }; + + // 初始化表单值 + useEffect(() => formInit(), []); + useEffect(() => formInit(), [actualType]); + const formInit = () => { + if (actualType === 'Boolean' || actualType === 'switch') { + form.setFieldValue(name, false); } - {type === 'Number' && - validateNumber(value, false) } - ]} - className='debug-form-item' - > - handleNumberItemBlur(e.target.value, false)} - /> - + if (actualType === 'multiselect') { + form.setFieldValue(name, []); + } else { + form.setFieldValue(name, null); } - {type === 'Boolean' && - - - + }; + + // 如果没有 appearance,回退到原来的 type 渲染逻辑 + if (!appearance) { + switch (type) { + case 'String': + return ( + + handleStringItemBlur(e.target.value)} /> + + ); + case 'Integer': + case 'Number': + const isInteger = type === 'Integer'; + return ( + validateNumber(value, isInteger) }]} + className='debug-form-item' + > + handleNumberItemBlur(e.target.value, isInteger)} + /> + + ); + case 'Boolean': + return ( + + + + ); + default: + return null; } - -} + } + // 有 appearance,走新的 actualType 渲染逻辑 + return ( + <> + {actualType === 'input' && ( + + handleStringItemBlur(e.target.value)} /> + + )} + {actualType === 'number' && ( + validateNumber(value, false) }]} className='debug-form-item'> + handleNumberItemBlur(e.target.value, false)} + /> + + )} + {actualType === 'Integer' && ( + validateNumber(value, true) }]} className='debug-form-item'> + value.replace(/[^\d-]/g, '')} + formatter={(value) => (value === '0' ? value : `${Math.floor(value) || ''}`)} + onBlur={(e) => handleNumberItemBlur(e.target.value, true)} + /> + + )} + {(actualType === 'Boolean' || actualType === 'switch') && ( + + + + )} + {actualType === 'dropdown' && ( + + + + )} + {actualType === 'multiselect' && ( + + + + )} + + ); +}; export default RenderFormItem; diff --git a/frontend/src/pages/chatPreview/components/send-editor/components/conversation-configuration.tsx b/frontend/src/pages/chatPreview/components/send-editor/components/conversation-configuration.tsx index d4917624f..e55818074 100644 --- a/frontend/src/pages/chatPreview/components/send-editor/components/conversation-configuration.tsx +++ b/frontend/src/pages/chatPreview/components/send-editor/components/conversation-configuration.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState, useContext, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Form, InputNumber, Input, Switch, Popover, Empty } from 'antd'; +import { Form, InputNumber, Input, Switch, Popover, Empty, Select } from 'antd'; import { isInputEmpty, getConfiguration } from '@/shared/utils/common'; import { AippContext } from '@/pages/aippIndex/context'; import { useAppSelector } from '@/store/hook'; @@ -80,44 +80,111 @@ const ConversationConfiguration = ({ appInfo, updateUserContext, chatRunning, is }, []); // 根据类型获取输入类型 - const getConfigurationItem = ({ name, type }) => { + const getConfigurationItem = ({ name, type, appearance = {}, value}) => { + const displayType = appearance?.displayType; + + // 优先使用 displayType 分支渲染 + switch (displayType) { + case 'input': + return ( + + ); + case 'number': + return ( + handleNumberChange(e, false, name)} + /> + ); + case 'switch': + return ; + case 'dropdown': + return ( + + ); + case 'multiselect': + return ( + + ); + } + + // 如果没有 appearance.displayType,退回旧逻辑 switch (type) { case 'String': - return + return ( + + ); case 'Number': - return handleNumberChange(e, false, name)} - /> + return ( + handleNumberChange(e, false, name)} + /> + ); case 'Integer': - return value.replace(/[^\d-]/g, '')} // 仅允许数字和负号 - onChange={(e) => handleNumberChange(e, true, name)} - formatter={(value) => { - if (value === '0') { - return '0'; - } - return `${Math.floor(value) || ''}`; - } - } // 强制显示整数 - /> + return ( + value.replace(/[^\d-]/g, '')} + formatter={(value) => (value === '0' ? '0' : `${Math.floor(value) || ''}`)} + min={-999999999} + max={999999999} + onChange={(e) => handleNumberChange(e, true, name)} + /> + ); case 'Boolean': - return + return ; + default: + return ; } }; @@ -170,6 +237,29 @@ const ConversationConfiguration = ({ appInfo, updateUserContext, chatRunning, is } }, [showElsa]); + useEffect(() => { + if (configurationList?.length) { + configurationList.forEach(item => { + const preItem = preConfigurationList.current.find(it => it.name === item.name); + const isChangeType = preItem?.type !== item.type; + + // 若已有值,保持不变,否则设置为默认值 + const existingValue = form.getFieldValue(item.name); + const defaultValue = item.value ?? null; + + if (item.type === 'Boolean') { + form.setFieldValue(item.name, isChangeType ? false : (existingValue ?? defaultValue ?? false)); + } else { + const isEmpty = isInputEmpty(existingValue); // 你已有此函数 + form.setFieldValue(item.name, isChangeType ? null : (isEmpty ? defaultValue : existingValue)); + } + }); + updateData(); + } + preConfigurationList.current = configurationList; + }, [configurationList]); + + const content = ( <>

@@ -186,7 +276,7 @@ const ConversationConfiguration = ({ appInfo, updateUserContext, chatRunning, is name={config.name} label={config.displayName || ' '} className={config.isRequired ? 'is-required' : ''}> - {getConfigurationItem(config)} + {getConfigurationItem({...config, value: form.getFieldValue(config.value)})} ) } @@ -214,4 +304,4 @@ const ConversationConfiguration = ({ appInfo, updateUserContext, chatRunning, is }; -export default ConversationConfiguration; \ No newline at end of file +export default ConversationConfiguration; diff --git a/frontend/src/pages/configForm/configUi/components/add-input-param.tsx b/frontend/src/pages/configForm/configUi/components/add-input-param.tsx new file mode 100644 index 000000000..a456826d9 --- /dev/null +++ b/frontend/src/pages/configForm/configUi/components/add-input-param.tsx @@ -0,0 +1,520 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useEffect, useState } from 'react'; +import { + Modal, + Form, + Input, + Select, + InputNumber, + Checkbox, + Button, + Row, + Col, + Space, + Switch, +} from 'antd'; +import { + UnorderedListOutlined, + FileTextOutlined, + NumberOutlined, + CheckSquareOutlined, + SwitcherOutlined, + DeleteOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { v4 as uuidv4 } from 'uuid'; +import { useTranslation } from 'react-i18next'; + +const { Option } = Select; + +const InputParamModal = (props) => { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const { showModal, setShowModal, onSubmit, mode, modalRef } = props; + const [fieldType, setFieldType] = useState('input'); + + // 字段类型配置 + const fieldTypes = [ + { value: 'input', label: t('inputType'), icon: , type: 'String' }, + { value: 'number', label: t('numberType'), icon: , type: 'Number' }, + { value: 'dropdown', label: t('dropdownType'), icon: , type: 'String' }, + { value: 'switch', label: t('switchType'), icon: , type: 'Boolean' }, + { value: 'multiselect', label: t('multiselectType'), icon: , type: 'Array[String]' }, + ]; + + const requiredValue = Form.useWatch('required', form); + const visibleValue = Form.useWatch('visible', form); + const maxLength = Form.useWatch(['appearance', 'maxLength'], form); + const dropdownOptions = Form.useWatch(['appearance', 'options'], form); + const maxValue = Form.useWatch(['appearance', 'maxValue'], form); + const minValue = Form.useWatch(['appearance', 'minValue'], form); + + useEffect(() => { + if (mode === 'edit' && modalRef.current) { + const defaultValue = modalRef.current.selectedParam; + form.setFieldsValue({ + fieldType: defaultValue.appearance?.displayType, + tableName: defaultValue.name, + displayName: defaultValue.displayName, + defaultValue: defaultValue.value, + required: defaultValue.isRequired, + visible: defaultValue.isVisible, + appearance: defaultValue.appearance, + }); + setFieldType(defaultValue.appearance?.displayType || 'input'); + } else if (mode === 'add') { + // 添加模式:重置表单到初始值 + form.resetFields(); + setFieldType('input'); + } + }, [showModal, mode]); + + useEffect(() => { + if (requiredValue && !visibleValue) { + form.setFieldValue('visible', true); + } + }, [requiredValue, form, visibleValue]); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + const fieldTypeItem = fieldTypes.find((ft) => ft.value === values.fieldType); + const type = fieldTypeItem?.type || 'String'; + const finalType = type.includes('Array') ? 'Array' : type; + + const transformed = { + id: mode === 'add' ? `input_${uuidv4()}` : modalRef.current.selectedParam.id, + name: values.tableName, + type: finalType, + value: getDefaultValueByType(values.defaultValue, finalType), + displayName: values.displayName, + isRequired: values.required, + isVisible: values.visible, + disableModifiable: false, + appearance: { + displayType: fieldTypeItem?.value || 'input', + ...values.appearance, + }, + }; + + function getDefaultValueByType(value, type) { + if (value !== undefined && value !== null && value !== '') { + return value; + } + if (type === 'Array') { + return []; + } else { + return ''; + } + } + + setShowModal(false); + form.resetFields(); + onSubmit(transformed); + } catch (error) { + console.log('验证失败:', error); + } + }; + + const handleCancel = () => { + setShowModal(false); + form.resetFields(); + }; + + const handleFieldTypeChange = (value) => { + setFieldType(value); + form.setFieldsValue({ + appearance: {}, // 清空所有 appearance 配置 + defaultValue: undefined, + }); + }; + + const getTypeByValue = (value) => { + const field = fieldTypes.find((item) => item.value === value); + return field?.type; // 如果找不到会返回 undefined + }; + + // 根据字段类型渲染不同的配置项 + const renderFieldSpecificOptions = () => { + switch (fieldType) { + case 'input': + return ( + <> + + + + + + + + + ); + + case 'dropdown': + return ( + <> + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + ({ + validator(_, value) { + if (!value) return Promise.resolve(); + const options = getFieldValue(['appearance', 'options']) || []; + const duplicates = options.filter( + (option, idx) => option === value && idx !== name + ); + if (duplicates.length > 0) { + return Promise.reject(new Error(t('optionShouldBeUnique'))); + } + return Promise.resolve(); + }, + }), + ]} + > + { + if (fields.length > 1) { + const options = form.getFieldValue(['appearance', 'options']); + const defaultValue = form.getFieldValue('defaultValue'); + // 如果删除的是默认值,清空默认值 + if (defaultValue === options[index]) { + form.setFieldValue('defaultValue', ''); + } + remove(name); + } + }} + style={{ + cursor: fields.length > 1 ? 'pointer' : 'not-allowed', + color: fields.length > 1 ? '#2d2f32' : '#8c8c8c', + fontSize: '14px', + }} + /> + } + /> + + ))} + + + + + )} + + + + + + + ); + + case 'number': + return ( + <> + + + + + + + + + + + + + + + + + ); + + case 'switch': + return ( + <> + + + + + ); + + case 'multiselect': + return ( + <> + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + ({ + validator(_, value) { + if (!value) return Promise.resolve(); + const options = getFieldValue(['appearance', 'options']) || []; + const duplicates = options.filter( + (option, idx) => option === value && idx !== name + ); + if (duplicates.length > 0) { + return Promise.reject(new Error(t('optionShouldBeUnique'))); + } + return Promise.resolve(); + }, + }), + ]} + > + { + if (fields.length > 1) { + const options = form.getFieldValue(['appearance', 'options']); + const defaultValue = form.getFieldValue('defaultValue'); + // 如果删除的是默认值,清空默认值 + if (defaultValue === options[index]) { + form.setFieldValue('defaultValue', ''); + } + remove(name); + } + }} + style={{ + cursor: fields.length > 1 ? 'pointer' : 'not-allowed', + color: fields.length > 1 ? '#2d2f32' : '#8c8c8c', + fontSize: '14px', + }} + /> + } + /> + + ))} + + + + + )} + + + + + + + ); + + default: + return null; + } + }; + + return ( + + {t('cancel')} + , + , + ]} + destroyOnClose + > +
+ {/* 变量类型 */} + + + + + {/* 变量名称 */} + ({ + validator(_, value) { + if (!value) { + return Promise.resolve(); // 为空的校验由 required 处理 + } + + const existList = modalRef?.current?.existParam || []; + + const isEditMode = mode === 'edit'; + const currentId = modalRef?.current?.selectedParam?.id; + const currentName = modalRef?.current?.selectedParam?.name; + + const nameExists = existList.some((param) => { + if (isEditMode && param.id === currentId) { + return false; // 编辑时跳过自身 + } + return param.name === value; + }); + + if (nameExists) { + return Promise.reject(new Error(t('attributeVarNameMustBeUnique'))); + } + return Promise.resolve(); + }, + }), + ]} + > + + + + {/* 显示名称 */} + ({ + validator(_, value) { + if (!value) { + return Promise.resolve(); // 为空的校验由 required 处理 + } + + const existList = modalRef?.current?.existParam || []; + + const isEditMode = mode === 'edit'; + const currentId = modalRef?.current?.selectedParam?.id; + const currentName = modalRef?.current?.selectedParam?.displayName; + + const nameExists = existList.some((param) => { + if (isEditMode && param.id === currentId) { + return false; // 编辑时跳过自身 + } + return param.displayName === value; + }); + + if (nameExists) { + return Promise.reject(new Error(t('attributeDisplayNameMustBeUnique'))); + } + return Promise.resolve(); + }, + }), + ]} + > + + + + {/* 字段类型特定配置 */} + {renderFieldSpecificOptions()} + + {/* 参数展示、必填配置 */} + + + + {t('requiredOrNot')} + + + + + {t('displayInDialogConfiguration')} + + + +
+
+ ); +}; + +export default InputParamModal;