feat: 完善用户确认节点选项与输出流转
- 重构确认节点单选多选配置及输出参数契约 - 统一管理端、用户中心、匿名分享和公共接口恢复流程 - 增加保存校验、错误契约及交互测试
This commit is contained in:
@@ -18,10 +18,12 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
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.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Plugin;
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.enums.PluginType;
|
import tech.easyflow.ai.enums.PluginType;
|
||||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||||
import tech.easyflow.ai.service.PluginService;
|
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.AgentResourceReferenceService;
|
||||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
@@ -91,11 +94,15 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowExecResultService workflowExecResultService;
|
||||||
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowCheckService workflowCheckService;
|
private WorkflowCheckService workflowCheckService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
|
|
||||||
@PostMapping("/tool/save")
|
@PostMapping("/tool/save")
|
||||||
@SaCheckPermission("/api/v1/plugin/save")
|
@SaCheckPermission("/api/v1/plugin/save")
|
||||||
@@ -215,6 +222,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@SaCheckPermission("/api/v1/plugin/query")
|
@SaCheckPermission("/api/v1/plugin/query")
|
||||||
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("nodes") List<NodeInfo> nodes) {
|
@JsonBody("nodes") List<NodeInfo> nodes) {
|
||||||
|
assertPluginTestExecutionOwnership(executeId);
|
||||||
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
|
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,10 +237,33 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@SaCheckPermission("/api/v1/plugin/query")
|
@SaCheckPermission("/api/v1/plugin/query")
|
||||||
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
assertPluginTestExecutionOwnership(executeId);
|
||||||
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
return Result.ok();
|
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) {
|
private void handleArray(JSONArray array) {
|
||||||
for (Object o : array) {
|
for (Object o : array) {
|
||||||
JSONObject obj = (JSONObject) o;
|
JSONObject obj = (JSONObject) o;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
@@ -64,6 +64,8 @@ public class WorkflowChatController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
|
@Resource
|
||||||
private WorkflowExecResultService execResultService;
|
private WorkflowExecResultService execResultService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecStepService execStepService;
|
private WorkflowExecStepService execStepService;
|
||||||
@@ -171,19 +173,8 @@ public class WorkflowChatController {
|
|||||||
@JsonBody("confirmParams")
|
@JsonBody("confirmParams")
|
||||||
Map<String, Object> confirmParams
|
Map<String, Object> confirmParams
|
||||||
) {
|
) {
|
||||||
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
assertExecutionOwnership(executeId);
|
||||||
if (record.getStatus() != null
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
&& (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)
|
|
||||||
);
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
||||||
@@ -94,6 +95,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
|
@Resource
|
||||||
private ResourceAccessService resourceAccessService;
|
private ResourceAccessService resourceAccessService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
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,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
@@ -46,6 +47,7 @@ public class WorkflowPublicChatService {
|
|||||||
private final WorkflowPublicChatAccessGuard accessGuard;
|
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||||
private final WorkflowChatEventStream eventStream;
|
private final WorkflowChatEventStream eventStream;
|
||||||
private final ChainExecutor chainExecutor;
|
private final ChainExecutor chainExecutor;
|
||||||
|
private final WorkflowResumeService workflowResumeService;
|
||||||
private final WorkflowExecResultService execResultService;
|
private final WorkflowExecResultService execResultService;
|
||||||
private final WorkflowExecStepService execStepService;
|
private final WorkflowExecStepService execStepService;
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ public class WorkflowPublicChatService {
|
|||||||
WorkflowPublicChatAccessGuard accessGuard,
|
WorkflowPublicChatAccessGuard accessGuard,
|
||||||
WorkflowChatEventStream eventStream,
|
WorkflowChatEventStream eventStream,
|
||||||
ChainExecutor chainExecutor,
|
ChainExecutor chainExecutor,
|
||||||
|
WorkflowResumeService workflowResumeService,
|
||||||
WorkflowExecResultService execResultService,
|
WorkflowExecResultService execResultService,
|
||||||
WorkflowExecStepService execStepService
|
WorkflowExecStepService execStepService
|
||||||
) {
|
) {
|
||||||
@@ -67,6 +70,7 @@ public class WorkflowPublicChatService {
|
|||||||
this.accessGuard = accessGuard;
|
this.accessGuard = accessGuard;
|
||||||
this.eventStream = eventStream;
|
this.eventStream = eventStream;
|
||||||
this.chainExecutor = chainExecutor;
|
this.chainExecutor = chainExecutor;
|
||||||
|
this.workflowResumeService = workflowResumeService;
|
||||||
this.execResultService = execResultService;
|
this.execResultService = execResultService;
|
||||||
this.execStepService = execStepService;
|
this.execStepService = execStepService;
|
||||||
}
|
}
|
||||||
@@ -185,17 +189,8 @@ public class WorkflowPublicChatService {
|
|||||||
) {
|
) {
|
||||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||||
shareKey, visitorId);
|
shareKey, visitorId);
|
||||||
WorkflowExecResult record = assertExecutionOwnership(
|
assertExecutionOwnership(context, executeId);
|
||||||
context, executeId);
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
if (isTerminal(record.getStatus())) {
|
|
||||||
throw new BusinessException("当前工作流执行已结束");
|
|
||||||
}
|
|
||||||
chainExecutor.resumeAsync(
|
|
||||||
executeId,
|
|
||||||
confirmParams == null
|
|
||||||
? new LinkedHashMap<>()
|
|
||||||
: new LinkedHashMap<>(confirmParams)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,13 +248,6 @@ public class WorkflowPublicChatService {
|
|||||||
return record;
|
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(
|
private Map<String, Object> buildExecutionDetail(
|
||||||
WorkflowExecResult record,
|
WorkflowExecResult record,
|
||||||
List<WorkflowExecStep> steps,
|
List<WorkflowExecStep> steps,
|
||||||
|
|||||||
@@ -7,21 +7,27 @@ import org.testng.Assert;
|
|||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
import tech.easyflow.ai.entity.Plugin;
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
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.AgentResourceReferenceService;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
import tech.easyflow.ai.service.PluginItemService;
|
||||||
import tech.easyflow.ai.service.PluginService;
|
import tech.easyflow.ai.service.PluginService;
|
||||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +74,61 @@ public class PluginItemControllerTest {
|
|||||||
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建插件工具。
|
* 创建插件工具。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.testng.Assert;
|
|||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
@@ -162,6 +163,8 @@ public class WorkflowPublicChatServiceTest {
|
|||||||
WorkflowChatEventStream eventStream = mock(
|
WorkflowChatEventStream eventStream = mock(
|
||||||
WorkflowChatEventStream.class);
|
WorkflowChatEventStream.class);
|
||||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
WorkflowResumeService workflowResumeService =
|
||||||
|
mock(WorkflowResumeService.class);
|
||||||
WorkflowExecResultService execResultService = mock(
|
WorkflowExecResultService execResultService = mock(
|
||||||
WorkflowExecResultService.class);
|
WorkflowExecResultService.class);
|
||||||
WorkflowExecStepService execStepService = mock(
|
WorkflowExecStepService execStepService = mock(
|
||||||
@@ -196,6 +199,7 @@ public class WorkflowPublicChatServiceTest {
|
|||||||
accessGuard,
|
accessGuard,
|
||||||
eventStream,
|
eventStream,
|
||||||
chainExecutor,
|
chainExecutor,
|
||||||
|
workflowResumeService,
|
||||||
execResultService,
|
execResultService,
|
||||||
execStepService
|
execStepService
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
||||||
@@ -70,6 +71,8 @@ public class PublicWorkflowController {
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
|
@Resource
|
||||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecResultService workflowExecResultService;
|
private WorkflowExecResultService workflowExecResultService;
|
||||||
@@ -250,14 +253,7 @@ public class PublicWorkflowController {
|
|||||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||||
assertWorkflowExecutionResumable(execResult);
|
assertWorkflowExecutionResumable(execResult);
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
executeId,
|
|
||||||
confirmParams)) {
|
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ public final class WorkflowRunAsyncErrorProfile
|
|||||||
*/
|
*/
|
||||||
private boolean isStableBusinessCode(int code) {
|
private boolean isStableBusinessCode(int code) {
|
||||||
return (code >= 40011 && code <= 40017)
|
return (code >= 40011 && code <= 40017)
|
||||||
|
|| code == 40031
|
||||||
|| (code >= 40101 && code <= 40103)
|
|| (code >= 40101 && code <= 40103)
|
||||||
|| (code >= 40301 && code <= 40302)
|
|| (code >= 40301 && code <= 40302)
|
||||||
|| (code >= 40401 && code <= 40402)
|
|| (code >= 40401 && code <= 40402)
|
||||||
@@ -412,7 +413,8 @@ public final class WorkflowRunAsyncErrorProfile
|
|||||||
* @return 对外 HTTP 状态
|
* @return 对外 HTTP 状态
|
||||||
*/
|
*/
|
||||||
private int normalizeHttpStatus(int code, int fallback) {
|
private int normalizeHttpStatus(int code, int fallback) {
|
||||||
if (code >= 40011 && code <= 40017) {
|
if ((code >= 40011 && code <= 40017)
|
||||||
|
|| code == 40031) {
|
||||||
return 400;
|
return 400;
|
||||||
}
|
}
|
||||||
if (code >= 40101 && code <= 40103) {
|
if (code >= 40101 && code <= 40103) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
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.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
@@ -44,6 +45,7 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
|
|
||||||
private PublicWorkflowController controller;
|
private PublicWorkflowController controller;
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
private HttpServletRequest request;
|
private HttpServletRequest request;
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
public void setUp() {
|
public void setUp() {
|
||||||
controller = new PublicWorkflowController();
|
controller = new PublicWorkflowController();
|
||||||
chainExecutor = Mockito.mock(ChainExecutor.class);
|
chainExecutor = Mockito.mock(ChainExecutor.class);
|
||||||
|
workflowResumeService = Mockito.mock(WorkflowResumeService.class);
|
||||||
tinyFlowService = Mockito.mock(TinyFlowService.class);
|
tinyFlowService = Mockito.mock(TinyFlowService.class);
|
||||||
WorkflowApiPermissionService permissionService =
|
WorkflowApiPermissionService permissionService =
|
||||||
Mockito.mock(WorkflowApiPermissionService.class);
|
Mockito.mock(WorkflowApiPermissionService.class);
|
||||||
@@ -79,6 +82,10 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
controller,
|
controller,
|
||||||
"chainExecutor",
|
"chainExecutor",
|
||||||
chainExecutor);
|
chainExecutor);
|
||||||
|
ReflectionTestUtils.setField(
|
||||||
|
controller,
|
||||||
|
"workflowResumeService",
|
||||||
|
workflowResumeService);
|
||||||
ReflectionTestUtils.setField(
|
ReflectionTestUtils.setField(
|
||||||
controller,
|
controller,
|
||||||
"tinyFlowService",
|
"tinyFlowService",
|
||||||
@@ -108,10 +115,12 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
public void resumeShouldRejectNonSuspendedExecution() {
|
public void resumeShouldRejectNonSuspendedExecution() {
|
||||||
when(request.getRequestURI()).thenReturn(
|
when(request.getRequestURI()).thenReturn(
|
||||||
"/public-api/workflow/resume");
|
"/public-api/workflow/resume");
|
||||||
when(chainExecutor.resumeAsyncIfSuspended(
|
Mockito.doThrow(new BusinessException(
|
||||||
EXECUTE_ID,
|
409,
|
||||||
Map.of("approved", true)))
|
40901,
|
||||||
.thenReturn(false);
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
|
||||||
|
.when(workflowResumeService)
|
||||||
|
.resume(EXECUTE_ID, Map.of("approved", true));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
controller.resume(
|
controller.resume(
|
||||||
@@ -124,7 +133,7 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
Assert.assertEquals(40901, exception.getErrorCode());
|
Assert.assertEquals(40901, exception.getErrorCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(chainExecutor).resumeAsyncIfSuspended(
|
verify(workflowResumeService).resume(
|
||||||
EXECUTE_ID,
|
EXECUTE_ID,
|
||||||
Map.of("approved", true));
|
Map.of("approved", true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,28 @@ public class WorkflowRunAsyncErrorProfileTest {
|
|||||||
resolution.modelAndView.getModel().get("message"));
|
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 无效和两层权限错误保持可区分。
|
* 验证 API Key 无效和两层权限错误保持可区分。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
@@ -54,6 +55,8 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowResumeService workflowResumeService;
|
||||||
|
@Resource
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||||
|
|
||||||
public UcWorkflowController(WorkflowService service) {
|
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,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,9 +142,12 @@ public class TinyFlowService {
|
|||||||
node.setResult(resolved);
|
node.setResult(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当参数不为空时才覆盖
|
if (nodeState != null
|
||||||
if (chainState.getSuspendForParameters() != null) {
|
&& nodeState.getStatus() == NodeStatus.SUSPEND
|
||||||
|
&& chainState.getSuspendForParameters() != null) {
|
||||||
node.setSuspendForParameters(chainState.getSuspendForParameters());
|
node.setSuspendForParameters(chainState.getSuspendForParameters());
|
||||||
|
} else {
|
||||||
|
node.setSuspendForParameters(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package tech.easyflow.ai.easyagentsflow.service;
|
|||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
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 com.easyagents.flow.core.parser.ChainParser;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -53,6 +55,9 @@ public class WorkflowCheckService {
|
|||||||
private static final String TYPE_END = "endNode";
|
private static final String TYPE_END = "endNode";
|
||||||
private static final String TYPE_LOOP = "loopNode";
|
private static final String TYPE_LOOP = "loopNode";
|
||||||
private static final String TYPE_CONDITION = "conditionNode";
|
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_WORKFLOW = "workflow-node";
|
||||||
private static final String TYPE_PLUGIN = "plugin-node";
|
private static final String TYPE_PLUGIN = "plugin-node";
|
||||||
private static final String TYPE_MAKE_FILE = "make-file";
|
private static final String TYPE_MAKE_FILE = "make-file";
|
||||||
@@ -184,6 +189,8 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
||||||
checkConditionConfigurations(nodes, issues, issueKeys);
|
checkConditionConfigurations(nodes, issues, issueKeys);
|
||||||
|
checkConfirmConfigurations(nodes, issues, issueKeys);
|
||||||
|
checkConfirmOutputReferences(nodes, issues, issueKeys);
|
||||||
|
|
||||||
List<EdgeView> edges = new ArrayList<>();
|
List<EdgeView> edges = new ArrayList<>();
|
||||||
Set<String> edgeIds = new HashSet<>();
|
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 条件规则转换为运行时规则对象。
|
* 将 JSON 条件规则转换为运行时规则对象。
|
||||||
*
|
*
|
||||||
@@ -1674,6 +1963,35 @@ public class WorkflowCheckService {
|
|||||||
return result;
|
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) {
|
private void throwIfFailed(WorkflowCheckResult result) {
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.easyagents.flow.core.chain.ChainStatus;
|
|||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
import com.easyagents.flow.core.chain.NodeStatus;
|
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.ChainStateRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
@@ -148,6 +149,56 @@ public class TinyFlowServiceTest {
|
|||||||
.load(EXECUTE_ID, NODE_ID);
|
.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 执行错误使用面向试运行用户的定位信息。
|
* 验证 JavaScript 执行错误使用面向试运行用户的定位信息。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -525,6 +525,374 @@ public class WorkflowCheckServiceTest {
|
|||||||
assertHasCode(result, "LOOP_PARENT_CYCLE");
|
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
|
@Test
|
||||||
public void testSaveShouldPassForValidDraft() throws Exception {
|
public void testSaveShouldPassForValidDraft() throws Exception {
|
||||||
WorkflowCheckService service = newService(new HashMap<>());
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
@@ -1011,6 +1379,14 @@ public class WorkflowCheckServiceTest {
|
|||||||
return array;
|
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) {
|
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
|
||||||
JSONObject node = new JSONObject();
|
JSONObject node = new JSONObject();
|
||||||
node.put("id", id);
|
node.put("id", id);
|
||||||
@@ -1080,6 +1456,25 @@ public class WorkflowCheckServiceTest {
|
|||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建显式循环节点数据。
|
* 创建显式循环节点数据。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
if (!executeId.value) {
|
||||||
|
onSettled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
try {
|
||||||
.post('/api/v1/pluginItem/testResume', {
|
const res = await api.post('/api/v1/pluginItem/testResume', {
|
||||||
executeId: executeId.value,
|
executeId: executeId.value,
|
||||||
confirmParams: payload?.confirmParams || {},
|
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() {
|
function showWorkflowSteps() {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import nodeNames from './customNode/nodeNames';
|
|||||||
import {
|
import {
|
||||||
createInitialWorkflowData,
|
createInitialWorkflowData,
|
||||||
isWorkflowDataEmpty,
|
isWorkflowDataEmpty,
|
||||||
normalizeWorkflowStartNodes,
|
normalizeWorkflowNodes,
|
||||||
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
saveLoading.value = true;
|
saveLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
|
||||||
const savedContentSignature = createWorkflowContentSignature(content);
|
const savedContentSignature = createWorkflowContentSignature(content);
|
||||||
const res = await api.post('/api/v1/workflow/update', {
|
const res = await api.post('/api/v1/workflow/update', {
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
@@ -475,11 +475,11 @@ async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
|
|||||||
: {};
|
: {};
|
||||||
const serverContent = isWorkflowDataEmpty(parsedContent)
|
const serverContent = isWorkflowDataEmpty(parsedContent)
|
||||||
? createInitialWorkflowData()
|
? createInitialWorkflowData()
|
||||||
: normalizeWorkflowStartNodes(parsedContent);
|
: normalizeWorkflowNodes(parsedContent);
|
||||||
serverContentSignature = createWorkflowContentSignature(serverContent);
|
serverContentSignature = createWorkflowContentSignature(serverContent);
|
||||||
const draft = readWorkflowDraft(workflowId, serverContent);
|
const draft = readWorkflowDraft(workflowId, serverContent);
|
||||||
tinyFlowData.value = draft
|
tinyFlowData.value = draft
|
||||||
? normalizeWorkflowStartNodes(draft.content as Record<string, any>)
|
? normalizeWorkflowNodes(draft.content as Record<string, any>)
|
||||||
: serverContent;
|
: serverContent;
|
||||||
lastObservedWorkflowContent = tinyFlowData.value;
|
lastObservedWorkflowContent = tinyFlowData.value;
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ function persistPendingWorkflowDraft() {
|
|||||||
draftWriteTimer = undefined;
|
draftWriteTimer = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = normalizeWorkflowStartNodes(pendingDraftContent);
|
const content = normalizeWorkflowNodes(pendingDraftContent);
|
||||||
lastObservedWorkflowContent = content;
|
lastObservedWorkflowContent = content;
|
||||||
pendingDraftContent = null;
|
pendingDraftContent = null;
|
||||||
draftWriteTimer = undefined;
|
draftWriteTimer = undefined;
|
||||||
@@ -531,7 +531,7 @@ function flushWorkflowDraft() {
|
|||||||
function captureCurrentWorkflowDraft() {
|
function captureCurrentWorkflowDraft() {
|
||||||
const content = tinyflowRef.value?.getData();
|
const content = tinyflowRef.value?.getData();
|
||||||
if (content) {
|
if (content) {
|
||||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
lastObservedWorkflowContent = normalizeWorkflowNodes(content);
|
||||||
pendingDraftContent = lastObservedWorkflowContent;
|
pendingDraftContent = lastObservedWorkflowContent;
|
||||||
}
|
}
|
||||||
flushWorkflowDraft();
|
flushWorkflowDraft();
|
||||||
@@ -554,7 +554,7 @@ function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
|
|||||||
clearPendingWorkflowDraft();
|
clearPendingWorkflowDraft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedContent = normalizeWorkflowStartNodes(currentContent);
|
const normalizedContent = normalizeWorkflowNodes(currentContent);
|
||||||
lastObservedWorkflowContent = normalizedContent;
|
lastObservedWorkflowContent = normalizedContent;
|
||||||
if (
|
if (
|
||||||
createWorkflowContentSignature(normalizedContent) === savedContentSignature
|
createWorkflowContentSignature(normalizedContent) === savedContentSignature
|
||||||
@@ -594,7 +594,7 @@ async function runCheck(
|
|||||||
stage: WorkflowCheckStage,
|
stage: WorkflowCheckStage,
|
||||||
silentPass: boolean = false,
|
silentPass: boolean = false,
|
||||||
) {
|
) {
|
||||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
const content = normalizeWorkflowNodes(tinyflowRef.value?.getData());
|
||||||
if (!content) {
|
if (!content) {
|
||||||
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
||||||
return false;
|
return false;
|
||||||
@@ -756,8 +756,13 @@ async function runIndependently(node: any) {
|
|||||||
singleNode.value = node;
|
singleNode.value = node;
|
||||||
singleRunVisible.value = true;
|
singleRunVisible.value = true;
|
||||||
}
|
}
|
||||||
function resumeChain(data: any) {
|
async function resumeChain(data: any, onSettled: (accepted: boolean) => void) {
|
||||||
workflowForm.value?.resume(data);
|
try {
|
||||||
|
const accepted = await workflowForm.value?.resume(data);
|
||||||
|
onSettled(accepted === true);
|
||||||
|
} catch {
|
||||||
|
onSettled(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function handleChoose(nodeName: string, value: any) {
|
function handleChoose(nodeName: string, value: any) {
|
||||||
if (nodeName === nodeNames.workflowNode) {
|
if (nodeName === nodeNames.workflowNode) {
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ function buildResumeRequestExample() {
|
|||||||
{
|
{
|
||||||
executeId: '执行ID',
|
executeId: '执行ID',
|
||||||
confirmParams: {
|
confirmParams: {
|
||||||
confirm: true,
|
'selection__confirm-node-1': '审议类',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -936,6 +936,10 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(buildResumeRequestExample());
|
lines.push(buildResumeRequestExample());
|
||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
'`confirmParams` 的键请使用暂停节点 `suspendForParameters[].name` 返回的运行参数名;确认节点固定为 `selection__<节点 ID>`。单选传一个 `options[].value` 字符串,多选传由这些值组成的字符串数组;确认节点的 `label` 与 `value` 都是配置的选项内容。',
|
||||||
|
);
|
||||||
|
lines.push(``);
|
||||||
lines.push(`### 响应`);
|
lines.push(`### 响应`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push('```json');
|
lines.push('```json');
|
||||||
@@ -971,6 +975,9 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(
|
lines.push(
|
||||||
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
|
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
|
||||||
);
|
);
|
||||||
|
lines.push(
|
||||||
|
`| 400 | 40031 | 确认节点恢复参数缺失、类型错误或包含未配置选项 |`,
|
||||||
|
);
|
||||||
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
|
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
|
||||||
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
|
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
|
||||||
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);
|
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -161,7 +161,7 @@ const extraFormRef = ref<FormInstance>();
|
|||||||
const waitingConfirmation = ref<Record<string, any>>();
|
const waitingConfirmation = ref<Record<string, any>>();
|
||||||
const confirmValues = ref<Record<string, any>>({});
|
const confirmValues = ref<Record<string, any>>({});
|
||||||
const confirmFormRef = ref<FormInstance>();
|
const confirmFormRef = ref<FormInstance>();
|
||||||
const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
|
const confirmSubmittingAction = ref<'' | 'continue'>('');
|
||||||
const confirmError = ref('');
|
const confirmError = ref('');
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
@@ -252,21 +252,9 @@ const hiddenParameterSummaryCount = computed(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const confirmParameters = computed(() => {
|
const confirmParameters = computed(() => {
|
||||||
const parameters = Array.isArray(waitingConfirmation.value?.parameters)
|
return Array.isArray(waitingConfirmation.value?.parameters)
|
||||||
? 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(
|
const composerDisabled = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -1012,33 +1000,25 @@ function finalizeLiveExecutionSteps(
|
|||||||
function initializeConfirmValues(parameters: unknown) {
|
function initializeConfirmValues(parameters: unknown) {
|
||||||
const values: Record<string, any> = {};
|
const values: Record<string, any> = {};
|
||||||
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
||||||
if (parameter.formType === 'confirm') {
|
values[parameter.name] =
|
||||||
continue;
|
parameter.formType === 'checkbox' ? [] : (parameter.defaultValue ?? '');
|
||||||
}
|
|
||||||
values[parameter.name] = parameter.defaultValue ?? '';
|
|
||||||
}
|
}
|
||||||
confirmValues.value = values;
|
confirmValues.value = values;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeExecution(confirmed: boolean) {
|
async function resumeExecution() {
|
||||||
if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
|
if (!executeId.value || confirmSubmittingAction.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
|
confirmSubmittingAction.value = 'continue';
|
||||||
confirmError.value = '';
|
confirmError.value = '';
|
||||||
try {
|
try {
|
||||||
if (
|
if (!(await confirmFormRef.value?.validate().catch(() => false))) {
|
||||||
confirmed &&
|
|
||||||
!(await confirmFormRef.value?.validate().catch(() => false))
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.post(workflowChatEndpoint('resume'), {
|
await api.post(workflowChatEndpoint('resume'), {
|
||||||
executeId: executeId.value,
|
executeId: executeId.value,
|
||||||
confirmParams: {
|
confirmParams: { ...confirmValues.value },
|
||||||
[confirmKey.value]: confirmed ? 'yes' : 'no',
|
|
||||||
...(confirmed ? confirmValues.value : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
executionState.value = 'running';
|
executionState.value = 'running';
|
||||||
@@ -1523,20 +1503,13 @@ function executionTraceText(
|
|||||||
{{ confirmError }}
|
{{ confirmError }}
|
||||||
</p>
|
</p>
|
||||||
<div class="workflow-chat__form-actions">
|
<div class="workflow-chat__form-actions">
|
||||||
<ElButton
|
|
||||||
:loading="confirmSubmittingAction === 'reject'"
|
|
||||||
:disabled="Boolean(confirmSubmittingAction)"
|
|
||||||
@click="resumeExecution(false)"
|
|
||||||
>
|
|
||||||
拒绝
|
|
||||||
</ElButton>
|
|
||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="confirmSubmittingAction === 'confirm'"
|
:loading="confirmSubmittingAction === 'continue'"
|
||||||
:disabled="Boolean(confirmSubmittingAction)"
|
:disabled="Boolean(confirmSubmittingAction)"
|
||||||
@click="resumeExecution(true)"
|
@click="resumeExecution"
|
||||||
>
|
>
|
||||||
确认
|
继续
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -87,14 +87,22 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
function resume(data: any) {
|
async function resume(data: any) {
|
||||||
data.executeId = executeId.value;
|
data.executeId = executeId.value;
|
||||||
submitLoading.value = true;
|
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) {
|
if (res.errorCode === 0) {
|
||||||
|
accepted = true;
|
||||||
startPolling(executeId.value);
|
startPolling(executeId.value);
|
||||||
}
|
}
|
||||||
});
|
return accepted;
|
||||||
|
} finally {
|
||||||
|
if (!accepted) {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function submitV2() {
|
function submitV2() {
|
||||||
runForm.value?.validate((valid) => {
|
runForm.value?.validate((valid) => {
|
||||||
@@ -132,7 +140,7 @@ function startPolling(executeId: any) {
|
|||||||
if (pollingActive) return;
|
if (pollingActive) return;
|
||||||
pollingActive = true;
|
pollingActive = true;
|
||||||
pollingGeneration += 1;
|
pollingGeneration += 1;
|
||||||
schedulePolling(executeId, pollingGeneration);
|
void executePolling(executeId, pollingGeneration);
|
||||||
}
|
}
|
||||||
function schedulePolling(executeId: any, generation: number) {
|
function schedulePolling(executeId: any, generation: number) {
|
||||||
timer.value = setTimeout(() => {
|
timer.value = setTimeout(() => {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ function isWideItem(item: any) {
|
|||||||
return item.formType === 'textarea' || contentType === 'image';
|
return item.formType === 'textarea' || contentType === 'image';
|
||||||
}
|
}
|
||||||
function getCheckboxOptions(item: any) {
|
function getCheckboxOptions(item: any) {
|
||||||
|
if (Array.isArray(item.options)) {
|
||||||
|
return item.options.map((option: any) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (item.enums) {
|
if (item.enums) {
|
||||||
return (
|
return (
|
||||||
item.enums?.map((option: any) => ({
|
item.enums?.map((option: any) => ({
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ import {
|
|||||||
ElCollapse,
|
ElCollapse,
|
||||||
ElCollapseItem,
|
ElCollapseItem,
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
|
||||||
ElIcon,
|
ElIcon,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
import { $t } from '#/locales';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
|
|
||||||
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
|
|
||||||
|
|
||||||
export interface WorkflowStepsProps {
|
export interface WorkflowStepsProps {
|
||||||
workflowId: any;
|
workflowId: any;
|
||||||
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
|
|||||||
expandAll?: boolean;
|
expandAll?: boolean;
|
||||||
}
|
}
|
||||||
const props = defineProps<WorkflowStepsProps>();
|
const props = defineProps<WorkflowStepsProps>();
|
||||||
const emit = defineEmits(['resume']);
|
const emit = defineEmits<{
|
||||||
|
resume: [payload: any, onSettled: (accepted: boolean) => void];
|
||||||
|
}>();
|
||||||
const nodes = ref<any[]>([]);
|
const nodes = ref<any[]>([]);
|
||||||
const nodeStatusMap = ref<Record<string, any>>({});
|
const nodeStatusMap = ref<Record<string, any>>({});
|
||||||
const activeNames = ref<string[]>([]);
|
const activeNames = ref<string[]>([]);
|
||||||
@@ -90,7 +89,7 @@ watch(
|
|||||||
}
|
}
|
||||||
const currentNodes = newVal.nodes || {};
|
const currentNodes = newVal.nodes || {};
|
||||||
chainErrMsg.value = newVal.status === 21 ? newVal.message : '';
|
chainErrMsg.value = newVal.status === 21 ? newVal.message : '';
|
||||||
if (![20, 21].includes(newVal.status)) {
|
if (Number(newVal.status) !== 5) {
|
||||||
confirmBtnLoading.value = false;
|
confirmBtnLoading.value = false;
|
||||||
}
|
}
|
||||||
let autoExpandNodeId: string | undefined;
|
let autoExpandNodeId: string | undefined;
|
||||||
@@ -106,6 +105,10 @@ watch(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nodeStatusMap.value[nodeId] = currentNodeState;
|
nodeStatusMap.value[nodeId] = currentNodeState;
|
||||||
|
if (Number(currentStatus) === 5 && Number(previousStatus) !== 5) {
|
||||||
|
initializeConfirmParams(currentNodeState?.suspendForParameters);
|
||||||
|
confirmBtnLoading.value = false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!userControlledExpansion.value &&
|
!userControlledExpansion.value &&
|
||||||
!props.expandAll &&
|
!props.expandAll &&
|
||||||
@@ -128,6 +131,7 @@ watch(
|
|||||||
() => props.initSignal,
|
() => props.initSignal,
|
||||||
() => {
|
() => {
|
||||||
nodeStatusMap.value = {};
|
nodeStatusMap.value = {};
|
||||||
|
confirmParams.value = {};
|
||||||
confirmBtnLoading.value = false;
|
confirmBtnLoading.value = false;
|
||||||
chainErrMsg.value = '';
|
chainErrMsg.value = '';
|
||||||
userControlledExpansion.value = false;
|
userControlledExpansion.value = false;
|
||||||
@@ -164,8 +168,13 @@ const setFormRef = (el: any, key: string) => {
|
|||||||
formRefs.value[key] = el as FormInstance;
|
formRefs.value[key] = el as FormInstance;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
function getSelectMode(ops: any) {
|
function initializeConfirmParams(parameters: any) {
|
||||||
return ops.formType || 'radio';
|
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) {
|
function handleConfirm(node: any) {
|
||||||
const nodeKey = node.key;
|
const nodeKey = node.key;
|
||||||
@@ -176,17 +185,27 @@ function handleConfirm(node: any) {
|
|||||||
console.warn(`Form instance for ${nodeKey} not found`);
|
console.warn(`Form instance for ${nodeKey} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmKey = node.suspendForParameters[0].name;
|
|
||||||
form.validate((valid) => {
|
form.validate((valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const value = {
|
const value = {
|
||||||
confirmParams: {
|
confirmParams: { ...confirmParams.value },
|
||||||
[confirmKey]: 'yes',
|
|
||||||
...confirmParams.value,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
confirmBtnLoading.value = true;
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="isExpandedNode(node.key)">
|
<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">
|
<div class="mb-2 text-[16px] font-bold">
|
||||||
{{ node.original.data.message }}
|
{{ node.original.data.message }}
|
||||||
</div>
|
</div>
|
||||||
@@ -243,50 +267,24 @@ function handleConfirm(node: any) {
|
|||||||
label-position="top"
|
label-position="top"
|
||||||
:model="confirmParams"
|
:model="confirmParams"
|
||||||
>
|
>
|
||||||
<template
|
<WorkflowFormItem
|
||||||
v-for="(ops, idx) in node.suspendForParameters"
|
:parameters="node.suspendForParameters || []"
|
||||||
:key="idx"
|
: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'">
|
<ElButton
|
||||||
<div class="blue-bar"> </div>
|
:disabled="confirmBtnLoading"
|
||||||
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
|
:loading="confirmBtnLoading"
|
||||||
</div>
|
type="primary"
|
||||||
<div
|
@click="handleConfirm(node)"
|
||||||
class="description-container"
|
|
||||||
v-if="ops.formType !== 'confirm'"
|
|
||||||
>
|
>
|
||||||
{{ ops.formDescription }}
|
继续
|
||||||
</div>
|
</ElButton>
|
||||||
<ElFormItem
|
</div>
|
||||||
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>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -318,26 +316,4 @@ function handleConfirm(node: any) {
|
|||||||
transform: rotate(360deg);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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() {
|
function mountWorkflowSteps() {
|
||||||
return mount(WorkflowSteps, {
|
return mount(WorkflowSteps, {
|
||||||
props: {
|
props: {
|
||||||
@@ -35,9 +59,8 @@ function mountWorkflowSteps() {
|
|||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
ConfirmItem: true,
|
|
||||||
ConfirmItemMulti: true,
|
|
||||||
ShowJson: true,
|
ShowJson: true,
|
||||||
|
WorkflowFormItem: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -159,4 +182,145 @@ describe('workflowSteps', () => {
|
|||||||
'JavaScript 语法错误(第 2 行,第 3 列):Unexpected token',
|
'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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,4 +46,33 @@ describe('workflow public form item', () => {
|
|||||||
expect(input.props('allowResourcePicker')).toBe(false);
|
expect(input.props('allowResourcePicker')).toBe(false);
|
||||||
expect(input.props('uploadUrl')).toBe('/api/v1/workflowChat/public/upload');
|
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: '审议类' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,10 +64,16 @@ function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
|||||||
*/
|
*/
|
||||||
export function resolveWorkflowParameterLabel(parameter: any) {
|
export function resolveWorkflowParameterLabel(parameter: any) {
|
||||||
const name = String(parameter?.name || '').trim();
|
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) {
|
if (!isSystemParameter(parameter, name) && name) {
|
||||||
return configuredParameterName(name);
|
return configuredParameterName(name);
|
||||||
}
|
}
|
||||||
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
|
||||||
const displayName = replaceDefaultParameterLabel(
|
const displayName = replaceDefaultParameterLabel(
|
||||||
parameter?.displayName,
|
parameter?.displayName,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ export class Tinyflow {
|
|||||||
if (!flow) {
|
if (!flow) {
|
||||||
return null;
|
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 }) {
|
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
variant = 'default',
|
variant = 'default',
|
||||||
showSelectedType = true,
|
showSelectedType = true,
|
||||||
selectedType,
|
selectedType,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
items: SelectItem[],
|
items: SelectItem[],
|
||||||
@@ -26,46 +28,55 @@
|
|||||||
variant?: 'default' | 'reference' | 'model'
|
variant?: 'default' | 'reference' | 'model'
|
||||||
showSelectedType?: boolean
|
showSelectedType?: boolean
|
||||||
selectedType?: string
|
selectedType?: string
|
||||||
|
disabled?: boolean
|
||||||
|
disabledReason?: string
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let activeItemsState = $derived.by(() => {
|
let activeItemsState = $derived.by(() => {
|
||||||
const resultItems: SelectItem[] = [];
|
const flattenedItems: SelectItem[] = [];
|
||||||
const fillResult = (items: SelectItem[]) => {
|
const flatten = (sourceItems: SelectItem[]) => {
|
||||||
for (let item of items) {
|
for (const item of sourceItems) {
|
||||||
if (value.length > 0) {
|
flattenedItems.push(item);
|
||||||
if (value.includes(item.value)) {
|
|
||||||
resultItems.push(item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (defaultValue.includes(item.value)) {
|
|
||||||
resultItems.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
fillResult(item.children);
|
flatten(item.children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fillResult(items);
|
flatten(items);
|
||||||
return resultItems;
|
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 triggerObject: any = $state();
|
||||||
|
let triggerButton: HTMLButtonElement | undefined = $state();
|
||||||
let hoveredItem: SelectItem | null = $state(null);
|
let hoveredItem: SelectItem | null = $state(null);
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
|
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
triggerObject?.hide();
|
triggerObject?.hide();
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
hoveredItem = null;
|
hoveredItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || !isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
closeMenu();
|
||||||
|
triggerButton?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function handlerOnSelect(item: SelectItem) {
|
function handlerOnSelect(item: SelectItem) {
|
||||||
if (item.selectable !== false) {
|
if (item.selectable !== false) {
|
||||||
onSelect?.(item);
|
onSelect?.(item);
|
||||||
closeMenu();
|
if (!multiple) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (variant === 'reference') {
|
if (variant === 'reference') {
|
||||||
hoveredItem = item;
|
hoveredItem = item;
|
||||||
@@ -89,8 +100,23 @@
|
|||||||
|
|
||||||
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
|
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
|
||||||
{#each items as item}
|
{#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>
|
<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>
|
</button>
|
||||||
{#if item.children && item.children.length > 0}
|
{#if item.children && item.children.length > 0}
|
||||||
<div class="tf-select-default-children">
|
<div class="tf-select-default-children">
|
||||||
@@ -205,7 +231,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div {...rest} class="tf-select {rest['class']}">
|
<div {...rest} class="tf-select {rest['class']}" onkeydown={handleKeydown}>
|
||||||
<FloatingTrigger
|
<FloatingTrigger
|
||||||
bind:this={triggerObject}
|
bind:this={triggerObject}
|
||||||
onShow={() => isOpen = true}
|
onShow={() => isOpen = true}
|
||||||
@@ -213,8 +239,26 @@
|
|||||||
syncWidth={true}
|
syncWidth={true}
|
||||||
syncWidthMode={variant === 'default' ? 'equal' : 'min'}
|
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">
|
<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}`)}
|
{#each activeItemsState as item, index (`${index}_${item.value}`)}
|
||||||
{#if !multiple}
|
{#if !multiple}
|
||||||
{#if index === 0}
|
{#if index === 0}
|
||||||
@@ -238,35 +282,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="tf-select-input-placeholder">
|
<div class="tf-select-input-placeholder">
|
||||||
{placeholder}
|
{placeholder}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="tf-select-input-arrow">
|
<div class="tf-select-input-arrow">
|
||||||
{#if variant === 'reference'}
|
{#if variant === 'reference'}
|
||||||
@@ -284,7 +306,11 @@
|
|||||||
|
|
||||||
{#snippet floating()}
|
{#snippet floating()}
|
||||||
{#if variant === 'default'}
|
{#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)}
|
{@render renderDefaultItems(items)}
|
||||||
</div>
|
</div>
|
||||||
{:else if variant === 'model'}
|
{:else if variant === 'model'}
|
||||||
@@ -379,6 +405,46 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: var(--tf-bg-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 {
|
.tf-select-default-children {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
allowCopy = true,
|
allowCopy = true,
|
||||||
allowDelete = true,
|
allowDelete = true,
|
||||||
allowSetting = true,
|
allowSetting = true,
|
||||||
|
allowAsyncSetting = true,
|
||||||
allowSettingOfCondition = true,
|
allowSettingOfCondition = true,
|
||||||
showSourceHandle = true,
|
showSourceHandle = true,
|
||||||
showTargetHandle = true,
|
showTargetHandle = true,
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
allowCopy?: boolean,
|
allowCopy?: boolean,
|
||||||
allowDelete?: boolean,
|
allowDelete?: boolean,
|
||||||
allowSetting?: boolean,
|
allowSetting?: boolean,
|
||||||
|
allowAsyncSetting?: boolean,
|
||||||
allowSettingOfCondition?: boolean,
|
allowSettingOfCondition?: boolean,
|
||||||
showSourceHandle?: boolean,
|
showSourceHandle?: boolean,
|
||||||
showTargetHandle?: boolean,
|
showTargetHandle?: boolean,
|
||||||
@@ -268,15 +270,17 @@
|
|||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="input-item-inline">
|
{#if allowAsyncSetting}
|
||||||
<span>异步执行:</span>
|
<label class="input-item-inline">
|
||||||
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
|
<span>异步执行:</span>
|
||||||
const value = (event.target as any).checked;
|
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
|
||||||
updateNodeData(currentNodeId,{
|
const value = (event.target as any).checked;
|
||||||
async: value
|
updateNodeData(currentNodeId,{
|
||||||
})
|
async: value
|
||||||
}} />
|
})
|
||||||
</label>
|
}} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<label class="input-item-inline">
|
<label class="input-item-inline">
|
||||||
<span>循环执行:</span>
|
<span>循环执行:</span>
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
position,
|
position,
|
||||||
dataKeyName,
|
dataKeyName,
|
||||||
placeholder = '请输入参数值',
|
placeholder = '请输入参数值',
|
||||||
|
readOnly = false,
|
||||||
onParametersChange,
|
onParametersChange,
|
||||||
}: {
|
}: {
|
||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
position: number[],
|
position: number[],
|
||||||
dataKeyName: string,
|
dataKeyName: string,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
readOnly?: boolean,
|
||||||
onParametersChange?: ParameterChangeHandler,
|
onParametersChange?: ParameterChangeHandler,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
let triggerObject: any;
|
let triggerObject: any = $state();
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
const previousParameters = deepClone(
|
const previousParameters = deepClone(
|
||||||
@@ -180,16 +182,26 @@
|
|||||||
{#if position.length > 1}
|
{#if position.length > 1}
|
||||||
<span class="output-branch-marker"></span>
|
<span class="output-branch-marker"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
{#if readOnly}
|
||||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
<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>
|
</div>
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
|
{#if readOnly}
|
||||||
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
|
<span class="readonly-value readonly-value--type" title={currentParameter.dataType || 'String'}>
|
||||||
disabled={currentParameter.dataTypeDisabled === true}
|
{currentParameter.dataType || 'String'}
|
||||||
onSelect={updateDataType} />
|
</span>
|
||||||
{#if (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
|
{: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}>
|
<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"
|
<svg style="transform: scaleY(-1)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||||
fill="currentColor">
|
fill="currentColor">
|
||||||
@@ -199,37 +211,41 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="input-item">
|
{#if !readOnly}
|
||||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
<div class="input-item">
|
||||||
<MenuButton />
|
{#if currentParameter.settingsDisabled !== true}
|
||||||
{#snippet floating()}
|
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||||
<div class="input-more-setting">
|
<MenuButton />
|
||||||
<div class="input-more-item">
|
{#snippet floating()}
|
||||||
默认值:
|
<div class="input-more-setting">
|
||||||
<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}
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
<Button onclick={handleDelete}>删除</Button>
|
默认值:
|
||||||
|
<Textarea rows={1} style="width: 100%;"
|
||||||
|
value={currentParameter.defaultValue||''}
|
||||||
|
onchange={(event)=>{
|
||||||
|
updateByEvent( 'defaultValue', event)
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="input-more-item">
|
||||||
</div>
|
参数描述:
|
||||||
{/snippet}
|
<Textarea rows={3} style="width: 100%;"
|
||||||
</FloatingTrigger>
|
value={currentParameter.description||''}
|
||||||
</div>
|
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">
|
<style lang="less">
|
||||||
@@ -238,6 +254,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-name-shell {
|
.output-name-shell {
|
||||||
@@ -261,6 +278,22 @@
|
|||||||
opacity: 0.9;
|
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 {
|
.input-more-setting {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
noneParameterText = '无输出参数',
|
noneParameterText = '无输出参数',
|
||||||
dataKeyName = 'outputDefs',
|
dataKeyName = 'outputDefs',
|
||||||
placeholder = '请输入参数名称',
|
placeholder = '请输入参数名称',
|
||||||
|
readOnly = false,
|
||||||
onParametersChange,
|
onParametersChange,
|
||||||
}: {
|
}: {
|
||||||
noneParameterText?: string;
|
noneParameterText?: string;
|
||||||
dataKeyName?: string;
|
dataKeyName?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
onParametersChange?: ParameterChangeHandler;
|
onParametersChange?: ParameterChangeHandler;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
position={[...position, index]}
|
position={[...position, index]}
|
||||||
{dataKeyName}
|
{dataKeyName}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
{readOnly}
|
||||||
{onParametersChange}
|
{onParametersChange}
|
||||||
/>
|
/>
|
||||||
{#if param.children}
|
{#if param.children}
|
||||||
@@ -44,11 +47,13 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|
||||||
<div class="input-container">
|
<div class="input-container" class:input-container--readonly={readOnly}>
|
||||||
{#if (parameters.length !== 0)}
|
{#if (parameters.length !== 0)}
|
||||||
<div class="input-header">参数名称</div>
|
<div class="input-header">参数名称</div>
|
||||||
<div class="input-header">参数类型</div>
|
<div class="input-header">参数类型</div>
|
||||||
<div class="input-header"></div>
|
{#if !readOnly}
|
||||||
|
<div class="input-header"></div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{@render parameterList(parameters || [], [])}
|
{@render parameterList(parameters || [], [])}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +70,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&--readonly {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
column-gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.none-params {
|
.none-params {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: var(--tf-bg-muted);
|
background: var(--tf-bg-muted);
|
||||||
@@ -82,6 +92,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--tf-text-secondary);
|
color: var(--tf-text-secondary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,461 @@
|
|||||||
<svelte:options customElement={{ props: {} }} />
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import {useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
import {onMount, untrack} from 'svelte';
|
||||||
import {Button, Heading} from '../base';
|
|
||||||
import {Textarea} from '../base/index.js';
|
import type {TinyflowNodeData} from '#types';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {Heading, Input, Textarea} from '../base';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import type {Parameter, TinyflowNodeData} from '#types';
|
import {
|
||||||
import {deepEqual} from '#components/utils/deepEqual';
|
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 }: {
|
const currentNodeId = getCurrentNodeId();
|
||||||
data: TinyflowNodeData,
|
const {updateNodeData} = useSvelteFlow();
|
||||||
[key: string]: any
|
const store = useTinyflowStore();
|
||||||
} = $props();
|
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();
|
function normalizeOptions(value: unknown) {
|
||||||
const { addParameter } = useAddParameter();
|
return Array.isArray(value)
|
||||||
const { updateNodeData } = useSvelteFlow();
|
? value.map((option) => typeof option === 'string' ? option : '')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function sameOptions(left: string[], right: string[]) {
|
||||||
if (data.confirms) {
|
return left.length === right.length
|
||||||
const outputDefs = data.confirms.map((confirm: Parameter) => {
|
&& left.every((option, index) => option === right[index]);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 判断 outputDefs 与 data.outputDefs 是否完全一致
|
$effect(() => {
|
||||||
// 如果不判断,则会造成死循环更新
|
const nextMessage = String(data.message || '');
|
||||||
if (!deepEqual(outputDefs, data.outputDefs)) {
|
const nextOptions = normalizeOptions(data.options);
|
||||||
updateNodeData(currentNodeId, () => {
|
untrack(() => {
|
||||||
return {
|
if (!messageDirty && messageDraft !== nextMessage) {
|
||||||
outputDefs
|
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>
|
</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()}
|
<section>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<Heading level={3} mb="8px">交互选项</Heading>
|
||||||
<path
|
<fieldset class="choice-mode">
|
||||||
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>
|
<legend>选择方式</legend>
|
||||||
</svg>
|
<label>
|
||||||
{/snippet}
|
<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">
|
<div class="option-list nowheel">
|
||||||
<Heading level={3}>确认数据</Heading>
|
{#each options as option, optionIndex (optionIndex)}
|
||||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
<div class="option-item">
|
||||||
addParameter(currentNodeId, 'confirms')
|
<div
|
||||||
}}>
|
class="option-row"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
role="group"
|
||||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
aria-label={`选项 ${optionIndex + 1}`}
|
||||||
</svg>
|
ondragover={(event: DragEvent) => event.preventDefault()}
|
||||||
</Button>
|
ondrop={(event: DragEvent) => {
|
||||||
</div>
|
event.preventDefault();
|
||||||
<ConfirmParameterList dataKeyName="confirms" noneParameterText="无确认数据" />
|
if (draggedOptionIndex !== null) {
|
||||||
|
moveOption(draggedOptionIndex, optionIndex);
|
||||||
<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
|
|
||||||
}
|
}
|
||||||
})
|
draggedOptionIndex = null;
|
||||||
}} value={String(data.message || '')} />
|
}}
|
||||||
</div>
|
>
|
||||||
|
<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">
|
<section>
|
||||||
<Heading level={3} mt="10px">输出参数</Heading>
|
<Heading level={3} mb="10px">输出参数</Heading>
|
||||||
</div>
|
<OutputDefList />
|
||||||
<OutputDefList placeholder="" />
|
</section>
|
||||||
|
</div>
|
||||||
</NodeWrapper>
|
</NodeWrapper>
|
||||||
|
|
||||||
<style>
|
<style lang="less">
|
||||||
.heading {
|
.confirm-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 10px;
|
flex-direction: column;
|
||||||
}
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-title {
|
section {
|
||||||
font-size: 12px;
|
min-width: 0;
|
||||||
color: var(--tf-text-muted);
|
}
|
||||||
margin-bottom: 4px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
.setting-title,
|
||||||
display: flex;
|
.choice-mode {
|
||||||
align-items: center;
|
font-size: 12px;
|
||||||
justify-content: space-between;
|
line-height: 18px;
|
||||||
margin-bottom: 10px;
|
color: var(--tf-text-secondary);
|
||||||
gap: 10px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
'请输入选项内容',
|
||||||
|
'选项内容不能重复',
|
||||||
|
'选项内容不能重复',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Node } from '@xyflow/svelte';
|
|||||||
import type { TinyflowOptions } from '#types';
|
import type { TinyflowOptions } from '#types';
|
||||||
|
|
||||||
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
|
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
|
||||||
|
import { buildConfirmOutputDefs } from './confirmNode';
|
||||||
|
|
||||||
export type NodePaletteItem = {
|
export type NodePaletteItem = {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -101,6 +102,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
|
|||||||
sortNo: 900,
|
sortNo: 900,
|
||||||
description: '确认继续或选择内容',
|
description: '确认继续或选择内容',
|
||||||
category: '输入输出',
|
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>',
|
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>',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const createStore = () => {
|
|||||||
let edgesInternal = $state.raw([] as Edge[]);
|
let edgesInternal = $state.raw([] as Edge[]);
|
||||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||||
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
||||||
|
const pendingEditFlushers = new Set<() => void>();
|
||||||
|
|
||||||
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
||||||
|
|
||||||
@@ -39,6 +40,13 @@ export const createStore = () => {
|
|||||||
setViewport: (v: Viewport) => {
|
setViewport: (v: Viewport) => {
|
||||||
viewport = v;
|
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),
|
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
||||||
addNode: (node: Node) => {
|
addNode: (node: Node) => {
|
||||||
|
|||||||
@@ -120,6 +120,12 @@
|
|||||||
min-width: 296px;
|
min-width: 296px;
|
||||||
max-width: 296px;
|
max-width: 296px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
width: 360px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.svelte-flow__attribution a {
|
.svelte-flow__attribution a {
|
||||||
|
|||||||
@@ -85,4 +85,35 @@ describe('tinyflow store isolation', () => {
|
|||||||
first.destroy();
|
first.destroy();
|
||||||
second.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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type SelectItem = {
|
|||||||
itemTypeLabel?: string;
|
itemTypeLabel?: string;
|
||||||
isCollection?: boolean;
|
isCollection?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
disabledReason?: string;
|
||||||
children?: SelectItem[];
|
children?: SelectItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,9 +158,11 @@ export type Parameter = {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
deleteDisabled?: boolean;
|
deleteDisabled?: boolean;
|
||||||
|
settingsDisabled?: boolean;
|
||||||
addChildDisabled?: boolean;
|
addChildDisabled?: boolean;
|
||||||
children?: Parameter[];
|
children?: Parameter[];
|
||||||
enums?: string[];
|
enums?: string[];
|
||||||
|
options?: ParameterOption[];
|
||||||
formType?: string;
|
formType?: string;
|
||||||
formLabel?: string;
|
formLabel?: string;
|
||||||
formDescription?: string;
|
formDescription?: string;
|
||||||
@@ -174,6 +177,11 @@ export type Parameter = {
|
|||||||
flattenAggregation?: boolean;
|
flattenAggregation?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ParameterOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ParameterChangeHandler = (
|
export type ParameterChangeHandler = (
|
||||||
previousParameters: Parameter[],
|
previousParameters: Parameter[],
|
||||||
nextParameters: Parameter[],
|
nextParameters: Parameter[],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
FIELD_BINDING_META_KEY,
|
FIELD_BINDING_META_KEY,
|
||||||
isStartFormFieldKeyAvailable,
|
isStartFormFieldKeyAvailable,
|
||||||
normalizeStartNodeData,
|
normalizeStartNodeData,
|
||||||
normalizeWorkflowStartNodes,
|
normalizeWorkflowNodes,
|
||||||
renameStartFieldReferencesInNodes,
|
renameStartFieldReferencesInNodes,
|
||||||
removeStartFormField,
|
removeStartFormField,
|
||||||
syncManagedParametersForFields,
|
syncManagedParametersForFields,
|
||||||
@@ -1395,7 +1395,7 @@ describe('workflow node fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes only start nodes that already contain fixed user_input', () => {
|
it('normalizes only start nodes that already contain fixed user_input', () => {
|
||||||
const normalizedWorkflow = normalizeWorkflowStartNodes({
|
const normalizedWorkflow = normalizeWorkflowNodes({
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: 'start_new',
|
id: 'start_new',
|
||||||
@@ -1430,4 +1430,63 @@ describe('workflow node fields', () => {
|
|||||||
).toBe('user_input');
|
).toBe('user_input');
|
||||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
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' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
buildLoopReferenceParameters,
|
buildLoopReferenceParameters,
|
||||||
buildLoopScopeParameters,
|
buildLoopScopeParameters,
|
||||||
} from './loopScope';
|
} from './loopScope';
|
||||||
|
import { normalizeConfirmNodeData } from '../components/utils/confirmNode';
|
||||||
|
|
||||||
export const START_NODE_TYPE = 'startNode';
|
export const START_NODE_TYPE = 'startNode';
|
||||||
|
export const CONFIRM_NODE_TYPE = 'confirmNode';
|
||||||
export const LLM_NODE_TYPE = 'llmNode';
|
export const LLM_NODE_TYPE = 'llmNode';
|
||||||
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
|
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
|
||||||
export const SYSTEM_START_PARAM_NAME = 'user_input';
|
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,
|
data: T,
|
||||||
): T {
|
): T {
|
||||||
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
|
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
|
||||||
@@ -1156,10 +1158,24 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
|
|||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const nextNodes = data.nodes.map((node) => {
|
const nextNodes = data.nodes.map((node) => {
|
||||||
if (node?.type !== START_NODE_TYPE) {
|
if (!node?.data || typeof node.data !== 'object') {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
const currentData = (node.data || {}) as Record<string, any>;
|
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)
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
? (currentData.parameters as Parameter[])
|
? (currentData.parameters as Parameter[])
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -86,14 +86,22 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
function resume(data: any) {
|
async function resume(data: any) {
|
||||||
data.executeId = executeId.value;
|
data.executeId = executeId.value;
|
||||||
submitLoading.value = true;
|
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) {
|
if (res.errorCode === 0) {
|
||||||
|
accepted = true;
|
||||||
startPolling(executeId.value);
|
startPolling(executeId.value);
|
||||||
}
|
}
|
||||||
});
|
return accepted;
|
||||||
|
} finally {
|
||||||
|
if (!accepted) {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function submitV2() {
|
function submitV2() {
|
||||||
runForm.value?.validate((valid) => {
|
runForm.value?.validate((valid) => {
|
||||||
@@ -131,7 +139,7 @@ function startPolling(executeId: any) {
|
|||||||
if (pollingActive) return;
|
if (pollingActive) return;
|
||||||
pollingActive = true;
|
pollingActive = true;
|
||||||
pollingGeneration += 1;
|
pollingGeneration += 1;
|
||||||
schedulePolling(executeId, pollingGeneration);
|
void executePolling(executeId, pollingGeneration);
|
||||||
}
|
}
|
||||||
function schedulePolling(executeId: any, generation: number) {
|
function schedulePolling(executeId: any, generation: number) {
|
||||||
timer.value = setTimeout(() => {
|
timer.value = setTimeout(() => {
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ function isFileContentType(contentType: any) {
|
|||||||
return contentType === 'file';
|
return contentType === 'file';
|
||||||
}
|
}
|
||||||
function getCheckboxOptions(item: any) {
|
function getCheckboxOptions(item: any) {
|
||||||
|
if (Array.isArray(item.options)) {
|
||||||
|
return item.options.map((option: any) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (item.enums) {
|
if (item.enums) {
|
||||||
return (
|
return (
|
||||||
item.enums?.map((option: any) => ({
|
item.enums?.map((option: any) => ({
|
||||||
@@ -73,7 +79,9 @@ function buildRules(item: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ import {
|
|||||||
ElCollapse,
|
ElCollapse,
|
||||||
ElCollapseItem,
|
ElCollapseItem,
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
|
||||||
ElIcon,
|
ElIcon,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
import { $t } from '#/locales';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
import ConfirmItem from '#/views/ai/workflow/components/ConfirmItem.vue';
|
|
||||||
import ConfirmItemMulti from '#/views/ai/workflow/components/ConfirmItemMulti.vue';
|
|
||||||
|
|
||||||
export interface WorkflowStepsProps {
|
export interface WorkflowStepsProps {
|
||||||
workflowId: any;
|
workflowId: any;
|
||||||
@@ -31,7 +28,9 @@ export interface WorkflowStepsProps {
|
|||||||
pollingData?: any;
|
pollingData?: any;
|
||||||
}
|
}
|
||||||
const props = defineProps<WorkflowStepsProps>();
|
const props = defineProps<WorkflowStepsProps>();
|
||||||
const emit = defineEmits(['resume']);
|
const emit = defineEmits<{
|
||||||
|
resume: [payload: any, onSettled: (accepted: boolean) => void];
|
||||||
|
}>();
|
||||||
const nodes = ref<any[]>([]);
|
const nodes = ref<any[]>([]);
|
||||||
const nodeStatusMap = ref<Record<string, any>>({});
|
const nodeStatusMap = ref<Record<string, any>>({});
|
||||||
const isChainError = ref(false);
|
const isChainError = ref(false);
|
||||||
@@ -43,13 +42,18 @@ watch(
|
|||||||
isChainError.value = true;
|
isChainError.value = true;
|
||||||
chainErrMsg.value = newVal.message;
|
chainErrMsg.value = newVal.message;
|
||||||
}
|
}
|
||||||
if (![20, 21].includes(newVal.status)) {
|
if (Number(newVal.status) !== 5) {
|
||||||
confirmBtnLoading.value = false;
|
confirmBtnLoading.value = false;
|
||||||
}
|
}
|
||||||
for (const nodeId in nodes) {
|
for (const nodeId in nodes) {
|
||||||
|
const previousStatus = nodeStatusMap.value[nodeId]?.status;
|
||||||
nodeStatusMap.value[nodeId] = nodes[nodeId];
|
nodeStatusMap.value[nodeId] = nodes[nodeId];
|
||||||
if (nodes[nodeId].status === 5) {
|
if (nodes[nodeId].status === 5) {
|
||||||
activeName.value = nodeId;
|
activeName.value = nodeId;
|
||||||
|
if (Number(previousStatus) !== 5) {
|
||||||
|
initializeConfirmParams(nodes[nodeId].suspendForParameters);
|
||||||
|
confirmBtnLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -59,6 +63,7 @@ watch(
|
|||||||
() => props.initSignal,
|
() => props.initSignal,
|
||||||
() => {
|
() => {
|
||||||
nodeStatusMap.value = {};
|
nodeStatusMap.value = {};
|
||||||
|
confirmParams.value = {};
|
||||||
isChainError.value = false;
|
isChainError.value = false;
|
||||||
confirmBtnLoading.value = false;
|
confirmBtnLoading.value = false;
|
||||||
chainErrMsg.value = '';
|
chainErrMsg.value = '';
|
||||||
@@ -91,8 +96,13 @@ const setFormRef = (el: any, key: string) => {
|
|||||||
};
|
};
|
||||||
const confirmBtnLoading = ref(false);
|
const confirmBtnLoading = ref(false);
|
||||||
const chainErrMsg = ref('');
|
const chainErrMsg = ref('');
|
||||||
function getSelectMode(ops: any) {
|
function initializeConfirmParams(parameters: any) {
|
||||||
return ops.formType || 'radio';
|
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) {
|
function handleConfirm(node: any) {
|
||||||
const nodeKey = node.key;
|
const nodeKey = node.key;
|
||||||
@@ -103,17 +113,27 @@ function handleConfirm(node: any) {
|
|||||||
console.warn(`Form instance for ${nodeKey} not found`);
|
console.warn(`Form instance for ${nodeKey} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmKey = node.suspendForParameters[0].name;
|
|
||||||
form.validate((valid) => {
|
form.validate((valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const value = {
|
const value = {
|
||||||
confirmParams: {
|
confirmParams: { ...confirmParams.value },
|
||||||
[confirmKey]: 'yes',
|
|
||||||
...confirmParams.value,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
confirmBtnLoading.value = true;
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<div class="mb-2 text-[16px] font-bold">
|
||||||
{{ node.original.data.message }}
|
{{ node.original.data.message }}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,50 +199,24 @@ function handleConfirm(node: any) {
|
|||||||
label-position="top"
|
label-position="top"
|
||||||
:model="confirmParams"
|
:model="confirmParams"
|
||||||
>
|
>
|
||||||
<template
|
<WorkflowFormItem
|
||||||
v-for="(ops, idx) in node.suspendForParameters"
|
:parameters="node.suspendForParameters || []"
|
||||||
:key="idx"
|
: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'">
|
<ElButton
|
||||||
<div class="blue-bar"> </div>
|
:disabled="confirmBtnLoading"
|
||||||
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
|
:loading="confirmBtnLoading"
|
||||||
</div>
|
type="primary"
|
||||||
<div
|
@click="handleConfirm(node)"
|
||||||
class="description-container"
|
|
||||||
v-if="ops.formType !== 'confirm'"
|
|
||||||
>
|
>
|
||||||
{{ ops.formDescription }}
|
继续
|
||||||
</div>
|
</ElButton>
|
||||||
<ElFormItem
|
</div>
|
||||||
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>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -248,26 +247,4 @@ function handleConfirm(node: any) {
|
|||||||
transform: rotate(360deg);
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user