feat: 完善用户确认节点选项与输出流转

- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
This commit is contained in:
2026-09-04 14:55:55 +08:00
parent 65c85180c2
commit 0968e3bfa5
51 changed files with 2465 additions and 1591 deletions

View File

@@ -18,10 +18,12 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginService;
@@ -29,6 +31,7 @@ import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
@@ -91,11 +94,15 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowResumeService workflowResumeService;
@PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save")
@@ -215,6 +222,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
@@ -229,10 +237,33 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams);
assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
/**
* 校验插件试运行实例由当前登录用户发起。
*
* @param executeId 执行实例 ID
*/
private void assertPluginTestExecutionOwnership(String executeId) {
if (StrUtil.isBlank(executeId)) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
}
LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
if (currentAccount == null
|| currentAccount.getId() == null
|| record.getCreatedBy() == null
|| !currentAccount.getId().toString().equals(record.getCreatedBy())) {
throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
}
}
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;

View File

@@ -1,6 +1,5 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
@@ -14,6 +13,7 @@ import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
@@ -64,6 +64,8 @@ public class WorkflowChatController {
@Resource
private ChainExecutor chainExecutor;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
@@ -171,19 +173,8 @@ public class WorkflowChatController {
@JsonBody("confirmParams")
Map<String, Object> confirmParams
) {
WorkflowExecResult record = assertExecutionOwnership(executeId);
if (record.getStatus() != null
&& (record.getStatus() == ChainStatus.SUCCEEDED.getValue()
|| record.getStatus() == ChainStatus.FAILED.getValue()
|| record.getStatus() == ChainStatus.CANCELLED.getValue())) {
throw new BusinessException("当前工作流执行已结束");
}
chainExecutor.resumeAsync(
executeId,
confirmParams == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(confirmParams)
);
assertExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService;
@@ -94,6 +95,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@@ -324,12 +327,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
)
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
@@ -46,6 +47,7 @@ public class WorkflowPublicChatService {
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
private final WorkflowResumeService workflowResumeService;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
@@ -57,6 +59,7 @@ public class WorkflowPublicChatService {
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowResumeService workflowResumeService,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
@@ -67,6 +70,7 @@ public class WorkflowPublicChatService {
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
this.workflowResumeService = workflowResumeService;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
@@ -185,17 +189,8 @@ public class WorkflowPublicChatService {
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
if (isTerminal(record.getStatus())) {
throw new BusinessException("当前工作流执行已结束");
}
chainExecutor.resumeAsync(
executeId,
confirmParams == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(confirmParams)
);
assertExecutionOwnership(context, executeId);
workflowResumeService.resume(executeId, confirmParams);
}
/**
@@ -253,13 +248,6 @@ public class WorkflowPublicChatService {
return record;
}
private boolean isTerminal(Integer status) {
return status != null
&& (status == ChainStatus.SUCCEEDED.getValue()
|| status == ChainStatus.FAILED.getValue()
|| status == ChainStatus.CANCELLED.getValue());
}
private Map<String, Object> buildExecutionDetail(
WorkflowExecResult record,
List<WorkflowExecStep> steps,

View File

@@ -7,21 +7,27 @@ import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -68,6 +74,61 @@ public class PluginItemControllerTest {
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
}
/**
* 验证当前用户不能恢复其他用户发起的插件试运行实例。
*/
@Test
public void testResumeShouldRejectAnotherUsersExecution() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.TWO);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> controller.pluginToolTestResume("execution-1", Map.of())
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 403);
}
verifyNoInteractions(resumeService);
}
/**
* 验证当前用户可以恢复自己发起的插件试运行实例。
*/
@Test
public void testResumeShouldAllowExecutionOwner() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
controller.pluginToolTestResume("execution-1", Map.of("choice", "A"));
}
verify(resumeService).resume("execution-1", Map.of("choice", "A"));
}
/**
* 创建插件工具。
*

View File

@@ -12,6 +12,7 @@ import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
@@ -162,6 +163,8 @@ public class WorkflowPublicChatServiceTest {
WorkflowChatEventStream eventStream = mock(
WorkflowChatEventStream.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
WorkflowResumeService workflowResumeService =
mock(WorkflowResumeService.class);
WorkflowExecResultService execResultService = mock(
WorkflowExecResultService.class);
WorkflowExecStepService execStepService = mock(
@@ -196,6 +199,7 @@ public class WorkflowPublicChatServiceTest {
accessGuard,
eventStream,
chainExecutor,
workflowResumeService,
execResultService,
execStepService
);

View File

@@ -18,6 +18,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
@@ -70,6 +71,8 @@ public class PublicWorkflowController {
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@@ -250,14 +253,7 @@ public class PublicWorkflowController {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
if (!chainExecutor.resumeAsyncIfSuspended(
executeId,
confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -394,6 +394,7 @@ public final class WorkflowRunAsyncErrorProfile
*/
private boolean isStableBusinessCode(int code) {
return (code >= 40011 && code <= 40017)
|| code == 40031
|| (code >= 40101 && code <= 40103)
|| (code >= 40301 && code <= 40302)
|| (code >= 40401 && code <= 40402)
@@ -412,7 +413,8 @@ public final class WorkflowRunAsyncErrorProfile
* @return 对外 HTTP 状态
*/
private int normalizeHttpStatus(int code, int fallback) {
if (code >= 40011 && code <= 40017) {
if ((code >= 40011 && code <= 40017)
|| code == 40031) {
return 400;
}
if (code >= 40101 && code <= 40103) {

View File

@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PublishStatus;
@@ -44,6 +45,7 @@ public class PublicWorkflowControllerBehaviorTest {
private PublicWorkflowController controller;
private ChainExecutor chainExecutor;
private WorkflowResumeService workflowResumeService;
private TinyFlowService tinyFlowService;
private HttpServletRequest request;
@@ -54,6 +56,7 @@ public class PublicWorkflowControllerBehaviorTest {
public void setUp() {
controller = new PublicWorkflowController();
chainExecutor = Mockito.mock(ChainExecutor.class);
workflowResumeService = Mockito.mock(WorkflowResumeService.class);
tinyFlowService = Mockito.mock(TinyFlowService.class);
WorkflowApiPermissionService permissionService =
Mockito.mock(WorkflowApiPermissionService.class);
@@ -79,6 +82,10 @@ public class PublicWorkflowControllerBehaviorTest {
controller,
"chainExecutor",
chainExecutor);
ReflectionTestUtils.setField(
controller,
"workflowResumeService",
workflowResumeService);
ReflectionTestUtils.setField(
controller,
"tinyFlowService",
@@ -108,10 +115,12 @@ public class PublicWorkflowControllerBehaviorTest {
public void resumeShouldRejectNonSuspendedExecution() {
when(request.getRequestURI()).thenReturn(
"/public-api/workflow/resume");
when(chainExecutor.resumeAsyncIfSuspended(
EXECUTE_ID,
Map.of("approved", true)))
.thenReturn(false);
Mockito.doThrow(new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
.when(workflowResumeService)
.resume(EXECUTE_ID, Map.of("approved", true));
try {
controller.resume(
@@ -124,7 +133,7 @@ public class PublicWorkflowControllerBehaviorTest {
Assert.assertEquals(40901, exception.getErrorCode());
}
verify(chainExecutor).resumeAsyncIfSuspended(
verify(workflowResumeService).resume(
EXECUTE_ID,
Map.of("approved", true));
}

View File

@@ -167,6 +167,28 @@ public class WorkflowRunAsyncErrorProfileTest {
resolution.modelAndView.getModel().get("message"));
}
/**
* 验证确认节点恢复校验保留专用错误码,不回退为运行参数错误。
*/
@Test
public void shouldKeepResumeValidationCode() {
Resolution resolution = resolve(
"/public-api/workflow/resume",
MediaType.APPLICATION_JSON_VALUE,
new BusinessException(
400,
40031,
"确认参数[模板类型]包含未配置选项"));
Assert.assertEquals(400, resolution.response.getStatus());
Assert.assertEquals(
40031,
resolution.modelAndView.getModel().get("errorCode"));
Assert.assertEquals(
"确认参数[模板类型]包含未配置选项",
resolution.modelAndView.getModel().get("message"));
}
/**
* 验证 API Key 无效和两层权限错误保持可区分。
*/

View File

@@ -13,6 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.annotation.UsePermission;
@@ -54,6 +55,8 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
public UcWorkflowController(WorkflowService service) {
@@ -163,12 +166,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
)
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -142,9 +142,12 @@ public class TinyFlowService {
node.setResult(resolved);
}
// 只有当参数不为空时才覆盖
if (chainState.getSuspendForParameters() != null) {
if (nodeState != null
&& nodeState.getStatus() == NodeStatus.SUSPEND
&& chainState.getSuspendForParameters() != null) {
node.setSuspendForParameters(chainState.getSuspendForParameters());
} else {
node.setSuspendForParameters(null);
}
}

View File

@@ -3,6 +3,8 @@ package tech.easyflow.ai.easyagentsflow.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.DataType;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -53,6 +55,9 @@ public class WorkflowCheckService {
private static final String TYPE_END = "endNode";
private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_CONDITION = "conditionNode";
private static final String TYPE_CONFIRM = "confirmNode";
private static final Set<String> CONFIRM_ARRAY_LEFT_OPERATORS = Set.of(
"contains", "notContains", "isEmpty", "isNotEmpty");
private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_MAKE_FILE = "make-file";
@@ -184,6 +189,8 @@ public class WorkflowCheckService {
}
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
checkConditionConfigurations(nodes, issues, issueKeys);
checkConfirmConfigurations(nodes, issues, issueKeys);
checkConfirmOutputReferences(nodes, issues, issueKeys);
List<EdgeView> edges = new ArrayList<>();
Set<String> edgeIds = new HashSet<>();
@@ -514,6 +521,288 @@ public class WorkflowCheckService {
}
}
/**
* 校验用户确认节点配置及其对外输出定义。
*/
private void checkConfirmConfigurations(
List<NodeView> nodes,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
for (NodeView node : nodes) {
if (!TYPE_CONFIRM.equals(node.type)) {
continue;
}
if (node.data == null) {
addIssue(
issues,
issueKeys,
"CONFIRM_CONFIGURATION_INVALID",
"用户确认节点配置不能为空",
node.id,
null,
node.name);
continue;
}
ConfirmNode configuration;
try {
validateConfirmConfigurationTypes(node.data);
checkConfirmOutputDefinitions(
node,
Boolean.TRUE.equals(node.data.get("multiple")),
issues,
issueKeys);
configuration = node.data.toJavaObject(ConfirmNode.class);
configuration.validateConfiguration();
} catch (Exception exception) {
addIssue(
issues,
issueKeys,
"CONFIRM_CONFIGURATION_INVALID",
"用户确认节点配置无效: " + shortError(exception),
node.id,
null,
node.name);
continue;
}
}
}
private void validateConfirmConfigurationTypes(JSONObject data) {
for (String key : data.keySet()) {
if (!ConfirmNode.SUPPORTED_CONFIGURATION_KEYS.contains(key)) {
throw new IllegalArgumentException(
"用户确认节点包含无效配置字段: " + key);
}
}
if (!(data.get("message") instanceof String)) {
throw new IllegalArgumentException("用户确认节点提示内容必须为字符串");
}
if (!(data.get("multiple") instanceof Boolean)) {
throw new IllegalArgumentException("用户确认节点选择方式必须为布尔值");
}
Object optionsValue = data.get("options");
if (optionsValue == null) {
return;
}
if (!(optionsValue instanceof JSONArray options)) {
throw new IllegalArgumentException("用户确认节点选项必须为数组");
}
for (Object option : options) {
if (!(option instanceof String)) {
throw new IllegalArgumentException("用户确认节点选项内容必须为字符串");
}
}
}
private void checkConfirmOutputDefinitions(
NodeView node,
boolean multiple,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
Object outputDefsValue = node.data.get("outputDefs");
if (!(outputDefsValue instanceof JSONArray outputDefs)
|| outputDefs.size() != 1) {
addConfirmOutputIssue(node, issues, issueKeys);
return;
}
Object outputValue = outputDefs.get(0);
if (!(outputValue instanceof JSONObject output)) {
addConfirmOutputIssue(node, issues, issueKeys);
return;
}
String expectedType = multiple
? DataType.Array_String.toString()
: DataType.String.toString();
if (trimToNull(output.getString("name")) == null
|| !expectedType.equals(
trimToNull(output.getString("dataType")))) {
addConfirmOutputIssue(node, issues, issueKeys);
}
}
private void addConfirmOutputIssue(
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
addIssue(
issues,
issueKeys,
"CONFIRM_OUTPUT_SCHEMA_INVALID",
"用户确认节点必须配置唯一非空输出参数,且类型与选择方式一致",
node.id,
null,
node.name);
}
/**
* 校验下游节点保存的确认输出引用仍然存在且类型一致。
*/
private void checkConfirmOutputReferences(
List<NodeView> nodes,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
ConfirmOutputIndex confirmOutputs = new ConfirmOutputIndex();
for (NodeView node : nodes) {
if (!TYPE_CONFIRM.equals(node.type) || node.data == null) {
continue;
}
try {
ConfirmNode configuration = node.data.toJavaObject(ConfirmNode.class);
configuration.validateConfiguration();
confirmOutputs.put(
node.id,
configuration.resolveOutputName(),
configuration.isMultiple()
? DataType.Array_String.toString()
: DataType.String.toString());
} catch (Exception ignored) {
// 配置错误已由 checkConfirmConfigurations 给出精确问题。
}
}
if (confirmOutputs.isEmpty()) {
return;
}
for (NodeView node : nodes) {
if (node.data != null) {
checkConfirmOutputReferences(
node.data,
node,
confirmOutputs,
issues,
issueKeys);
}
}
}
private void checkConfirmOutputReferences(
Object value,
NodeView consumer,
ConfirmOutputIndex confirmOutputs,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (value instanceof JSONObject object) {
if ("ref".equals(trimToNull(object.getString("refType")))) {
checkConfirmOutputReference(
object.getString("ref"),
object.getString("dataType"),
true,
consumer,
confirmOutputs,
issues,
issueKeys);
}
String leftType = checkConfirmOutputReference(
object.getString("leftRef"),
null,
false,
consumer,
confirmOutputs,
issues,
issueKeys);
String operator = trimToNull(object.getString("operator"));
if (DataType.Array_String.toString().equals(leftType)
&& !CONFIRM_ARRAY_LEFT_OPERATORS.contains(operator)) {
addConfirmConditionTypeIssue(
object.getString("leftRef"),
operator,
consumer,
issues,
issueKeys);
}
if ("ref".equals(trimToNull(object.getString("rightType")))) {
String rightType = checkConfirmOutputReference(
object.getString("rightRef"),
null,
false,
consumer,
confirmOutputs,
issues,
issueKeys);
if (DataType.Array_String.toString().equals(rightType)) {
addConfirmConditionTypeIssue(
object.getString("rightRef"),
operator,
consumer,
issues,
issueKeys);
}
}
for (Object child : object.values()) {
checkConfirmOutputReferences(
child,
consumer,
confirmOutputs,
issues,
issueKeys);
}
return;
}
if (value instanceof JSONArray array) {
for (Object child : array) {
checkConfirmOutputReferences(
child,
consumer,
confirmOutputs,
issues,
issueKeys);
}
}
}
private String checkConfirmOutputReference(
String rawReference,
String rawActualType,
boolean requireActualType,
NodeView consumer,
ConfirmOutputIndex confirmOutputs,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
String reference = trimToNull(rawReference);
if (reference == null) {
return null;
}
String expectedType = confirmOutputs.get(reference);
if (expectedType == null
&& !confirmOutputs.referencesConfirmNode(reference)) {
return null;
}
String actualType = trimToNull(rawActualType);
boolean invalidType = actualType == null
? requireActualType
: !Objects.equals(expectedType, actualType);
if (expectedType == null || invalidType) {
addIssue(
issues,
issueKeys,
"CONFIRM_OUTPUT_REFERENCE_INVALID",
"用户确认输出引用不存在或类型已变化: "
+ reference,
consumer.id,
null,
consumer.name);
}
return expectedType;
}
private void addConfirmConditionTypeIssue(
String reference,
String operator,
NodeView consumer,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
addIssue(
issues,
issueKeys,
"CONFIRM_OUTPUT_REFERENCE_INVALID",
"用户确认多选输出不支持当前条件操作符: "
+ trimToNull(reference) + " (" + operator + ")",
consumer.id,
null,
consumer.name);
}
/**
* 将 JSON 条件规则转换为运行时规则对象。
*
@@ -1674,6 +1963,35 @@ public class WorkflowCheckService {
return result;
}
private static class ConfirmOutputIndex {
private final Map<String, String> outputTypes = new HashMap<>();
private final Set<String> nodeIds = new HashSet<>();
private void put(String nodeId, String outputName, String dataType) {
nodeIds.add(nodeId);
outputTypes.put(nodeId + "." + outputName, dataType);
}
private String get(String reference) {
return outputTypes.get(reference);
}
private boolean isEmpty() {
return outputTypes.isEmpty();
}
private boolean referencesConfirmNode(String reference) {
int separator = reference.indexOf('.');
while (separator > 0) {
if (nodeIds.contains(reference.substring(0, separator))) {
return true;
}
separator = reference.indexOf('.', separator + 1);
}
return false;
}
}
private void throwIfFailed(WorkflowCheckResult result) {
if (result == null) {
return;

View File

@@ -0,0 +1,49 @@
package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.flow.core.chain.ChainResumeException;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.springframework.stereotype.Service;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 统一校验并恢复暂停中的工作流。
*/
@Service
public class WorkflowResumeService {
@Resource
private ChainExecutor chainExecutor;
/**
* 恢复暂停实例,并将引擎校验失败转换为稳定的接口错误。
*
* @param executeId 工作流实例 ID
* @param variables 用户提交的确认参数
*/
public void resume(String executeId, Map<String, Object> variables) {
Map<String, Object> submitted = variables == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(variables);
final boolean resumed;
try {
resumed = chainExecutor.resumeAsyncIfSuspended(
executeId, submitted);
} catch (ChainResumeException exception) {
throw new BusinessException(
400,
40031,
exception.getMessage(),
exception);
}
if (!resumed) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
}
}

View File

@@ -6,6 +6,7 @@ import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ExceptionSummary;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.NodeStatus;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
@@ -148,6 +149,56 @@ public class TinyFlowServiceTest {
.load(EXECUTE_ID, NODE_ID);
}
/**
* 验证链级挂起参数只返回给真正暂停的节点。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldAttachSuspendParametersOnlyToSuspendedNode()
throws Exception {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
ChainStateRepository chainStateRepository =
mock(ChainStateRepository.class);
NodeStateRepository nodeStateRepository =
mock(NodeStateRepository.class);
ChainState chainState = new ChainState();
chainState.setStatus(ChainStatus.SUSPEND);
Parameter parameter = new Parameter();
parameter.setName("selection__confirm-1");
chainState.setSuspendForParameters(List.of(parameter));
NodeState suspendedState = new NodeState();
suspendedState.setStatus(NodeStatus.SUSPEND);
NodeState readyState = new NodeState();
readyState.setStatus(NodeStatus.READY);
when(chainExecutor.getChainStateRepository())
.thenReturn(chainStateRepository);
when(chainExecutor.getNodeStateRepository())
.thenReturn(nodeStateRepository);
when(chainStateRepository.load(EXECUTE_ID))
.thenReturn(chainState);
when(nodeStateRepository.load(EXECUTE_ID, "confirm-1"))
.thenReturn(suspendedState);
when(nodeStateRepository.load(EXECUTE_ID, "confirm-2"))
.thenReturn(readyState);
TinyFlowService service = service(chainExecutor);
NodeInfo first = new NodeInfo();
first.setNodeId("confirm-1");
NodeInfo second = new NodeInfo();
second.setNodeId("confirm-2");
second.setSuspendForParameters(List.of(parameter));
ChainInfo result = service.getChainStatus(
EXECUTE_ID, List.of(first, second));
Assert.assertEquals(
List.of(parameter),
result.getNodes().get("confirm-1")
.getSuspendForParameters());
Assert.assertNull(result.getNodes().get("confirm-2")
.getSuspendForParameters());
}
/**
* 验证 JavaScript 执行错误使用面向试运行用户的定位信息。
*

View File

@@ -525,6 +525,374 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "LOOP_PARENT_CYCLE");
}
/**
* 验证用户确认选项与输出定义一致时可以保存。
*/
@Test
public void testSaveShouldPassValidConfirmContract() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
String content = workflowJson(
array(node(
"confirm-1",
"confirmNode",
null,
confirmData(false, "String"))),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
/**
* 验证用户确认节点拒绝重复的选项内容。
*/
@Test
public void testSaveShouldBlockDuplicateConfirmOptionContents()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.getJSONArray("options").add("第一议题");
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
}
/**
* 验证用户确认节点拒绝非字符串选项。
*/
@Test
public void testSaveShouldBlockNonStringConfirmOption()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.getJSONArray("options").add(1);
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
}
/**
* 验证选择方式不能依赖 JSON 隐式类型转换。
*/
@Test
public void testSaveShouldBlockNonBooleanConfirmMode()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.put("multiple", "false");
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
}
/**
* 验证最终确认契约拒绝继续保存已废弃配置。
*/
@Test
public void testSaveShouldBlockRetiredConfirmData()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.put("async", true);
confirm.put("fields", new JSONArray());
confirm.put("confirms", new JSONArray());
confirm.put("schemaVersion", 1);
confirm.put("unknownLegacySetting", true);
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
}
/**
* 验证多选模式必须声明数组输出。
*/
@Test
public void testSaveShouldBlockMismatchedConfirmOutputType()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
String content = workflowJson(
array(node(
"confirm-1",
"confirmNode",
null,
confirmData(true, "String"))),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
}
/**
* 验证确认节点可以使用用户配置的输出参数名称及下游引用。
*/
@Test
public void testSaveShouldPassConfiguredConfirmOutputName()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(
false, "String", "templateChoice");
JSONObject consumer = data("下游节点");
consumer.put("parameters", array(refParameter(
"input", "confirm-1.templateChoice", "String")));
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null, confirm),
node("code-1", "codeNode", null, consumer)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
/**
* 验证空输出定义不能利用空类型绕过字段契约校验。
*/
@Test
public void testSaveShouldBlockEmptyConfirmOutputDefinition()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.put("outputDefs", array(new JSONObject()));
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
}
/**
* 验证字符串编码的输出定义不能通过 JSON 隐式类型转换。
*/
@Test
public void testSaveShouldBlockStringEncodedConfirmOutputDefinition()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
confirm.put(
"outputDefs",
"[{\"name\":\"selection\",\"dataType\":\"String\"}]");
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
}
/**
* 验证输出定义数组中的字符串元素不能被隐式解析为对象。
*/
@Test
public void testSaveShouldBlockStringElementConfirmOutputDefinition()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject confirm = confirmData(false, "String");
JSONArray outputDefs = new JSONArray();
outputDefs.add("{\"name\":\"selection\",\"dataType\":\"String\"}");
confirm.put("outputDefs", outputDefs);
String content = workflowJson(
array(node("confirm-1", "confirmNode", null, confirm)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
}
/**
* 验证不存在的确认输出会阻止下游引用。
*/
@Test
public void testSaveShouldBlockMissingConfirmOutputReference()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject consumer = data("下游节点");
consumer.put("parameters", array(refParameter(
"input", "confirm-1.removed", "String")));
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null,
confirmData(false, "String")),
node("code-1", "codeNode", null, consumer)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
/**
* 验证手工导入的带点节点 ID 也能识别失效确认输出引用。
*/
@Test
public void testSaveShouldBlockMissingConfirmOutputForDottedNodeId()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject consumer = data("下游节点");
consumer.put("parameters", array(refParameter(
"input", "confirm.group-1.removed", "String")));
String content = workflowJson(
array(
node("confirm.group-1", "confirmNode", null,
confirmData(false, "String")),
node("code-1", "codeNode", null, consumer)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
/**
* 验证切换选择方式后,保存会拦截仍声明旧类型的下游引用。
*/
@Test
public void testSaveShouldBlockMismatchedConfirmOutputReferenceType()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject consumer = data("下游节点");
consumer.put("parameters", array(refParameter(
"input", "confirm-1.selection", "Array<String>")));
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null,
confirmData(false, "String")),
node("code-1", "codeNode", null, consumer)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
/**
* 验证普通参数引用不能通过省略类型绕过确认输出契约。
*/
@Test
public void testSaveShouldBlockConfirmOutputReferenceWithoutType()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject reference = refParameter(
"input", "confirm-1.selection", "String");
reference.remove("dataType");
JSONObject consumer = data("下游节点");
consumer.put("parameters", array(reference));
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null,
confirmData(false, "String")),
node("code-1", "codeNode", null, consumer)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
/**
* 验证条件节点直接保存的左右引用也受确认输出契约约束。
*/
@Test
public void testSaveShouldBlockMissingConfirmOutputConditionReference()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject condition = conditionData(
"equals", "ref", "confirm-1.removed");
JSONObject rule = condition.getJSONArray("branches")
.getJSONObject(0)
.getJSONArray("rules")
.getJSONObject(0);
rule.put("leftRef", "confirm-1.selection");
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null,
confirmData(false, "String")),
node("condition-1", "conditionNode", null, condition)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
/**
* 验证确认字段切成多选后,不兼容的条件操作符会在保存阶段被阻止。
*/
@Test
public void testSaveShouldBlockIncompatibleConfirmArrayConditionReference()
throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject condition = conditionData(
"regexMatch", "fixed", "^AGENDA$");
condition.getJSONArray("branches")
.getJSONObject(0)
.getJSONArray("rules")
.getJSONObject(0)
.put("leftRef", "confirm-1.selection");
String content = workflowJson(
array(
node("confirm-1", "confirmNode", null,
confirmData(true, "Array<String>")),
node("condition-1", "conditionNode", null, condition)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
}
@Test
public void testSaveShouldPassForValidDraft() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
@@ -1011,6 +1379,14 @@ public class WorkflowCheckServiceTest {
return array;
}
private static JSONArray stringArray(String... values) {
JSONArray array = new JSONArray();
for (String value : values) {
array.add(value);
}
return array;
}
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
JSONObject node = new JSONObject();
node.put("id", id);
@@ -1080,6 +1456,25 @@ public class WorkflowCheckServiceTest {
return data;
}
private static JSONObject confirmData(
boolean multiple, String outputType) {
return confirmData(multiple, outputType, "selection");
}
private static JSONObject confirmData(
boolean multiple, String outputType, String outputName) {
JSONObject output = new JSONObject();
output.put("name", outputName);
output.put("dataType", outputType);
JSONObject data = data("用户确认");
data.put("message", "请选择会议纪要模板");
data.put("multiple", multiple);
data.put("options", stringArray("第一议题", "审议类", "听取类"));
data.put("outputDefs", array(output));
return data;
}
/**
* 创建显式循环节点数据。
*

View File

@@ -0,0 +1,62 @@
package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.flow.core.chain.ChainResumeException;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Map;
import java.lang.reflect.Field;
/**
* {@link WorkflowResumeService} 恢复错误契约测试。
*/
public class WorkflowResumeServiceTest {
private WorkflowResumeService service;
private ChainExecutor chainExecutor;
@Before
public void setUp() throws Exception {
service = new WorkflowResumeService();
chainExecutor = Mockito.mock(ChainExecutor.class);
Field field = WorkflowResumeService.class.getDeclaredField(
"chainExecutor");
field.setAccessible(true);
field.set(service, chainExecutor);
}
@Test
public void shouldMapInvalidConfirmationToBadRequest() {
Mockito.when(chainExecutor.resumeAsyncIfSuspended(
"execute-1", Map.of("choice", "UNKNOWN")))
.thenThrow(new ChainResumeException("包含未配置选项"));
try {
service.resume("execute-1", Map.of("choice", "UNKNOWN"));
Assert.fail("invalid option must be rejected");
} catch (BusinessException exception) {
Assert.assertEquals(400, exception.getHttpStatus());
Assert.assertEquals(40031, exception.getErrorCode());
Assert.assertEquals("包含未配置选项", exception.getMessage());
}
}
@Test
public void shouldMapNonSuspendedInstanceToConflict() {
Mockito.when(chainExecutor.resumeAsyncIfSuspended(
"execute-1", Map.of("choice", "AGENDA")))
.thenReturn(false);
try {
service.resume("execute-1", Map.of("choice", "AGENDA"));
Assert.fail("non-suspended instance must be rejected");
} catch (BusinessException exception) {
Assert.assertEquals(409, exception.getHttpStatus());
Assert.assertEquals(40901, exception.getErrorCode());
}
}
}

View File

@@ -291,23 +291,29 @@ async function executePolling(nextExecuteId: string, generation: number) {
}
}
function resumeChain(payload: any) {
async function resumeChain(
payload: any,
onSettled: (accepted: boolean) => void,
) {
if (!executeId.value) {
onSettled(false);
return;
}
api
.post('/api/v1/pluginItem/testResume', {
try {
const res = await api.post('/api/v1/pluginItem/testResume', {
executeId: executeId.value,
confirmParams: payload?.confirmParams || {},
})
.then((res) => {
if (res.errorCode === 0) {
startPolling(executeId.value);
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
});
if (res.errorCode === 0) {
startPolling(executeId.value);
onSettled(true);
return;
}
onSettled(false);
} catch (error) {
onSettled(false);
runResultResponse.value = buildErrorResult(error);
}
}
function showWorkflowSteps() {

View File

@@ -50,7 +50,7 @@ import nodeNames from './customNode/nodeNames';
import {
createInitialWorkflowData,
isWorkflowDataEmpty,
normalizeWorkflowStartNodes,
normalizeWorkflowNodes,
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import '@tinyflow-ai/vue/dist/index.css';
@@ -444,7 +444,7 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
}
saveLoading.value = true;
try {
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
const savedContentSignature = createWorkflowContentSignature(content);
const res = await api.post('/api/v1/workflow/update', {
id: workflowId.value,
@@ -475,11 +475,11 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
: {};
const serverContent = isWorkflowDataEmpty(parsedContent)
? createInitialWorkflowData()
: normalizeWorkflowStartNodes(parsedContent);
: normalizeWorkflowNodes(parsedContent);
serverContentSignature = createWorkflowContentSignature(serverContent);
const draft = readWorkflowDraft(workflowId, serverContent);
tinyFlowData.value = draft
? normalizeWorkflowStartNodes(draft.content as Record<string, any>)
? normalizeWorkflowNodes(draft.content as Record<string, any>)
: serverContent;
lastObservedWorkflowContent = tinyFlowData.value;
}
@@ -492,7 +492,7 @@ function persistPendingWorkflowDraft() {
draftWriteTimer = undefined;
return;
}
const content = normalizeWorkflowStartNodes(pendingDraftContent);
const content = normalizeWorkflowNodes(pendingDraftContent);
lastObservedWorkflowContent = content;
pendingDraftContent = null;
draftWriteTimer = undefined;
@@ -531,7 +531,7 @@ function flushWorkflowDraft() {
function captureCurrentWorkflowDraft() {
const content = tinyflowRef.value?.getData();
if (content) {
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
lastObservedWorkflowContent = normalizeWorkflowNodes(content);
pendingDraftContent = lastObservedWorkflowContent;
}
flushWorkflowDraft();
@@ -554,7 +554,7 @@ function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
clearPendingWorkflowDraft();
return;
}
const normalizedContent = normalizeWorkflowStartNodes(currentContent);
const normalizedContent = normalizeWorkflowNodes(currentContent);
lastObservedWorkflowContent = normalizedContent;
if (
createWorkflowContentSignature(normalizedContent) === savedContentSignature
@@ -594,7 +594,7 @@ async function runCheck(
stage: WorkflowCheckStage,
silentPass: boolean = false,
) {
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
if (!content) {
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
return false;
@@ -756,8 +756,13 @@ async function runIndependently(node: any) {
singleNode.value = node;
singleRunVisible.value = true;
}
function resumeChain(data: any) {
workflowForm.value?.resume(data);
async function resumeChain(data: any, onSettled: (accepted: boolean) => void) {
try {
const accepted = await workflowForm.value?.resume(data);
onSettled(accepted === true);
} catch {
onSettled(false);
}
}
function handleChoose(nodeName: string, value: any) {
if (nodeName === nodeNames.workflowNode) {

View File

@@ -682,7 +682,7 @@ function buildResumeRequestExample() {
{
executeId: '执行ID',
confirmParams: {
confirm: true,
'selection__confirm-node-1': '审议类',
},
},
null,
@@ -936,6 +936,10 @@ const apiDocMarkdown = computed(() => {
lines.push(buildResumeRequestExample());
lines.push('```');
lines.push(``);
lines.push(
'`confirmParams` 的键请使用暂停节点 `suspendForParameters[].name` 返回的运行参数名;确认节点固定为 `selection__<节点 ID>`。单选传一个 `options[].value` 字符串,多选传由这些值组成的字符串数组;确认节点的 `label` 与 `value` 都是配置的选项内容。',
);
lines.push(``);
lines.push(`### 响应`);
lines.push(``);
lines.push('```json');
@@ -971,6 +975,9 @@ const apiDocMarkdown = computed(() => {
lines.push(
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
);
lines.push(
`| 400 | 40031 | 确认节点恢复参数缺失、类型错误或包含未配置选项 |`,
);
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);

View File

@@ -1,211 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
// 请确保路径正确,或者将图片放在 public 目录下引用
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值
modelValue: {
type: [String, Number, Object],
default: null,
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue === item;
};
// 切换选中状态
const changeValue = (item: any) => {
if (props.modelValue === item) {
// 如果点击已选中的,则取消选中
emit('update:modelValue', null);
emit('change', null); // 触发 Element Plus 表单验证
} else {
emit('update:modelValue', item);
emit('change', item); // 触发 Element Plus 表单验证
}
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 100%"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="display: block; width: 80px; height: 80px; border-radius: 8px"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 100%"
@click="changeValue(item)"
>
<audio
controls
:src="item"
style="width: 100%; height: 44px; margin-top: 8px"
></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 100%"
@click="changeValue(item)"
>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
"
>
<div style="display: flex; align-items: center; width: 92%">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号,如果没有安装 Element Plus可以用普通的 span + css -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.custom-radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.custom-radio-option {
position: relative;
box-sizing: border-box; /* 确保 padding 不会撑大宽度 */
padding: 8px;
cursor: pointer;
background-color: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
padding: 8px;
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
}
.custom-radio-option.selected::after {
position: absolute;
right: 0;
bottom: 0;
box-sizing: border-box;
width: 16px;
height: 16px;
content: '';
background-color: var(--el-color-primary);
border-radius: 6px 2px;
}
.custom-radio-option.selected::before {
position: absolute;
right: 3px;
bottom: 7px;
z-index: 1;
width: 9px;
height: 4px;
content: '';
border-bottom: 1px solid white;
border-left: 1px solid white;
transform: rotate(-45deg);
}
.download-icon-btn {
display: flex; /* 为了对齐图标 */
align-items: center;
margin-right: 10px;
font-size: 18px;
cursor: pointer;
}
</style>

View File

@@ -1,216 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值,多选版本这里是数组
modelValue: {
type: Array as () => any[],
default: () => [],
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue && props.modelValue.includes(item);
};
// 切换选中状态 (多选逻辑)
const changeValue = (item: any) => {
// 复制一份当前数组,避免直接修改 prop
const currentValues = props.modelValue ? [...props.modelValue] : [];
const index = currentValues.indexOf(item);
if (index === -1) {
// 如果不存在,则添加
currentValues.push(item);
} else {
// 如果已存在,则移除
currentValues.splice(index, 1);
}
// 更新 v-model
emit('update:modelValue', currentValues);
// 触发 Element Plus 表单验证
emit('change', currentValues);
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 100%"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="display: block; width: 80px; height: 80px; border-radius: 8px"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 300px"
@click="changeValue(item)"
>
<audio controls :src="item" style="width: 100%; height: 40px"></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="flex-shrink: 0; width: 100%"
@click="changeValue(item)"
>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
"
>
<div style="display: flex; align-items: center; width: 92%">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号 -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* 这里复用之前的 CSS样式完全一致 */
.custom-radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.custom-radio-option {
position: relative;
box-sizing: border-box;
padding: 8px;
cursor: pointer;
background-color: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
padding: 8px;
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
}
.custom-radio-option.selected::after {
position: absolute;
right: 0;
bottom: 0;
box-sizing: border-box;
width: 16px;
height: 16px;
content: '';
background-color: var(--el-color-primary);
border-radius: 6px 2px;
}
.custom-radio-option.selected::before {
position: absolute;
right: 3px;
bottom: 7px;
z-index: 1;
width: 9px;
height: 5px;
content: '';
border-bottom: 1px solid white;
border-left: 1px solid white;
transform: rotate(-45deg);
}
.download-icon-btn {
display: flex;
align-items: center;
margin-right: 10px;
font-size: 18px;
cursor: pointer;
}
</style>

View File

@@ -161,7 +161,7 @@ const extraFormRef = ref<FormInstance>();
const waitingConfirmation = ref<Record<string, any>>();
const confirmValues = ref<Record<string, any>>({});
const confirmFormRef = ref<FormInstance>();
const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
const confirmSubmittingAction = ref<'' | 'continue'>('');
const confirmError = ref('');
const detailVisible = ref(false);
const detailLoading = ref(false);
@@ -252,21 +252,9 @@ const hiddenParameterSummaryCount = computed(() =>
),
);
const confirmParameters = computed(() => {
const parameters = Array.isArray(waitingConfirmation.value?.parameters)
return Array.isArray(waitingConfirmation.value?.parameters)
? waitingConfirmation.value?.parameters
: [];
return parameters.filter(
(parameter: any) => parameter.formType !== 'confirm',
);
});
const confirmKey = computed(() => {
const parameters = Array.isArray(waitingConfirmation.value?.parameters)
? waitingConfirmation.value?.parameters
: [];
return (
parameters.find((parameter: any) => parameter.formType === 'confirm')
?.name || ''
);
});
const composerDisabled = computed(
() =>
@@ -1012,33 +1000,25 @@ function finalizeLiveExecutionSteps(
function initializeConfirmValues(parameters: unknown) {
const values: Record<string, any> = {};
for (const parameter of Array.isArray(parameters) ? parameters : []) {
if (parameter.formType === 'confirm') {
continue;
}
values[parameter.name] = parameter.defaultValue ?? '';
values[parameter.name] =
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
}
confirmValues.value = values;
}
async function resumeExecution(confirmed: boolean) {
if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
async function resumeExecution() {
if (!executeId.value || confirmSubmittingAction.value) {
return;
}
confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
confirmSubmittingAction.value = 'continue';
confirmError.value = '';
try {
if (
confirmed &&
!(await confirmFormRef.value?.validate().catch(() => false))
) {
if (!(await confirmFormRef.value?.validate().catch(() => false))) {
return;
}
await api.post(workflowChatEndpoint('resume'), {
executeId: executeId.value,
confirmParams: {
[confirmKey.value]: confirmed ? 'yes' : 'no',
...(confirmed ? confirmValues.value : {}),
},
confirmParams: { ...confirmValues.value },
});
waitingConfirmation.value = undefined;
executionState.value = 'running';
@@ -1523,20 +1503,13 @@ function executionTraceText(
{{ confirmError }}
</p>
<div class="workflow-chat__form-actions">
<ElButton
:loading="confirmSubmittingAction === 'reject'"
:disabled="Boolean(confirmSubmittingAction)"
@click="resumeExecution(false)"
>
拒绝
</ElButton>
<ElButton
type="primary"
:loading="confirmSubmittingAction === 'confirm'"
:loading="confirmSubmittingAction === 'continue'"
:disabled="Boolean(confirmSubmittingAction)"
@click="resumeExecution(true)"
@click="resumeExecution"
>
确认
继续
</ElButton>
</div>
</section>

View File

@@ -87,14 +87,22 @@ watch(
},
);
const executeId = ref('');
function resume(data: any) {
async function resume(data: any) {
data.executeId = executeId.value;
submitLoading.value = true;
api.post('/api/v1/workflow/resume', data).then((res) => {
let accepted = false;
try {
const res = await api.post('/api/v1/workflow/resume', data);
if (res.errorCode === 0) {
accepted = true;
startPolling(executeId.value);
}
});
return accepted;
} finally {
if (!accepted) {
submitLoading.value = false;
}
}
}
function submitV2() {
runForm.value?.validate((valid) => {
@@ -132,7 +140,7 @@ function startPolling(executeId: any) {
if (pollingActive) return;
pollingActive = true;
pollingGeneration += 1;
schedulePolling(executeId, pollingGeneration);
void executePolling(executeId, pollingGeneration);
}
function schedulePolling(executeId: any, generation: number) {
timer.value = setTimeout(() => {

View File

@@ -59,6 +59,12 @@ function isWideItem(item: any) {
return item.formType === 'textarea' || contentType === 'image';
}
function getCheckboxOptions(item: any) {
if (Array.isArray(item.options)) {
return item.options.map((option: any) => ({
label: option.label,
value: option.value,
}));
}
if (item.enums) {
return (
item.enums?.map((option: any) => ({

View File

@@ -14,14 +14,11 @@ import {
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElIcon,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
export interface WorkflowStepsProps {
workflowId: any;
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
expandAll?: boolean;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const emit = defineEmits<{
resume: [payload: any, onSettled: (accepted: boolean) => void];
}>();
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const activeNames = ref<string[]>([]);
@@ -90,7 +89,7 @@ watch(
}
const currentNodes = newVal.nodes || {};
chainErrMsg.value = newVal.status === 21 ? newVal.message : '';
if (![20, 21].includes(newVal.status)) {
if (Number(newVal.status) !== 5) {
confirmBtnLoading.value = false;
}
let autoExpandNodeId: string | undefined;
@@ -106,6 +105,10 @@ watch(
continue;
}
nodeStatusMap.value[nodeId] = currentNodeState;
if (Number(currentStatus) === 5 && Number(previousStatus) !== 5) {
initializeConfirmParams(currentNodeState?.suspendForParameters);
confirmBtnLoading.value = false;
}
if (
!userControlledExpansion.value &&
!props.expandAll &&
@@ -128,6 +131,7 @@ watch(
() => props.initSignal,
() => {
nodeStatusMap.value = {};
confirmParams.value = {};
confirmBtnLoading.value = false;
chainErrMsg.value = '';
userControlledExpansion.value = false;
@@ -164,8 +168,13 @@ const setFormRef = (el: any, key: string) => {
formRefs.value[key] = el as FormInstance;
}
};
function getSelectMode(ops: any) {
return ops.formType || 'radio';
function initializeConfirmParams(parameters: any) {
const values: Record<string, any> = {};
for (const parameter of Array.isArray(parameters) ? parameters : []) {
values[parameter.name] =
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
}
confirmParams.value = values;
}
function handleConfirm(node: any) {
const nodeKey = node.key;
@@ -176,17 +185,27 @@ function handleConfirm(node: any) {
console.warn(`Form instance for ${nodeKey} not found`);
return;
}
const confirmKey = node.suspendForParameters[0].name;
form.validate((valid) => {
if (valid) {
const value = {
confirmParams: {
[confirmKey]: 'yes',
...confirmParams.value,
},
confirmParams: { ...confirmParams.value },
};
confirmBtnLoading.value = true;
emit('resume', value);
emit('resume', value, (accepted) => {
confirmBtnLoading.value = false;
if (!accepted) {
return;
}
confirmParams.value = {};
const currentState = nodeStatusMap.value[nodeKey];
if (currentState) {
nodeStatusMap.value[nodeKey] = {
...currentState,
status: 1,
suspendForParameters: [],
};
}
});
}
});
}
@@ -234,7 +253,12 @@ function handleConfirm(node: any) {
</div>
</template>
<template v-if="isExpandedNode(node.key)">
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div
v-if="
node.original.type === 'confirmNode' && Number(node.status) === 5
"
class="p-2.5"
>
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
@@ -243,50 +267,24 @@ function handleConfirm(node: any) {
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
<WorkflowFormItem
:parameters="node.suspendForParameters || []"
:run-params="confirmParams"
@update:run-params="confirmParams = $event"
/>
<div
v-if="node.suspendForParameters?.length > 0"
class="flex justify-end"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<ElButton
:disabled="confirmBtnLoading"
:loading="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
继续
</ElButton>
</div>
</ElForm>
</div>
<div v-else>
@@ -318,26 +316,4 @@ function handleConfirm(node: any) {
transform: rotate(360deg);
}
}
.header-container {
display: flex;
align-items: center;
font-weight: bold;
word-break: break-all;
}
.blue-bar {
display: inline-block;
width: 2px;
height: 16px;
margin-right: 16px;
background-color: var(--el-color-primary);
border-radius: 1px;
}
.description-container {
margin-bottom: 16px;
color: #969799;
word-break: break-all;
}
</style>

View File

@@ -25,6 +25,30 @@ function createWorkflowNodes() {
];
}
function createConfirmNode() {
return [
{
key: 'confirm-a',
label: '用户确认',
original: {
data: { message: '请选择模板' },
type: 'confirmNode',
},
},
];
}
function createConfirmNodes() {
return ['confirm-a', 'confirm-b'].map((key) => ({
key,
label: `用户确认 ${key}`,
original: {
data: { message: `请选择 ${key}` },
type: 'confirmNode',
},
}));
}
function mountWorkflowSteps() {
return mount(WorkflowSteps, {
props: {
@@ -35,9 +59,8 @@ function mountWorkflowSteps() {
},
global: {
stubs: {
ConfirmItem: true,
ConfirmItemMulti: true,
ShowJson: true,
WorkflowFormItem: true,
},
},
});
@@ -159,4 +182,145 @@ describe('workflowSteps', () => {
'JavaScript 语法错误(第 2 行,第 3 列Unexpected token',
);
});
it('恢复请求未被受理时允许用户修正后重试', async () => {
const wrapper = mount(WorkflowSteps, {
props: {
initSignal: false,
nodeJson: createConfirmNode(),
pollingData: undefined,
workflowId: 'workflow-1',
},
global: {
stubs: {
ShowJson: true,
WorkflowFormItem: true,
},
},
});
await wrapper.setProps({
pollingData: {
nodes: {
'confirm-a': {
status: 5,
suspendForParameters: [
{
formType: 'radio',
name: 'selection__confirm-a',
required: true,
},
],
},
},
status: 5,
},
});
const button = wrapper.get('button.el-button');
await button.trigger('click');
expect(button.attributes('disabled')).toBeDefined();
const settle = wrapper.emitted('resume')?.[0]?.[1] as
| ((accepted: boolean) => void)
| undefined;
expect(settle).toBeTypeOf('function');
settle?.(false);
await wrapper.vm.$nextTick();
expect(button.attributes('disabled')).toBeUndefined();
});
it('恢复成功后同一节点再次暂停会清空旧值并允许再次确认', async () => {
const wrapper = mount(WorkflowSteps, {
props: {
initSignal: false,
nodeJson: createConfirmNode(),
pollingData: undefined,
workflowId: 'workflow-1',
},
global: {
stubs: {
ShowJson: true,
WorkflowFormItem: true,
},
},
});
const suspendedState = {
nodes: {
'confirm-a': {
status: 5,
suspendForParameters: [
{
formType: 'radio',
name: 'selection__confirm-a',
required: true,
},
],
},
},
status: 5,
};
await wrapper.setProps({ pollingData: suspendedState });
await wrapper.get('button.el-button').trigger('click');
const settle = wrapper.emitted('resume')?.[0]?.[1] as
| ((accepted: boolean) => void)
| undefined;
settle?.(true);
await wrapper.vm.$nextTick();
expect(wrapper.find('button.el-button').exists()).toBe(false);
await wrapper.setProps({
pollingData: {
...suspendedState,
nodes: {
'confirm-a': {
...suspendedState.nodes['confirm-a'],
},
},
},
});
const nextButton = wrapper.get('button.el-button');
expect(nextButton.attributes('disabled')).toBeUndefined();
});
it('多个确认节点只在当前暂停节点展示确认表单', async () => {
const wrapper = mount(WorkflowSteps, {
props: {
expandAll: true,
initSignal: false,
nodeJson: createConfirmNodes(),
pollingData: undefined,
workflowId: 'workflow-1',
},
global: {
stubs: {
ShowJson: true,
WorkflowFormItem: true,
},
},
});
const suspendForParameters = [
{
formType: 'radio',
name: 'selection__confirm-a',
required: true,
},
];
await wrapper.setProps({
pollingData: {
nodes: {
'confirm-a': { status: 5, suspendForParameters },
'confirm-b': { status: 0, suspendForParameters },
},
status: 5,
},
});
expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
expect(wrapper.findAll('button.el-button')).toHaveLength(1);
});
});

View File

@@ -46,4 +46,33 @@ describe('workflow public form item', () => {
expect(input.props('allowResourcePicker')).toBe(false);
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
});
it('renders confirmation option content as both label and output value', () => {
const wrapper = mount(WorkflowFormItem, {
props: {
parameters: [
{
contentType: 'text',
formLabel: '会议纪要模板',
formType: 'radio',
name: 'selection__confirm',
options: [
{ label: '第一议题', value: '第一议题' },
{ label: '审议类', value: '审议类' },
],
required: true,
},
],
runParams: {},
},
});
const group = wrapper.findComponent({ name: 'ElRadioGroup' });
const formItem = wrapper.findComponent({ name: 'ElFormItem' });
expect(formItem.props('label')).toBe('会议纪要模板');
expect(group.props('options')).toEqual([
{ label: '第一议题', value: '第一议题' },
{ label: '审议类', value: '审议类' },
]);
});
});

View File

@@ -64,10 +64,16 @@ function replaceDefaultParameterLabel(label: unknown, name: unknown) {
*/
export function resolveWorkflowParameterLabel(parameter: any) {
const name = String(parameter?.name || '').trim();
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
const hasStructuredOptions =
['checkbox', 'radio'].includes(String(parameter?.formType || '')) &&
Array.isArray(parameter?.options);
if (hasStructuredOptions && formLabel) {
return formLabel;
}
if (!isSystemParameter(parameter, name) && name) {
return configuredParameterName(name);
}
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
const displayName = replaceDefaultParameterLabel(
parameter?.displayName,
name,

View File

@@ -106,7 +106,12 @@ export class Tinyflow {
if (!flow) {
return null;
}
return flow.toObject();
this.store.flushPendingEdits();
return {
...flow.toObject(),
nodes: this.store.getNodes(),
edges: this.store.getEdges(),
};
}
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {

View File

@@ -15,6 +15,8 @@
variant = 'default',
showSelectedType = true,
selectedType,
disabled = false,
disabledReason,
...rest
}: {
items: SelectItem[],
@@ -26,46 +28,55 @@
variant?: 'default' | 'reference' | 'model'
showSelectedType?: boolean
selectedType?: string
disabled?: boolean
disabledReason?: string
[key: string]: any
} = $props();
let activeItemsState = $derived.by(() => {
const resultItems: SelectItem[] = [];
const fillResult = (items: SelectItem[]) => {
for (let item of items) {
if (value.length > 0) {
if (value.includes(item.value)) {
resultItems.push(item);
}
} else {
if (defaultValue.includes(item.value)) {
resultItems.push(item);
}
}
const flattenedItems: SelectItem[] = [];
const flatten = (sourceItems: SelectItem[]) => {
for (const item of sourceItems) {
flattenedItems.push(item);
if (item.children && item.children.length > 0) {
fillResult(item.children);
flatten(item.children);
}
}
};
fillResult(items);
return resultItems;
flatten(items);
const selectedValues = value.length > 0 ? value : defaultValue;
return selectedValues.flatMap((selectedValue) => {
const item = flattenedItems.find((candidate) => candidate.value === selectedValue);
return item ? [item] : [];
});
});
let triggerObject: any = $state();
let triggerButton: HTMLButtonElement | undefined = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
function closeMenu() {
triggerObject?.hide();
isOpen = false;
hoveredItem = null;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isOpen) {
return;
}
event.preventDefault();
event.stopPropagation();
closeMenu();
triggerButton?.focus();
}
function handlerOnSelect(item: SelectItem) {
if (item.selectable !== false) {
onSelect?.(item);
closeMenu();
if (!multiple) {
closeMenu();
}
} else {
if (variant === 'reference') {
hoveredItem = item;
@@ -89,8 +100,23 @@
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
{#each items as item}
<button class="tf-select-default-item" style="padding-left: {10 + depth * 14}px" onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}>
<button
type="button"
role="option"
class="tf-select-default-item {value.includes(item.value) ? 'active' : ''} {item.selectable === false ? 'disabled' : ''}"
style="padding-left: {10 + depth * 14}px"
aria-selected={value.includes(item.value)}
aria-disabled={item.selectable === false}
aria-label={item.disabledReason
? `${String(item.displayLabel || item.label)}${item.disabledReason}`
: undefined}
title={item.disabledReason || String(item.displayLabel || item.label)}
onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}
>
<span class="tf-select-default-item-label">{item.label}</span>
{#if value.includes(item.value)}
<svg class="tf-select-default-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
{/if}
</button>
{#if item.children && item.children.length > 0}
<div class="tf-select-default-children">
@@ -205,7 +231,7 @@
{/each}
{/snippet}
<div {...rest} class="tf-select {rest['class']}">
<div {...rest} class="tf-select {rest['class']}" onkeydown={handleKeydown}>
<FloatingTrigger
bind:this={triggerObject}
onShow={() => isOpen = true}
@@ -213,8 +239,26 @@
syncWidth={true}
syncWidthMode={variant === 'default' ? 'equal' : 'min'}
>
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
<button
bind:this={triggerButton}
type="button"
class="tf-select-input nopan nodrag {isOpen ? 'active' : ''} {disabled ? 'disabled' : ''}"
{...rest}
{disabled}
title={disabled ? disabledReason : undefined}
aria-haspopup={variant === 'default' ? 'listbox' : undefined}
aria-expanded={isOpen}
>
<div class="tf-select-input-value">
{#if multiple && activeItemsState.length > 0}
{@const item = activeItemsState[0]}
<div class="tf-parameter-label-input">
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
{#if activeItemsState.length > 1}
<span class="tf-select-count">+{activeItemsState.length - 1}</span>
{/if}
</div>
{:else}
{#each activeItemsState as item, index (`${index}_${item.value}`)}
{#if !multiple}
{#if index === 0}
@@ -238,35 +282,13 @@
{/if}
</div>
{/if}
{:else}
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{#if isMarkupIcon(item.icon)}
{@html item.icon}
{:else}
<img src={item.icon} alt="" />
{/if}
</span>
{/if}
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
{#if variant === 'reference' && showSelectedType && (selectedType ?? item.dataType)}
<span class="tf-parameter-type" title={selectedType ?? item.dataType}>{selectedType ?? item.dataType}</span>
{/if}
</div>
{#if index < activeItemsState.length - 1}
<span style="margin-right: 4px;">,</span>
{/if}
{/if}
{:else}
<div class="tf-select-input-placeholder">
{placeholder}
</div>
{/each}
{/if}
</div>
<div class="tf-select-input-arrow">
{#if variant === 'reference'}
@@ -284,7 +306,11 @@
{#snippet floating()}
{#if variant === 'default'}
<div class="tf-select-default-wrapper nopan nodrag nowheel">
<div
class="tf-select-default-wrapper nopan nodrag nowheel"
role="listbox"
aria-multiselectable={multiple}
>
{@render renderDefaultItems(items)}
</div>
{:else if variant === 'model'}
@@ -379,6 +405,46 @@
&:hover {
background: var(--tf-bg-hover);
}
&.active {
background: var(--tf-primary-soft-bg);
}
&.disabled {
color: var(--tf-text-muted);
cursor: help;
opacity: 0.72;
}
&.disabled:hover {
background: var(--tf-bg-surface);
}
}
.tf-select-default-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tf-select-default-check {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--tf-primary-color);
}
.tf-select-input.disabled {
cursor: not-allowed;
opacity: 0.62;
}
.tf-select-count {
flex-shrink: 0;
color: var(--tf-text-secondary);
font-size: 11px;
}
.tf-select-default-children {

View File

@@ -1,177 +0,0 @@
<script lang="ts">
import {Input, MenuButton, Textarea} from '../base';
import {Button, FloatingTrigger, Select} from '../base/index.js';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {useRefOptions} from '../utils/useRefOptions.svelte';
import type {Parameter} from '#types';
import {confirmFormTypes, contentTypes} from '#consts';
const { parameter, index, dataKeyName, useChildrenOnly }: {
parameter: Parameter,
index: number,
dataKeyName: string,
useChildrenOnly?: boolean,
} = $props();
let currentNodeId = getCurrentNodeId();
let node = useNodesData(currentNodeId);
let param = $derived.by(() => {
return {
...parameter,
...(node?.current?.data?.[dataKeyName] as Array<Parameter>)[index]
};
});
const { updateNodeData } = useSvelteFlow();
const updateParam = (key: string, value: any) => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters[index] = {
...parameters[index],
[key]: value
};
return {
[dataKeyName]: parameters
};
});
};
const updateParamByEvent = (name: string, event: Event) => {
const newValue = (event.target as any).value;
updateParam(name, newValue);
};
const updateRef = (item: any) => {
const newValue = item.value;
updateParam('ref', newValue);
};
const updateFormType = (item: any) => {
const newValue = item.value;
updateParam('formType', newValue);
};
const updateContentType = (item: any) => {
const newValue = item.value;
updateParam('contentType', newValue);
};
// const updateRequired = (item: any) => {
// const newValue = item.target.checked;
// updateParam('required', newValue);
// };
let triggerObject: any;
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters.splice(index, 1);
return {
[dataKeyName]: [...parameters]
};
});
triggerObject?.hide();
};
let selectItems = useRefOptions(() => useChildrenOnly === true);
</script>
<div class="input-item">
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
disabled={param.nameDisabled === true}
oninput={(event)=>updateParamByEvent('name', event)} />
</div>
<div class="input-item">
{#if param.refType === 'fixed'}
<Input value={param.value} placeholder="请输入参数值" oninput={(event)=>updateParamByEvent('value', event)} />
{:else if (param.refType !== 'input')}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
expandAll
onSelect={updateRef} />
{/if}
</div>
<div class="input-item">
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
数据内容:
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
value={param.contentType ? [param.contentType] : []}
onSelect={updateContentType}
/>
</div>
<div class="input-more-item">
确认方式:
<Select items={confirmFormTypes} style="width: 100%" defaultValue={["single"]}
value={param.formType ? [param.formType] : []}
onSelect={updateFormType}
/>
</div>
<div class="input-more-item">
数据标题:
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formLabel', event)
}} value={param.formLabel} />
</div>
<div class="input-more-item">
数据描述:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formDescription', event)
}} value={param.formDescription} />
</div>
<!-- <label class="input-item-inline">-->
<!-- <span>是否必填:</span>-->
<!-- <input type="checkbox" checked={false} onchange={updateRequired} />-->
<!-- </label>-->
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
</div>
</div>
{/snippet}
</FloatingTrigger>
</div>
<style lang="less">
.input-item {
display: flex;
align-items: center;
}
.input-more-setting {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: var(--tf-bg-surface);
border: 1px solid var(--tf-border-color-strong);
border-radius: 5px;
width: 200px;
box-shadow: var(--tf-shadow-medium);
.input-more-item {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--tf-text-secondary);
}
}
</style>

View File

@@ -1,66 +0,0 @@
<script lang="ts">
import {useNodesData} from '@xyflow/svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import ConfirmParameterItem from './ConfirmParameterItem.svelte';
const {
noneParameterText = '无确认数据',
dataKeyName = 'parameters',
useChildrenOnly,
}: {
noneParameterText?: string;
dataKeyName?: string;
useChildrenOnly?: boolean,
} = $props();
let currentNodeId = getCurrentNodeId();
let node = useNodesData(currentNodeId);
let parameters = $derived.by(() => {
return [...node?.current?.data?.[dataKeyName] as Array<any> || []];
});
</script>
<div class="input-container">
{#if (parameters.length !== 0)}
<div class="input-header">参数名称</div>
<div class="input-header">参数值</div>
<div class="input-header"></div>
{/if}
{#each parameters as param, index (param.id)}
<ConfirmParameterItem parameter={param} index={index} {dataKeyName} {useChildrenOnly}/>
{:else }
<div class="none-params">{noneParameterText}</div>
{/each}
</div>
<style lang="less">
.input-container {
display: grid;
grid-template-columns: 40% 50% 10%;
row-gap: 5px;
column-gap: 3px;
.none-params {
font-size: 12px;
background: var(--tf-bg-muted);
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
width: calc(100% - 5px);
grid-column: 1 / -1; /* 从第一列开始到最后一列结束 */
}
.input-header {
font-size: 12px;
color: var(--tf-text-secondary);
}
}
</style>

View File

@@ -33,6 +33,7 @@
allowCopy = true,
allowDelete = true,
allowSetting = true,
allowAsyncSetting = true,
allowSettingOfCondition = true,
showSourceHandle = true,
showTargetHandle = true,
@@ -49,6 +50,7 @@
allowCopy?: boolean,
allowDelete?: boolean,
allowSetting?: boolean,
allowAsyncSetting?: boolean,
allowSettingOfCondition?: boolean,
showSourceHandle?: boolean,
showTargetHandle?: boolean,
@@ -268,15 +270,17 @@
</details>
{/if}
<label class="input-item-inline">
<span>异步执行:</span>
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
const value = (event.target as any).checked;
updateNodeData(currentNodeId,{
async: value
})
}} />
</label>
{#if allowAsyncSetting}
<label class="input-item-inline">
<span>异步执行:</span>
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
const value = (event.target as any).checked;
updateNodeData(currentNodeId,{
async: value
})
}} />
</label>
{/if}
<label class="input-item-inline">
<span>循环执行:</span>

View File

@@ -13,12 +13,14 @@
position,
dataKeyName,
placeholder = '请输入参数值',
readOnly = false,
onParametersChange,
}: {
parameter: Parameter,
position: number[],
dataKeyName: string,
placeholder?: string,
readOnly?: boolean,
onParametersChange?: ParameterChangeHandler,
} = $props();
@@ -99,7 +101,7 @@
};
let triggerObject: any;
let triggerObject: any = $state();
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
const previousParameters = deepClone(
@@ -180,16 +182,26 @@
{#if position.length > 1}
<span class="output-branch-marker"></span>
{/if}
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
{#if readOnly}
<span class="readonly-value" title={displayParameterName}>{displayParameterName || '--'}</span>
{:else}
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
{/if}
</div>
</div>
<div class="input-item">
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
disabled={currentParameter.dataTypeDisabled === true}
onSelect={updateDataType} />
{#if (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
{#if readOnly}
<span class="readonly-value readonly-value--type" title={currentParameter.dataType || 'String'}>
{currentParameter.dataType || 'String'}
</span>
{:else}
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
disabled={currentParameter.dataTypeDisabled === true}
onSelect={updateDataType} />
{/if}
{#if !readOnly && (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
<Button class="input-btn-more" style="margin-left: auto" onclick={handleAddChildParameter}>
<svg style="transform: scaleY(-1)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
@@ -199,37 +211,41 @@
</Button>
{/if}
</div>
<div class="input-item">
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
默认值:
<Textarea rows={1} style="width: 100%;"
value={currentParameter.defaultValue||''}
onchange={(event)=>{
updateByEvent( 'defaultValue', event)
}} />
</div>
<div class="input-more-item">
参数描述:
<Textarea rows={3} style="width: 100%;"
value={currentParameter.description||''}
onchange={(event)=>{
updateByEvent( 'description', event)
}} />
</div>
{#if currentParameter.deleteDisabled !== true}
{#if !readOnly}
<div class="input-item">
{#if currentParameter.settingsDisabled !== true}
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
默认值:
<Textarea rows={1} style="width: 100%;"
value={currentParameter.defaultValue||''}
onchange={(event)=>{
updateByEvent( 'defaultValue', event)
}} />
</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
</div>
<div class="input-more-item">
参数描述:
<Textarea rows={3} style="width: 100%;"
value={currentParameter.description||''}
onchange={(event)=>{
updateByEvent( 'description', event)
}} />
</div>
{#if currentParameter.deleteDisabled !== true}
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
{/if}
</div>
{/if}
<style lang="less">
@@ -238,6 +254,7 @@
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.output-name-shell {
@@ -261,6 +278,22 @@
opacity: 0.9;
}
.readonly-value {
display: block;
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 24px;
color: var(--tf-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
&--type {
color: var(--tf-text-secondary);
text-align: right;
}
}
.input-more-setting {
display: flex;
flex-direction: column;

View File

@@ -8,11 +8,13 @@
noneParameterText = '无输出参数',
dataKeyName = 'outputDefs',
placeholder = '请输入参数名称',
readOnly = false,
onParametersChange,
}: {
noneParameterText?: string;
dataKeyName?: string;
placeholder?: string;
readOnly?: boolean;
onParametersChange?: ParameterChangeHandler;
} = $props();
@@ -31,6 +33,7 @@
position={[...position, index]}
{dataKeyName}
{placeholder}
{readOnly}
{onParametersChange}
/>
{#if param.children}
@@ -44,11 +47,13 @@
{/snippet}
<div class="input-container">
<div class="input-container" class:input-container--readonly={readOnly}>
{#if (parameters.length !== 0)}
<div class="input-header">参数名称</div>
<div class="input-header">参数类型</div>
<div class="input-header"></div>
{#if !readOnly}
<div class="input-header"></div>
{/if}
{/if}
{@render parameterList(parameters || [], [])}
</div>
@@ -65,6 +70,11 @@
min-width: 0;
box-sizing: border-box;
&--readonly {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 16px;
}
.none-params {
font-size: 12px;
background: var(--tf-bg-muted);
@@ -82,6 +92,10 @@
font-size: 12px;
color: var(--tf-text-secondary);
min-width: 0;
&:nth-child(2) {
text-align: right;
}
}
}

View File

@@ -1,132 +1,461 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading} from '../base';
import {Textarea} from '../base/index.js';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {useSvelteFlow} from '@xyflow/svelte';
import {onMount, untrack} from 'svelte';
import type {TinyflowNodeData} from '#types';
import {Heading, Input, Textarea} from '../base';
import OutputDefList from '../core/OutputDefList.svelte';
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
import type {Parameter, TinyflowNodeData} from '#types';
import {deepEqual} from '#components/utils/deepEqual';
import NodeWrapper from '../core/NodeWrapper.svelte';
import {
createConfirmOption,
MAX_CONFIRM_OPTIONS,
normalizeConfirmNodeData,
validateConfirmOptions,
} from '../utils/confirmNode';
import {deepEqual} from '../utils/deepEqual';
import {getCurrentNodeId} from '../utils/NodeUtils';
import {useTinyflowStore} from '../../store/stores.svelte';
const {data, ...rest}: {
data: TinyflowNodeData;
[key: string]: any;
} = $props();
const { data, ...rest }: {
data: TinyflowNodeData,
[key: string]: any
} = $props();
const currentNodeId = getCurrentNodeId();
const {updateNodeData} = useSvelteFlow();
const store = useTinyflowStore();
const INPUT_COMMIT_DELAY_MS = 200;
const multiple = $derived(data.multiple === true);
let messageDraft = $state(untrack(() => String(data.message || '')));
let optionDrafts = $state<string[]>(
untrack(() => normalizeOptions(data.options)),
);
let messageDirty = false;
let optionsDirty = false;
let inputCommitTimer: ReturnType<typeof setTimeout> | undefined;
const options = $derived(optionDrafts);
const validations = $derived(validateConfirmOptions(options));
let draggedOptionIndex = $state<number | null>(null);
const currentNodeId = getCurrentNodeId();
const { addParameter } = useAddParameter();
const { updateNodeData } = useSvelteFlow();
function normalizeOptions(value: unknown) {
return Array.isArray(value)
? value.map((option) => typeof option === 'string' ? option : '')
: [];
}
$effect(() => {
if (data.confirms) {
const outputDefs = data.confirms.map((confirm: Parameter) => {
return {
// id?: string;
// name?: string;
// nameDisabled?: boolean;
// dataType?: string;
// dataTypeDisabled?: boolean;
// ref?: string;
// refType?: string;
// value?: string;
// description?: string;
// required?: boolean;
// defaultValue?: string;
// deleteDisabled?: boolean;
// addChildDisabled?: boolean;
// children?: Parameter[];
...confirm,
nameDisabled: true,
dataTypeDisabled: true,
dataType: confirm.formType === 'checkbox' || confirm.formType === 'select' ? 'Array' : 'String',
addChildDisabled: true
} as Parameter;
});
function sameOptions(left: string[], right: string[]) {
return left.length === right.length
&& left.every((option, index) => option === right[index]);
}
// 判断 outputDefs 与 data.outputDefs 是否完全一致
// 如果不判断,则会造成死循环更新
if (!deepEqual(outputDefs, data.outputDefs)) {
updateNodeData(currentNodeId, () => {
return {
outputDefs
};
});
}
}
$effect(() => {
const nextMessage = String(data.message || '');
const nextOptions = normalizeOptions(data.options);
untrack(() => {
if (!messageDirty && messageDraft !== nextMessage) {
messageDraft = nextMessage;
}
if (!optionsDirty && !sameOptions(optionDrafts, nextOptions)) {
optionDrafts = nextOptions;
}
});
});
$effect(() => {
const normalizedData = normalizeConfirmNodeData(data);
if (!deepEqual(normalizedData, data)) {
updateNodeData(currentNodeId, normalizedData, {replace: true});
}
});
function flushInputDraft() {
if (inputCommitTimer) {
clearTimeout(inputCommitTimer);
inputCommitTimer = undefined;
}
if (!messageDirty && !optionsDirty) return;
const patch: Record<string, unknown> = {};
if (messageDirty) patch.message = messageDraft;
if (optionsDirty) patch.options = [...optionDrafts];
messageDirty = false;
optionsDirty = false;
store.updateNodeData(currentNodeId, patch);
}
function scheduleInputCommit() {
if (inputCommitTimer) clearTimeout(inputCommitTimer);
inputCommitTimer = setTimeout(flushInputDraft, INPUT_COMMIT_DELAY_MS);
}
function updateMessage(content: string) {
messageDraft = content;
messageDirty = true;
scheduleInputCommit();
}
function replaceOptions(nextOptions: string[], immediate = false) {
optionDrafts = nextOptions;
optionsDirty = true;
if (immediate) {
flushInputDraft();
return;
}
scheduleInputCommit();
}
function updateOption(index: number, content: string) {
replaceOptions(options.map((option, optionIndex) =>
optionIndex === index ? content : option,
));
}
function addOption() {
if (options.length >= MAX_CONFIRM_OPTIONS) return;
replaceOptions([...options, createConfirmOption(options)], true);
}
function deleteOption(index: number) {
if (options.length <= 1) return;
replaceOptions(
options.filter((_, optionIndex) => optionIndex !== index),
true,
);
}
function moveOption(from: number, to: number) {
if (from === to || from < 0 || to < 0 || to >= options.length) return;
const nextOptions = [...options];
const [moved] = nextOptions.splice(from, 1);
nextOptions.splice(to, 0, moved);
replaceOptions(nextOptions, true);
}
function onGripKeydown(event: KeyboardEvent, index: number) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
moveOption(index, index + (event.key === 'ArrowUp' ? -1 : 1));
}
onMount(() => {
const unregister = store.registerPendingEditFlusher(flushInputDraft);
return () => {
flushInputDraft();
unregister();
};
});
</script>
<NodeWrapper
{data}
{...rest}
allowAsyncSetting={false}
wrapperClass="tf-node-wrapper--confirm"
>
{#snippet icon()}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path>
</svg>
{/snippet}
<NodeWrapper {data} {...rest}>
<div class="confirm-card">
<section>
<Heading level={3} mb="8px">固定信息</Heading>
<div class="setting-title">提示内容</div>
<Textarea
class="confirm-message"
rows={3}
maxHeight="120px"
maxlength={2000}
placeholder="请输入用户需要确认的提示内容"
style="width: 100%"
value={messageDraft}
oninput={(event: Event) => updateMessage(
(event.target as HTMLTextAreaElement).value,
)}
onblur={flushInputDraft}
/>
</section>
{#snippet icon()}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
{/snippet}
<section>
<Heading level={3} mb="8px">交互选项</Heading>
<fieldset class="choice-mode">
<legend>选择方式</legend>
<label>
<input
type="radio"
name="confirm-mode-{currentNodeId}"
checked={!multiple}
onchange={() => updateNodeData(currentNodeId, {multiple: false})}
/>
<span>单选</span>
</label>
<label>
<input
type="radio"
name="confirm-mode-{currentNodeId}"
checked={multiple}
onchange={() => updateNodeData(currentNodeId, {multiple: true})}
/>
<span>多选</span>
</label>
</fieldset>
<div class="heading">
<Heading level={3}>确认数据</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, 'confirms')
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
</div>
<ConfirmParameterList dataKeyName="confirms" noneParameterText="无确认数据" />
<Heading level={3} mt="10px">确认消息</Heading>
<div class="setting-title">消息内容</div>
<div class="setting-item">
<Textarea rows={5} placeholder="请输入用户需要确认的消息内容"
style="width: 100%" onchange={(e:any)=>{
updateNodeData(currentNodeId, ()=>{
return {
message: e.target.value
<div class="option-list nowheel">
{#each options as option, optionIndex (optionIndex)}
<div class="option-item">
<div
class="option-row"
role="group"
aria-label={`选项 ${optionIndex + 1}`}
ondragover={(event: DragEvent) => event.preventDefault()}
ondrop={(event: DragEvent) => {
event.preventDefault();
if (draggedOptionIndex !== null) {
moveOption(draggedOptionIndex, optionIndex);
}
})
}} value={String(data.message || '')} />
</div>
draggedOptionIndex = null;
}}
>
<button
type="button"
class="grip nodrag nopan"
draggable="true"
aria-label={`调整选项 ${optionIndex + 1} 顺序,方向键也可移动`}
ondragstart={() => draggedOptionIndex = optionIndex}
ondragend={() => draggedOptionIndex = null}
onkeydown={(event: KeyboardEvent) => onGripKeydown(event, optionIndex)}
></button>
<Input
value={option}
maxlength={200}
aria-label={`选项 ${optionIndex + 1} 内容`}
aria-invalid={Boolean(validations[optionIndex])}
placeholder="请输入选项内容"
oninput={(event: Event) => updateOption(
optionIndex,
(event.target as HTMLInputElement).value,
)}
onblur={flushInputDraft}
/>
<button
type="button"
class="icon-action nodrag nopan"
aria-label={`删除选项 ${option || optionIndex + 1}`}
disabled={options.length <= 1}
onclick={() => deleteOption(optionIndex)}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"></path>
</svg>
</button>
</div>
{#if validations[optionIndex]}
<div class="validation-message" role="alert">{validations[optionIndex]}</div>
{/if}
</div>
{/each}
</div>
<button
type="button"
class="text-action nodrag nopan"
disabled={options.length >= MAX_CONFIRM_OPTIONS}
onclick={addOption}
><span aria-hidden="true"></span> 添加选项</button>
</section>
<div class="heading">
<Heading level={3} mt="10px">输出参数</Heading>
</div>
<OutputDefList placeholder="" />
<section>
<Heading level={3} mb="10px">输出参数</Heading>
<OutputDefList />
</section>
</div>
</NodeWrapper>
<style>
.heading {
display: flex;
margin-bottom: 10px;
}
<style lang="less">
.confirm-card {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.setting-title {
font-size: 12px;
color: var(--tf-text-muted);
margin-bottom: 4px;
margin-top: 10px;
}
section {
min-width: 0;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 10px;
}
.setting-title,
.choice-mode {
font-size: 12px;
line-height: 18px;
color: var(--tf-text-secondary);
}
.setting-title {
margin-bottom: 4px;
}
:global(.confirm-message) {
box-sizing: border-box;
min-height: 72px;
font-size: 13px;
line-height: 1.5;
resize: none;
}
.choice-mode {
display: flex;
gap: 16px;
align-items: center;
padding: 0;
margin: 0 0 8px;
border: 0;
}
.choice-mode legend {
float: left;
margin-right: 2px;
}
.choice-mode label {
display: inline-flex;
gap: 5px;
align-items: center;
color: var(--tf-text-primary);
cursor: pointer;
}
.choice-mode input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--tf-primary-color);
}
.choice-mode input:focus-visible,
.grip:focus-visible,
.icon-action:focus-visible,
.text-action:focus-visible {
outline: 0;
border-radius: 5px;
box-shadow: var(--tf-focus-shadow);
}
.option-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
padding-right: 2px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.option-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 28px;
gap: 6px;
align-items: center;
min-width: 0;
}
.option-row :global(.tf-input) {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 30px;
font-size: 12px;
}
.grip,
.icon-action,
.text-action {
padding: 0;
background: transparent;
border: 0;
}
.grip,
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--tf-text-muted);
}
.grip {
width: 18px;
height: 28px;
font-size: 16px;
cursor: grab;
}
.grip:active {
cursor: grabbing;
}
.icon-action {
width: 28px;
height: 28px;
cursor: pointer;
opacity: .56;
}
.icon-action svg {
display: block;
width: 16px;
height: 16px;
overflow: visible;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.option-row:hover .icon-action,
.option-row:focus-within .icon-action {
opacity: .72;
}
.icon-action:hover:not(:disabled) {
color: var(--tf-danger-color);
opacity: 1;
}
.icon-action:disabled,
.text-action:disabled {
cursor: not-allowed;
opacity: .35;
}
.validation-message {
padding-left: 24px;
font-size: 11px;
line-height: 16px;
color: var(--tf-danger-color);
}
.text-action {
display: inline-flex;
gap: 3px;
align-items: center;
margin-top: 6px;
font-size: 12px;
line-height: 20px;
color: var(--tf-primary-color);
cursor: pointer;
}
.text-action:hover:not(:disabled) {
color: var(--tf-primary-color-hover);
}
</style>

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
buildConfirmOutputDefs,
createConfirmOption,
normalizeConfirmNodeData,
validateConfirmOptions,
} from './confirmNode';
describe('confirm node contract', () => {
it('maps the selection mode to one output with a default name', () => {
expect(buildConfirmOutputDefs(false)).toMatchObject([
{ name: 'selection', dataType: 'String' },
]);
expect(buildConfirmOutputDefs(true)).toMatchObject([
{ name: 'selection', dataType: 'Array<String>' },
]);
});
it('keeps the output name editable and locks its inferred type', () => {
expect(buildConfirmOutputDefs(false)[0]).toMatchObject({
addChildDisabled: true,
autoManaged: true,
dataTypeItems: [{ label: 'String', value: 'String' }],
dataTypeDisabled: true,
deleteDisabled: true,
settingsDisabled: true,
});
expect(buildConfirmOutputDefs(false)[0]).not.toHaveProperty('nameDisabled');
expect(buildConfirmOutputDefs(true)[0]).toMatchObject({
dataType: 'Array<String>',
dataTypeItems: [
{ label: 'Array<String>', value: 'Array<String>' },
],
});
});
it('preserves a configured output name when the selection mode changes', () => {
expect(buildConfirmOutputDefs(true, 'templateChoice')).toMatchObject([
{ name: 'templateChoice', dataType: 'Array<String>' },
]);
});
it('materializes the visible single-select default in node data', () => {
expect(normalizeConfirmNodeData({
message: '请选择会议纪要模板',
options: ['确认', '取消'],
})).toMatchObject({
multiple: false,
outputDefs: [{ name: 'selection', dataType: 'String' }],
});
});
it('removes data that is not part of the final confirm contract', () => {
const normalized = normalizeConfirmNodeData({
async: true,
confirms: [{ name: 'legacy' }],
fields: [{ key: 'legacy' }],
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
parameters: [{ name: 'unused' }],
schemaVersion: 1,
unknownLegacySetting: true,
title: '用户确认',
});
expect(normalized).toMatchObject({
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
title: '用户确认',
});
expect(normalized).not.toHaveProperty('confirms');
expect(normalized).not.toHaveProperty('async');
expect(normalized).not.toHaveProperty('fields');
expect(normalized).not.toHaveProperty('parameters');
expect(normalized).not.toHaveProperty('schemaVersion');
expect(normalized).not.toHaveProperty('unknownLegacySetting');
});
it('preserves common node settings that are effective at runtime', () => {
const normalized = normalizeConfirmNodeData({
condition: 'true',
description: '确认继续或选择内容',
expand: true,
joinMode: 'all',
loopEnable: true,
loopIntervalMs: 1000,
maxLoopCount: 2,
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
retryEnable: true,
retryIntervalMs: 1000,
maxRetryCount: 3,
title: '用户确认',
});
expect(normalized).toMatchObject({
condition: 'true',
expand: true,
joinMode: 'all',
loopEnable: true,
retryEnable: true,
title: '用户确认',
});
});
it('keeps invalid explicit modes visible to backend validation', () => {
expect(normalizeConfirmNodeData({
multiple: 'false',
outputDefs: [{ name: 'templateType', dataType: 'String' }],
}).multiple).toBe('false');
});
it('creates a unique option content after an option was removed', () => {
expect(createConfirmOption(['选项 2', '选项 3'])).toBe('选项 4');
});
it('reports empty and duplicate option contents', () => {
expect(validateConfirmOptions(['', '审议类', ' 审议类 '])).toEqual([
'请输入选项内容',
'选项内容不能重复',
'选项内容不能重复',
]);
});
});

View File

@@ -0,0 +1,102 @@
import type { Parameter } from '#types';
export const MAX_CONFIRM_OPTIONS = 100;
export const DEFAULT_CONFIRM_OUTPUT_NAME = 'selection';
export const CONFIRM_NODE_DATA_KEYS = new Set([
'condition',
'description',
'expand',
'joinMode',
'loopBreakCondition',
'loopEnable',
'loopIntervalMs',
'maxLoopCount',
'maxRetryCount',
'message',
'multiple',
'options',
'outputDefs',
'resetRetryCountAfterNormal',
'retryEnable',
'retryIntervalMs',
'title',
]);
export function createConfirmOption(existing: string[]) {
let sequence = existing.length + 1;
let option = `选项 ${sequence}`;
while (existing.includes(option)) {
sequence += 1;
option = `选项 ${sequence}`;
}
return option;
}
export function buildConfirmOutputDefs(
multiple: boolean,
outputName = DEFAULT_CONFIRM_OUTPUT_NAME,
): Parameter[] {
const dataType = multiple ? 'Array<String>' : 'String';
return [
{
id: 'confirm-selection',
name: outputName,
dataType,
dataTypeItems: [{ label: dataType, value: dataType }],
dataTypeDisabled: true,
addChildDisabled: true,
deleteDisabled: true,
settingsDisabled: true,
autoManaged: true,
},
];
}
export function normalizeConfirmNodeData(data: Record<string, any>) {
let nextData = data;
const mutableData = () => {
if (nextData === data) {
nextData = { ...data };
}
return nextData;
};
for (const key of Object.keys(nextData)) {
if (!CONFIRM_NODE_DATA_KEYS.has(key)) {
delete mutableData()[key];
}
}
if (nextData.multiple == null) {
mutableData().multiple = false;
}
const configuredOutputName = Array.isArray(nextData.outputDefs)
&& typeof nextData.outputDefs[0]?.name === 'string'
? nextData.outputDefs[0].name
: DEFAULT_CONFIRM_OUTPUT_NAME;
const outputDefs = buildConfirmOutputDefs(
nextData.multiple === true,
configuredOutputName,
);
if (JSON.stringify(nextData.outputDefs) !== JSON.stringify(outputDefs)) {
mutableData().outputDefs = outputDefs;
}
return nextData;
}
export function validateConfirmOptions(options: string[]) {
const counts = new Map<string, number>();
for (const option of options) {
const normalized = option.trim();
if (normalized) counts.set(normalized, (counts.get(normalized) || 0) + 1);
}
return options.map((option) => {
const normalized = option.trim();
if (!normalized) return '请输入选项内容';
if (counts.get(normalized)! > 1) return '选项内容不能重复';
return undefined;
});
}

View File

@@ -3,6 +3,7 @@ import type { Node } from '@xyflow/svelte';
import type { TinyflowOptions } from '#types';
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
import { buildConfirmOutputDefs } from './confirmNode';
export type NodePaletteItem = {
icon?: string;
@@ -101,6 +102,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
sortNo: 900,
description: '确认继续或选择内容',
category: '输入输出',
extra: {
message: '请确认以下内容',
multiple: false,
options: ['选项一', '选项二'],
outputDefs: buildConfirmOutputDefs(false),
},
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',

View File

@@ -10,6 +10,7 @@ export const createStore = () => {
let edgesInternal = $state.raw([] as Edge[]);
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
const pendingEditFlushers = new Set<() => void>();
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
@@ -39,6 +40,13 @@ export const createStore = () => {
setViewport: (v: Viewport) => {
viewport = v;
},
registerPendingEditFlusher: (flusher: () => void) => {
pendingEditFlushers.add(flusher);
return () => pendingEditFlushers.delete(flusher);
},
flushPendingEdits: () => {
[...pendingEditFlushers].forEach((flusher) => flusher());
},
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
addNode: (node: Node) => {

View File

@@ -120,6 +120,12 @@
min-width: 296px;
max-width: 296px;
}
&--confirm {
width: 360px;
min-width: 360px;
max-width: 360px;
}
}
.svelte-flow__attribution a {

View File

@@ -85,4 +85,35 @@ describe('tinyflow store isolation', () => {
first.destroy();
second.destroy();
});
it('flushes pending node edits before exporting data', async () => {
const container = document.createElement('div');
document.body.append(container);
const tinyflow = new Tinyflow({
element: container,
data: {
nodes: [
{ id: 'confirm', position: { x: 0, y: 0 }, data: { message: '旧值' } },
],
edges: [],
},
});
await waitForRender();
const store = (tinyflow as unknown as {
store: {
registerPendingEditFlusher: (flusher: () => void) => () => boolean;
updateNodeData: (id: string, data: Record<string, unknown>) => void;
};
}).store;
const unregister = store.registerPendingEditFlusher(() => {
store.updateNodeData('confirm', { message: '最新值' });
});
expect(tinyflow.getData()?.nodes[0]?.data.message).toBe('最新值');
unregister();
tinyflow.destroy();
});
});

View File

@@ -25,6 +25,7 @@ export type SelectItem = {
itemTypeLabel?: string;
isCollection?: boolean;
tags?: string[];
disabledReason?: string;
children?: SelectItem[];
};
@@ -157,9 +158,11 @@ export type Parameter = {
required?: boolean;
defaultValue?: string;
deleteDisabled?: boolean;
settingsDisabled?: boolean;
addChildDisabled?: boolean;
children?: Parameter[];
enums?: string[];
options?: ParameterOption[];
formType?: string;
formLabel?: string;
formDescription?: string;
@@ -174,6 +177,11 @@ export type Parameter = {
flattenAggregation?: boolean;
};
export type ParameterOption = {
label: string;
value: string;
};
export type ParameterChangeHandler = (
previousParameters: Parameter[],
nextParameters: Parameter[],

View File

@@ -16,7 +16,7 @@ import {
FIELD_BINDING_META_KEY,
isStartFormFieldKeyAvailable,
normalizeStartNodeData,
normalizeWorkflowStartNodes,
normalizeWorkflowNodes,
renameStartFieldReferencesInNodes,
removeStartFormField,
syncManagedParametersForFields,
@@ -1395,7 +1395,7 @@ describe('workflow node fields', () => {
});
it('normalizes only start nodes that already contain fixed user_input', () => {
const normalizedWorkflow = normalizeWorkflowStartNodes({
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'start_new',
@@ -1430,4 +1430,63 @@ describe('workflow node fields', () => {
).toBe('user_input');
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
});
it('removes retired confirm data without changing the current contract', () => {
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'confirm_1',
type: 'confirmNode',
data: {
title: '用户确认',
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
confirms: [],
fields: [],
parameters: [{ name: 'unused' }],
schemaVersion: 1,
},
},
],
edges: [],
});
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
title: '用户确认',
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
});
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('confirms');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('fields');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('parameters');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty(
'schemaVersion',
);
});
it('writes the visible single-select default into confirm node data', () => {
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'confirm_1',
type: 'confirmNode',
data: {
message: '请选择会议纪要模板',
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
},
},
],
edges: [],
});
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
multiple: false,
outputDefs: [{ name: 'templateType', dataType: 'String' }],
});
});
});

View File

@@ -10,8 +10,10 @@ import {
buildLoopReferenceParameters,
buildLoopScopeParameters,
} from './loopScope';
import { normalizeConfirmNodeData } from '../components/utils/confirmNode';
export const START_NODE_TYPE = 'startNode';
export const CONFIRM_NODE_TYPE = 'confirmNode';
export const LLM_NODE_TYPE = 'llmNode';
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
export const SYSTEM_START_PARAM_NAME = 'user_input';
@@ -1147,7 +1149,7 @@ export function createInitialWorkflowData() {
};
}
export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
export function normalizeWorkflowNodes<T extends Record<string, any>>(
data: T,
): T {
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
@@ -1156,10 +1158,24 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
let changed = false;
const nextNodes = data.nodes.map((node) => {
if (node?.type !== START_NODE_TYPE) {
if (!node?.data || typeof node.data !== 'object') {
return node;
}
const currentData = (node.data || {}) as Record<string, any>;
if (node.type === CONFIRM_NODE_TYPE) {
const nextData = normalizeConfirmNodeData(currentData);
if (nextData === currentData) {
return node;
}
changed = true;
return {
...node,
data: nextData,
};
}
if (node.type !== START_NODE_TYPE) {
return node;
}
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];

View File

@@ -1,210 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值
modelValue: {
type: [String, Number, Object],
default: null,
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue === item;
};
// 切换选中状态
const changeValue = (item: any) => {
if (props.modelValue === item) {
// 如果点击已选中的,则取消选中
emit('update:modelValue', null);
emit('change', null); // 触发 Element Plus 表单验证
} else {
emit('update:modelValue', item);
emit('change', item); // 触发 Element Plus 表单验证
}
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<audio
controls
:src="item"
style="width: 100%; height: 44px; margin-top: 8px"
></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号,如果没有安装 Element Plus可以用普通的 span + css -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box; /* 确保 padding 不会撑大宽度 */
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 4px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex; /* 为了对齐图标 */
align-items: center;
}
</style>

View File

@@ -1,216 +0,0 @@
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue';
import { ElIcon, ElText } from 'element-plus';
import confirmFile from '#/assets/ai/workflow/confirm-file.png';
// 导入你的图片资源
import confirmOther from '#/assets/ai/workflow/confirm-other.png';
// 定义 Props
const props = defineProps({
// v-model 绑定值,多选版本这里是数组
modelValue: {
type: Array as () => any[],
default: () => [],
},
// 数据类型: text, image, video, audio, other, file
selectionDataType: {
type: String,
default: 'text',
},
// 数据列表
selectionData: {
type: Array as () => any[],
default: () => [],
},
});
// 定义 Emits
const emit = defineEmits(['update:modelValue', 'change']);
// 判断是否选中
const isSelected = (item: any) => {
return props.modelValue && props.modelValue.includes(item);
};
// 切换选中状态 (多选逻辑)
const changeValue = (item: any) => {
// 复制一份当前数组,避免直接修改 prop
const currentValues = props.modelValue ? [...props.modelValue] : [];
const index = currentValues.indexOf(item);
if (index === -1) {
// 如果不存在,则添加
currentValues.push(item);
} else {
// 如果已存在,则移除
currentValues.splice(index, 1);
}
// 更新 v-model
emit('update:modelValue', currentValues);
// 触发 Element Plus 表单验证
emit('change', currentValues);
};
// 获取图标
const getIcon = (type: string) => {
return type === 'other' ? confirmOther : confirmFile;
};
// 下载处理
const handleDownload = (url: string) => {
window.open(url, '_blank');
};
</script>
<template>
<div class="custom-radio-group">
<template v-for="(item, index) in selectionData" :key="index">
<!-- 类型: Text -->
<div
v-if="selectionDataType === 'text'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
{{ item }}
</div>
<!-- 类型: Image -->
<div
v-else-if="selectionDataType === 'image'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="padding: 0"
@click="changeValue(item)"
>
<img
:src="item"
alt=""
style="width: 80px; height: 80px; border-radius: 8px; display: block"
/>
</div>
<!-- 类型: Video -->
<div
v-else-if="selectionDataType === 'video'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
@click="changeValue(item)"
>
<video controls :src="item" style="width: 162px; height: 141px"></video>
</div>
<!-- 类型: Audio -->
<div
v-else-if="selectionDataType === 'audio'"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 300px; flex-shrink: 0"
@click="changeValue(item)"
>
<audio controls :src="item" style="width: 100%; height: 40px"></audio>
</div>
<!-- 类型: File Other -->
<div
v-else-if="
selectionDataType === 'other' || selectionDataType === 'file'
"
class="custom-radio-option"
:class="{ selected: isSelected(item) }"
style="width: 100%; flex-shrink: 0"
@click="changeValue(item)"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
>
<div style="width: 92%; display: flex; align-items: center">
<img
style="width: 20px; height: 20px; margin-right: 8px"
alt=""
:src="getIcon(selectionDataType)"
/>
<!-- 使用 Element Plus 的 Text 组件处理省略号 -->
<ElText truncated>
{{ item }}
</ElText>
</div>
<div class="download-icon-btn" @click.stop="handleDownload(item)">
<ElIcon><Download /></ElIcon>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* 这里复用之前的 CSS样式完全一致 */
.custom-radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.custom-radio-option {
background-color: var(--el-bg-color);
padding: 8px;
border-radius: 8px;
cursor: pointer;
position: relative;
box-shadow: 0 0 0 1px var(--el-border-color);
transition: all 0.2s;
box-sizing: border-box;
}
.custom-radio-option:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.custom-radio-option.selected {
box-shadow: 0 0 0 1px var(--el-color-primary-light-3);
padding: 8px;
background: var(--el-color-primary-light-9);
}
.custom-radio-option.selected::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
background-color: var(--el-color-primary);
border-radius: 6px 2px;
box-sizing: border-box;
}
.custom-radio-option.selected::before {
content: '';
position: absolute;
right: 3px;
bottom: 7px;
width: 9px;
height: 5px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: rotate(-45deg);
z-index: 1;
}
.download-icon-btn {
font-size: 18px;
cursor: pointer;
margin-right: 10px;
display: flex;
align-items: center;
}
</style>

View File

@@ -86,14 +86,22 @@ watch(
},
);
const executeId = ref('');
function resume(data: any) {
async function resume(data: any) {
data.executeId = executeId.value;
submitLoading.value = true;
api.post('/userCenter/workflow/resume', data).then((res) => {
let accepted = false;
try {
const res = await api.post('/userCenter/workflow/resume', data);
if (res.errorCode === 0) {
accepted = true;
startPolling(executeId.value);
}
});
return accepted;
} finally {
if (!accepted) {
submitLoading.value = false;
}
}
}
function submitV2() {
runForm.value?.validate((valid) => {
@@ -131,7 +139,7 @@ function startPolling(executeId: any) {
if (pollingActive) return;
pollingActive = true;
pollingGeneration += 1;
schedulePolling(executeId, pollingGeneration);
void executePolling(executeId, pollingGeneration);
}
function schedulePolling(executeId: any, generation: number) {
timer.value = setTimeout(() => {

View File

@@ -46,6 +46,12 @@ function isFileContentType(contentType: any) {
return contentType === 'file';
}
function getCheckboxOptions(item: any) {
if (Array.isArray(item.options)) {
return item.options.map((option: any) => ({
label: option.label,
value: option.value,
}));
}
if (item.enums) {
return (
item.enums?.map((option: any) => ({
@@ -73,7 +79,9 @@ function buildRules(item: any) {
return;
}
if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required')));
callback(
value.length > 0 ? undefined : new Error($t('message.required')),
);
return;
}
if (value && typeof value === 'object') {

View File

@@ -15,14 +15,11 @@ import {
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElIcon,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
export interface WorkflowStepsProps {
workflowId: any;
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
pollingData?: any;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const emit = defineEmits<{
resume: [payload: any, onSettled: (accepted: boolean) => void];
}>();
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
@@ -43,13 +42,18 @@ watch(
isChainError.value = true;
chainErrMsg.value = newVal.message;
}
if (![20, 21].includes(newVal.status)) {
if (Number(newVal.status) !== 5) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
const previousStatus = nodeStatusMap.value[nodeId]?.status;
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
activeName.value = nodeId;
if (Number(previousStatus) !== 5) {
initializeConfirmParams(nodes[nodeId].suspendForParameters);
confirmBtnLoading.value = false;
}
}
}
},
@@ -59,6 +63,7 @@ watch(
() => props.initSignal,
() => {
nodeStatusMap.value = {};
confirmParams.value = {};
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
@@ -91,8 +96,13 @@ const setFormRef = (el: any, key: string) => {
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
function initializeConfirmParams(parameters: any) {
const values: Record<string, any> = {};
for (const parameter of Array.isArray(parameters) ? parameters : []) {
values[parameter.name] =
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
}
confirmParams.value = values;
}
function handleConfirm(node: any) {
const nodeKey = node.key;
@@ -103,17 +113,27 @@ function handleConfirm(node: any) {
console.warn(`Form instance for ${nodeKey} not found`);
return;
}
const confirmKey = node.suspendForParameters[0].name;
form.validate((valid) => {
if (valid) {
const value = {
confirmParams: {
[confirmKey]: 'yes',
...confirmParams.value,
},
confirmParams: { ...confirmParams.value },
};
confirmBtnLoading.value = true;
emit('resume', value);
emit('resume', value, (accepted) => {
confirmBtnLoading.value = false;
if (!accepted) {
return;
}
confirmParams.value = {};
const currentState = nodeStatusMap.value[nodeKey];
if (currentState) {
nodeStatusMap.value[nodeKey] = {
...currentState,
status: 1,
suspendForParameters: [],
};
}
});
}
});
}
@@ -165,7 +185,12 @@ function handleConfirm(node: any) {
</div>
</div>
</template>
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div
v-if="
node.original.type === 'confirmNode' && Number(node.status) === 5
"
class="p-2.5"
>
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
@@ -174,50 +199,24 @@ function handleConfirm(node: any) {
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
<WorkflowFormItem
:parameters="node.suspendForParameters || []"
:run-params="confirmParams"
@update:run-params="confirmParams = $event"
/>
<div
v-if="node.suspendForParameters?.length > 0"
class="flex justify-end"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<ElButton
:disabled="confirmBtnLoading"
:loading="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
继续
</ElButton>
</div>
</ElForm>
</div>
<div v-else>
@@ -248,26 +247,4 @@ function handleConfirm(node: any) {
transform: rotate(360deg);
}
}
.header-container {
display: flex;
align-items: center;
font-weight: bold;
word-break: break-all;
}
.blue-bar {
display: inline-block;
width: 2px;
height: 16px;
margin-right: 16px;
background-color: var(--el-color-primary);
border-radius: 1px;
}
.description-container {
margin-bottom: 16px;
color: #969799;
word-break: break-all;
}
</style>