feat: 完成工作流开始节点开场表单
- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析 - 统一 Admin/UserCenter 开场表单渲染与文件集合输入 - 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
@@ -5,10 +5,7 @@ import cn.dev33.satoken.stp.StpUtil;
|
|||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
@@ -25,6 +22,7 @@ import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
|
|||||||
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.WorkflowDatacenterContentService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
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;
|
||||||
@@ -77,8 +75,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private ChainParser chainParser;
|
|
||||||
@Resource
|
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private CodeEngineCapabilityService codeEngineCapabilityService;
|
private CodeEngineCapabilityService codeEngineCapabilityService;
|
||||||
@@ -87,6 +83,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
|
@Resource
|
||||||
private ResourceAccessService resourceAccessService;
|
private ResourceAccessService resourceAccessService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||||
@@ -126,6 +124,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
if (variables == null) {
|
if (variables == null) {
|
||||||
variables = new HashMap<>();
|
variables = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -155,6 +154,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -245,17 +245,10 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
if (res == null) {
|
||||||
if (definition == null) {
|
|
||||||
return Result.fail(2, "节点配置错误,请检查! ");
|
return Result.fail(2, "节点配置错误,请检查! ");
|
||||||
}
|
}
|
||||||
List<Parameter> chainParameters = definition.getStartParameters();
|
|
||||||
Map<String, Object> res = new HashMap<>();
|
|
||||||
res.put("parameters", chainParameters);
|
|
||||||
res.put("title", workflow.getTitle());
|
|
||||||
res.put("description", workflow.getDescription());
|
|
||||||
res.put("icon", workflow.getIcon());
|
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package tech.easyflow.publicapi.controller;
|
|||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,7 +13,7 @@ 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.WorkflowDatacenterContentService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
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.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
@@ -41,13 +38,11 @@ public class PublicWorkflowController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private ChainParser chainParser;
|
|
||||||
@Resource
|
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowCheckService workflowCheckService;
|
private WorkflowCheckService workflowCheckService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过id或别名获取工作流详情
|
* 通过id或别名获取工作流详情
|
||||||
@@ -81,6 +76,7 @@ public class PublicWorkflowController {
|
|||||||
if (variables == null) {
|
if (variables == null) {
|
||||||
variables = new HashMap<>();
|
variables = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -104,6 +100,7 @@ public class PublicWorkflowController {
|
|||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
||||||
return Result.ok(executeId);
|
return Result.ok(executeId);
|
||||||
}
|
}
|
||||||
@@ -139,17 +136,10 @@ public class PublicWorkflowController {
|
|||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
if (res == null) {
|
||||||
if (definition == null) {
|
|
||||||
return Result.fail(2, "节点配置错误,请检查! ");
|
return Result.fail(2, "节点配置错误,请检查! ");
|
||||||
}
|
}
|
||||||
List<Parameter> chainParameters = definition.getStartParameters();
|
|
||||||
Map<String, Object> res = new HashMap<>();
|
|
||||||
res.put("parameters", chainParameters);
|
|
||||||
res.put("title", workflow.getTitle());
|
|
||||||
res.put("description", workflow.getDescription());
|
|
||||||
res.put("icon", workflow.getIcon());
|
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,15 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
|||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||||
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.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
|
||||||
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.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;
|
||||||
@@ -48,13 +45,11 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private ChainParser chainParser;
|
|
||||||
@Resource
|
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowCheckService workflowCheckService;
|
private WorkflowCheckService workflowCheckService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||||
|
|
||||||
@@ -86,6 +81,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
if (variables == null) {
|
if (variables == null) {
|
||||||
variables = new HashMap<>();
|
variables = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -115,6 +111,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
throw new RuntimeException("工作流不存在");
|
throw new RuntimeException("工作流不存在");
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
@@ -176,17 +173,10 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||||
}
|
}
|
||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
|
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
if (res == null) {
|
||||||
if (definition == null) {
|
|
||||||
return Result.fail(2, "节点配置错误,请检查! ");
|
return Result.fail(2, "节点配置错误,请检查! ");
|
||||||
}
|
}
|
||||||
List<Parameter> chainParameters = definition.getStartParameters();
|
|
||||||
Map<String, Object> res = new HashMap<>();
|
|
||||||
res.put("parameters", chainParameters);
|
|
||||||
res.put("title", workflow.getTitle());
|
|
||||||
res.put("description", workflow.getDescription());
|
|
||||||
res.put("icon", workflow.getIcon());
|
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class WorkflowCheckService {
|
|||||||
private static final String TYPE_LOOP = "loopNode";
|
private static final String TYPE_LOOP = "loopNode";
|
||||||
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 SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@@ -78,6 +79,10 @@ public class WorkflowCheckService {
|
|||||||
Set<String> issueKeys = new LinkedHashSet<>();
|
Set<String> issueKeys = new LinkedHashSet<>();
|
||||||
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
||||||
if (parsedWorkflow != null) {
|
if (parsedWorkflow != null) {
|
||||||
|
List<NodeView> startNodes = parsedWorkflow.nodes.stream()
|
||||||
|
.filter(node -> TYPE_START.equals(node.type))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
checkStartFormSchema(startNodes, issues, issueKeys);
|
||||||
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
|
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +441,69 @@ public class WorkflowCheckService {
|
|||||||
detectWorkflowReferenceCycle(currentWorkflowIdString, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
|
detectWorkflowReferenceCycle(currentWorkflowIdString, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验开始节点开场表单 Schema 的最小约束。
|
||||||
|
*
|
||||||
|
* @param startNodes 开始节点列表
|
||||||
|
* @param issues 问题收集
|
||||||
|
* @param issueKeys 去重键集合
|
||||||
|
*/
|
||||||
|
private void checkStartFormSchema(List<NodeView> startNodes,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
for (NodeView startNode : startNodes) {
|
||||||
|
if (startNode == null || startNode.data == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONArray schema = startNode.data.getJSONArray("startFormSchema");
|
||||||
|
if (schema == null || schema.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject userInputField = null;
|
||||||
|
for (int i = 0; i < schema.size(); i++) {
|
||||||
|
JSONObject field = schema.getJSONObject(i);
|
||||||
|
if (field == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = trimToNull(field.getString("key"));
|
||||||
|
if (!StringUtils.hasText(key)) {
|
||||||
|
addIssue(issues, issueKeys, "START_FORM_FIELD_KEY_EMPTY",
|
||||||
|
"开始表单字段缺少 key", startNode.id, null, startNode.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String type = trimToNull(field.getString("type"));
|
||||||
|
if (Arrays.asList("radio", "checkbox", "select").contains(type)) {
|
||||||
|
JSONArray options = field.getJSONArray("options");
|
||||||
|
if (options == null || options.isEmpty()) {
|
||||||
|
addIssue(issues, issueKeys, "START_FORM_OPTIONS_EMPTY",
|
||||||
|
"选择类开始表单字段必须配置至少一个选项", startNode.id, null, startNode.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||||
|
userInputField = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInputField == null) {
|
||||||
|
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_MISSING",
|
||||||
|
"开始表单必须包含 user_input 主问题字段", startNode.id, null, startNode.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(userInputField.getBoolean("required"))) {
|
||||||
|
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_REQUIRED",
|
||||||
|
"开始表单的 user_input 字段必须为必填", startNode.id, null, startNode.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
String userInputType = trimToNull(userInputField.getString("type"));
|
||||||
|
if (!Arrays.asList("text", "textarea").contains(userInputType)) {
|
||||||
|
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_TYPE_INVALID",
|
||||||
|
"开始表单的 user_input 字段只能使用 text 或 textarea", startNode.id, null, startNode.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void detectWorkflowReferenceCycle(String rootWorkflowId, String currentWorkflowId, String currentContent,
|
private void detectWorkflowReferenceCycle(String rootWorkflowId, String currentWorkflowId, String currentContent,
|
||||||
Map<String, String> contentCache,
|
Map<String, String> contentCache,
|
||||||
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流运行参数解析器。
|
||||||
|
*
|
||||||
|
* <p>负责统一构建运行页所需的开始节点参数、开场表单元信息和字段 Schema,
|
||||||
|
* 避免 Admin / Public / UserCenter 三端各自维护一套解析逻辑。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowRunningParameterResolver {
|
||||||
|
|
||||||
|
private static final String START_NODE_TYPE = "startNode";
|
||||||
|
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
|
private static final String DEFAULT_START_FORM_TITLE = "开始问答";
|
||||||
|
private static final String DEFAULT_START_FORM_DESCRIPTION = "请先补充必要信息,再开始执行工作流。";
|
||||||
|
private static final String DEFAULT_START_FORM_SUBMIT_TEXT = "开始";
|
||||||
|
private static final int FILE_MAX_COUNT = 10;
|
||||||
|
private static final long FILE_MAX_SINGLE_SIZE = 5L * 1024 * 1024;
|
||||||
|
private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ChainParser chainParser;
|
||||||
|
@Resource
|
||||||
|
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建工作流运行参数视图。
|
||||||
|
*
|
||||||
|
* @param workflow 工作流实体
|
||||||
|
* @return 运行参数视图;当流程内容无法解析时返回 {@code null}
|
||||||
|
*/
|
||||||
|
public Map<String, Object> buildRunningParametersView(Workflow workflow) {
|
||||||
|
if (workflow == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Parameter> startParameters = resolveStartParameters(workflow.getContent());
|
||||||
|
if (startParameters == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject startNodeData = findStartNodeData(workflow.getContent());
|
||||||
|
List<Map<String, Object>> startFormSchema = resolveStartFormSchema(startNodeData, startParameters);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("parameters", startParameters);
|
||||||
|
result.put("title", workflow.getTitle());
|
||||||
|
result.put("description", workflow.getDescription());
|
||||||
|
result.put("icon", workflow.getIcon());
|
||||||
|
result.put("startFormMeta", resolveStartFormMeta(startNodeData));
|
||||||
|
result.put("startFormSchema", startFormSchema);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析开始节点运行参数。
|
||||||
|
*
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @return 开始节点参数;解析失败时返回 {@code null}
|
||||||
|
*/
|
||||||
|
public List<Parameter> resolveStartParameters(String content) {
|
||||||
|
try {
|
||||||
|
String preparedContent = workflowDatacenterContentService.prepareContent(content);
|
||||||
|
ChainDefinition definition = chainParser.parse(preparedContent);
|
||||||
|
return definition == null ? null : definition.getStartParameters();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化工作流运行时变量,确保文件参数统一为文件对象数组。
|
||||||
|
*
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @param variables 原始运行变量
|
||||||
|
* @return 归一化后的变量副本
|
||||||
|
*/
|
||||||
|
public Map<String, Object> normalizeRuntimeVariables(String content, Map<String, Object> variables) {
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
|
if (variables != null) {
|
||||||
|
normalized.putAll(variables);
|
||||||
|
}
|
||||||
|
List<Parameter> startParameters = resolveStartParameters(content);
|
||||||
|
if (startParameters == null || startParameters.isEmpty()) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
for (Parameter parameter : startParameters) {
|
||||||
|
if (!isFileParameter(parameter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = trimToNull(parameter.getName());
|
||||||
|
if (!StringUtils.hasText(name) || !normalized.containsKey(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> resolveStartFormMeta(JSONObject startNodeData) {
|
||||||
|
JSONObject rawMeta = startNodeData == null ? null : startNodeData.getJSONObject("startFormMeta");
|
||||||
|
Map<String, Object> meta = new LinkedHashMap<>();
|
||||||
|
meta.put("title", trimToDefault(rawMeta == null ? null : rawMeta.getString("title"), DEFAULT_START_FORM_TITLE));
|
||||||
|
meta.put("description", trimToDefault(rawMeta == null ? null : rawMeta.getString("description"), DEFAULT_START_FORM_DESCRIPTION));
|
||||||
|
meta.put("submitText", trimToDefault(rawMeta == null ? null : rawMeta.getString("submitText"), DEFAULT_START_FORM_SUBMIT_TEXT));
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> resolveStartFormSchema(JSONObject startNodeData, List<Parameter> parameters) {
|
||||||
|
JSONArray rawSchema = startNodeData == null ? null : startNodeData.getJSONArray("startFormSchema");
|
||||||
|
List<Map<String, Object>> schema = new ArrayList<>();
|
||||||
|
Set<String> seenKeys = new LinkedHashSet<>();
|
||||||
|
if (rawSchema != null && !rawSchema.isEmpty()) {
|
||||||
|
for (int i = 0; i < rawSchema.size(); i++) {
|
||||||
|
JSONObject field = rawSchema.getJSONObject(i);
|
||||||
|
Map<String, Object> normalized = normalizeStartFormField(field, null);
|
||||||
|
if (normalized == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = String.valueOf(normalized.get("key"));
|
||||||
|
if (!seenKeys.add(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||||
|
schema.add(0, normalized);
|
||||||
|
} else {
|
||||||
|
schema.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schema.isEmpty()) {
|
||||||
|
for (Parameter parameter : parameters) {
|
||||||
|
Map<String, Object> normalized = normalizeStartFormField(null, parameter);
|
||||||
|
if (normalized == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = String.valueOf(normalized.get("key"));
|
||||||
|
if (!seenKeys.add(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||||
|
schema.add(0, normalized);
|
||||||
|
} else {
|
||||||
|
schema.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seenKeys.contains(SYSTEM_START_PARAM_NAME)) {
|
||||||
|
schema.add(0, normalizeStartFormField(new JSONObject(), null));
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> normalizeStartFormField(JSONObject field, Parameter parameter) {
|
||||||
|
String parameterName = parameter == null ? null : trimToNull(parameter.getName());
|
||||||
|
String key = trimToNull(field == null ? null : field.getString("key"));
|
||||||
|
if (!StringUtils.hasText(key)) {
|
||||||
|
key = parameterName;
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(key)) {
|
||||||
|
key = SYSTEM_START_PARAM_NAME;
|
||||||
|
}
|
||||||
|
boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key)
|
||||||
|
|| (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved")));
|
||||||
|
String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved);
|
||||||
|
List<String> options = resolveFieldOptions(field, parameter, type);
|
||||||
|
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
|
normalized.put("key", key);
|
||||||
|
normalized.put("label", trimToDefault(
|
||||||
|
field == null ? null : field.getString("label"),
|
||||||
|
parameter == null ? null : parameter.getFormLabel(),
|
||||||
|
SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key
|
||||||
|
));
|
||||||
|
normalized.put("type", type);
|
||||||
|
normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required")))
|
||||||
|
|| (parameter != null && parameter.isRequired()));
|
||||||
|
normalized.put("placeholder", trimToDefault(
|
||||||
|
field == null ? null : field.getString("placeholder"),
|
||||||
|
parameter == null ? null : parameter.getFormPlaceholder(),
|
||||||
|
SYSTEM_START_PARAM_NAME.equals(key) ? "请输入用户问题" : ""
|
||||||
|
));
|
||||||
|
normalized.put("description", trimToDefault(
|
||||||
|
field == null ? null : field.getString("description"),
|
||||||
|
parameter == null ? null : parameter.getFormDescription(),
|
||||||
|
""
|
||||||
|
));
|
||||||
|
normalized.put("defaultValue", resolveDefaultValue(field, parameter, type));
|
||||||
|
normalized.put("options", options);
|
||||||
|
normalized.put("systemReserved", systemReserved);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) {
|
||||||
|
Object rawDefaultValue = field == null ? null : field.get("defaultValue");
|
||||||
|
if (rawDefaultValue != null) {
|
||||||
|
if ("checkbox".equals(type) && rawDefaultValue instanceof List<?>) {
|
||||||
|
return rawDefaultValue;
|
||||||
|
}
|
||||||
|
return rawDefaultValue;
|
||||||
|
}
|
||||||
|
return parameter == null ? "" : parameter.getDefaultValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveFieldOptions(JSONObject field, Parameter parameter, String type) {
|
||||||
|
if (!"radio".equals(type) && !"checkbox".equals(type) && !"select".equals(type)) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<String> options = new ArrayList<>();
|
||||||
|
if (field != null) {
|
||||||
|
JSONArray rawOptions = field.getJSONArray("options");
|
||||||
|
if (rawOptions != null) {
|
||||||
|
for (int i = 0; i < rawOptions.size(); i++) {
|
||||||
|
String option = trimToNull(rawOptions.getString(i));
|
||||||
|
if (StringUtils.hasText(option)) {
|
||||||
|
options.add(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!options.isEmpty()) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (parameter != null && parameter.getEnums() != null) {
|
||||||
|
for (Object option : parameter.getEnums()) {
|
||||||
|
String normalized = trimToNull(option == null ? null : String.valueOf(option));
|
||||||
|
if (StringUtils.hasText(normalized)) {
|
||||||
|
options.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveStartFormFieldType(String rawType, Parameter parameter, boolean systemReserved) {
|
||||||
|
String parameterType = parameterType(parameter);
|
||||||
|
String requested = trimToNull(rawType);
|
||||||
|
String normalized;
|
||||||
|
if (StringUtils.hasText(requested)) {
|
||||||
|
normalized = requested;
|
||||||
|
} else if (StringUtils.hasText(parameterType)) {
|
||||||
|
normalized = parameterType;
|
||||||
|
} else {
|
||||||
|
normalized = systemReserved ? "textarea" : "text";
|
||||||
|
}
|
||||||
|
if (systemReserved) {
|
||||||
|
return "text".equals(normalized) ? "text" : "textarea";
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "textarea", "radio", "checkbox", "select", "file" -> normalized;
|
||||||
|
default -> "text";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parameterType(Parameter parameter) {
|
||||||
|
if (parameter == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("file".equals(trimToNull(parameter.getContentType()))
|
||||||
|
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())))) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
String formType = trimToNull(parameter.getFormType());
|
||||||
|
if ("textarea".equals(formType)) {
|
||||||
|
return "textarea";
|
||||||
|
}
|
||||||
|
if ("radio".equals(formType)) {
|
||||||
|
return "radio";
|
||||||
|
}
|
||||||
|
if ("checkbox".equals(formType)) {
|
||||||
|
return "checkbox";
|
||||||
|
}
|
||||||
|
if ("select".equals(formType)) {
|
||||||
|
return "select";
|
||||||
|
}
|
||||||
|
return SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter.getName())) ? "textarea" : "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断参数是否为文件输入参数。
|
||||||
|
*
|
||||||
|
* @param parameter 参数定义
|
||||||
|
* @return 是否文件参数
|
||||||
|
*/
|
||||||
|
private boolean isFileParameter(Parameter parameter) {
|
||||||
|
if (parameter == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return "file".equals(trimToNull(parameter.getContentType()))
|
||||||
|
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单文件或多文件运行值归一化为文件对象数组。
|
||||||
|
*
|
||||||
|
* @param value 原始变量值
|
||||||
|
* @param parameterName 参数名
|
||||||
|
* @return 归一化后的文件对象数组
|
||||||
|
*/
|
||||||
|
private List<Object> normalizeFileVariableValue(Object value, String parameterName) {
|
||||||
|
List<Object> candidates = new ArrayList<>();
|
||||||
|
collectFileValues(value, candidates);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Object> normalized = new ArrayList<>();
|
||||||
|
Set<String> seenFilePaths = new LinkedHashSet<>();
|
||||||
|
long totalSize = 0L;
|
||||||
|
for (Object candidate : candidates) {
|
||||||
|
if (!(candidate instanceof Map<?, ?> fileMap)) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 的输入格式不正确,必须为文件对象或文件对象数组");
|
||||||
|
}
|
||||||
|
String fileName = trimObjectToNull(fileMap.get("fileName"));
|
||||||
|
String filePath = trimObjectToNull(fileMap.get("filePath"));
|
||||||
|
if (!StringUtils.hasText(fileName)) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 缺少 fileName");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(filePath)) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 缺少 filePath");
|
||||||
|
}
|
||||||
|
if (!seenFilePaths.add(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Long size = parseLong(fileMap.get("size"));
|
||||||
|
if (size != null && size > FILE_MAX_SINGLE_SIZE) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 中单个文件不能超过 5MB");
|
||||||
|
}
|
||||||
|
if (size != null && size > 0) {
|
||||||
|
totalSize += size;
|
||||||
|
}
|
||||||
|
normalized.add(new LinkedHashMap<>(fileMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.size() > FILE_MAX_COUNT) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 最多上传 10 个文件");
|
||||||
|
}
|
||||||
|
if (totalSize > FILE_MAX_TOTAL_SIZE) {
|
||||||
|
throw new BusinessException("文件参数 " + parameterName + " 的文件总大小不能超过 50MB");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectFileValues(Object value, List<Object> result) {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value instanceof Collection<?> collection) {
|
||||||
|
for (Object item : collection) {
|
||||||
|
collectFileValues(item, result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimObjectToNull(Object value) {
|
||||||
|
return trimToNull(value == null ? null : String.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long parseLong(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return number.longValue();
|
||||||
|
}
|
||||||
|
String text = trimObjectToNull(value);
|
||||||
|
if (!StringUtils.hasText(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(text);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new BusinessException("文件大小格式不正确: " + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject findStartNodeData(String content) {
|
||||||
|
if (!StringUtils.hasText(content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object parsed = JSON.parse(content);
|
||||||
|
if (!(parsed instanceof JSONObject root)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JSONArray nodes = root.getJSONArray("nodes");
|
||||||
|
if (nodes == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < nodes.size(); i++) {
|
||||||
|
JSONObject node = nodes.getJSONObject(i);
|
||||||
|
if (node == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!START_NODE_TYPE.equals(trimToNull(node.getString("type")))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return node.getJSONObject("data");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToDefault(String value, String fallback) {
|
||||||
|
String normalized = trimToNull(value);
|
||||||
|
return StringUtils.hasText(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToDefault(String primary, String secondary, String fallback) {
|
||||||
|
String normalizedPrimary = trimToNull(primary);
|
||||||
|
if (StringUtils.hasText(normalizedPrimary)) {
|
||||||
|
return normalizedPrimary;
|
||||||
|
}
|
||||||
|
String normalizedSecondary = trimToNull(secondary);
|
||||||
|
return StringUtils.hasText(normalizedSecondary) ? normalizedSecondary : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,6 +236,98 @@ public class WorkflowCheckServiceTest {
|
|||||||
Assert.assertEquals(0, result.getIssueCount());
|
Assert.assertEquals(0, result.getIssueCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockStartFormWithoutUserInput() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray schema = new JSONArray();
|
||||||
|
JSONObject field = new JSONObject();
|
||||||
|
field.put("key", "scene");
|
||||||
|
field.put("type", "select");
|
||||||
|
field.put("required", true);
|
||||||
|
JSONArray options = new JSONArray();
|
||||||
|
options.add("售前");
|
||||||
|
field.put("options", options);
|
||||||
|
schema.add(field);
|
||||||
|
startData.put("startFormSchema", schema);
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "START_FORM_USER_INPUT_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPreExecuteShouldBlockInvalidStartFormUserInputTypeAndOptions() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray schema = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject userInputField = new JSONObject();
|
||||||
|
userInputField.put("key", "user_input");
|
||||||
|
userInputField.put("type", "radio");
|
||||||
|
userInputField.put("required", false);
|
||||||
|
schema.add(userInputField);
|
||||||
|
|
||||||
|
JSONObject selectField = new JSONObject();
|
||||||
|
selectField.put("key", "scene");
|
||||||
|
selectField.put("type", "select");
|
||||||
|
selectField.put("required", true);
|
||||||
|
schema.add(selectField);
|
||||||
|
|
||||||
|
startData.put("startFormSchema", schema);
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED");
|
||||||
|
assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID");
|
||||||
|
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockInvalidStartFormSchema() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray schema = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject userInputField = new JSONObject();
|
||||||
|
userInputField.put("key", "user_input");
|
||||||
|
userInputField.put("type", "radio");
|
||||||
|
userInputField.put("required", false);
|
||||||
|
schema.add(userInputField);
|
||||||
|
|
||||||
|
JSONObject checkboxField = new JSONObject();
|
||||||
|
checkboxField.put("key", "labels");
|
||||||
|
checkboxField.put("type", "checkbox");
|
||||||
|
checkboxField.put("required", true);
|
||||||
|
schema.add(checkboxField);
|
||||||
|
|
||||||
|
startData.put("startFormSchema", schema);
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node("s1", "startNode", null, startData)),
|
||||||
|
new JSONArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED");
|
||||||
|
assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID");
|
||||||
|
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
|
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
|
||||||
WorkflowCheckService service = new WorkflowCheckService();
|
WorkflowCheckService service = new WorkflowCheckService();
|
||||||
ChainParser parser = ChainParser.builder()
|
ChainParser parser = ChainParser.builder()
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
||||||
|
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowRunningParameterResolver} 测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowRunningParameterResolverTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应返回显式配置的开场表单元信息与字段 Schema。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testBuildRunningParametersViewShouldKeepStartFormSchema() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray schema = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject userInputField = new JSONObject();
|
||||||
|
userInputField.put("key", "user_input");
|
||||||
|
userInputField.put("label", "主问题");
|
||||||
|
userInputField.put("type", "text");
|
||||||
|
userInputField.put("required", true);
|
||||||
|
schema.add(userInputField);
|
||||||
|
|
||||||
|
JSONObject fileField = new JSONObject();
|
||||||
|
fileField.put("key", "attachments");
|
||||||
|
fileField.put("label", "附件");
|
||||||
|
fileField.put("type", "file");
|
||||||
|
fileField.put("required", false);
|
||||||
|
schema.add(fileField);
|
||||||
|
|
||||||
|
JSONObject meta = new JSONObject();
|
||||||
|
meta.put("title", "问答入口");
|
||||||
|
meta.put("description", "请先填写信息");
|
||||||
|
meta.put("submitText", "立即开始");
|
||||||
|
startData.put("startFormMeta", meta);
|
||||||
|
startData.put("startFormSchema", schema);
|
||||||
|
startData.put("parameters", startParameters());
|
||||||
|
|
||||||
|
Workflow workflow = workflow(
|
||||||
|
workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> result = resolver.buildRunningParametersView(workflow);
|
||||||
|
Assert.assertNotNull(result);
|
||||||
|
Assert.assertEquals("问答入口", ((Map<?, ?>) result.get("startFormMeta")).get("title"));
|
||||||
|
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||||
|
Assert.assertEquals(2, fields.size());
|
||||||
|
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
||||||
|
Assert.assertEquals("text", fields.get(0).get("type"));
|
||||||
|
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
||||||
|
Assert.assertEquals("file", fields.get(1).get("type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧工作流仅保留 parameters 时,也应补出最小开场表单 Schema。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testBuildRunningParametersViewShouldFallbackToParameters() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
startData.put("parameters", startParameters());
|
||||||
|
|
||||||
|
Workflow workflow = workflow(
|
||||||
|
workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> result = resolver.buildRunningParametersView(workflow);
|
||||||
|
Assert.assertNotNull(result);
|
||||||
|
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||||
|
Assert.assertEquals(2, fields.size());
|
||||||
|
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
||||||
|
Assert.assertEquals("textarea", fields.get(0).get("type"));
|
||||||
|
Assert.assertEquals(Boolean.TRUE, fields.get(0).get("required"));
|
||||||
|
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
||||||
|
Assert.assertEquals("file", fields.get(1).get("type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldWrapFileValueIntoArray() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("attachments", fileValue("demo.pdf", "/files/demo.pdf", 1024L));
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables);
|
||||||
|
|
||||||
|
Object attachments = normalized.get("attachments");
|
||||||
|
Assert.assertTrue(attachments instanceof List<?>);
|
||||||
|
Assert.assertEquals(1, ((List<?>) attachments).size());
|
||||||
|
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多文件参数应按 filePath 去重并保留已有非文件变量。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldDedupeFileArray() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("user_input", "hello");
|
||||||
|
variables.put("attachments", List.of(
|
||||||
|
fileValue("a.pdf", "/files/a.pdf", 1024L),
|
||||||
|
fileValue("a-copy.pdf", "/files/a.pdf", 1024L),
|
||||||
|
fileValue("b.pdf", "/files/b.pdf", 1024L)
|
||||||
|
));
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables);
|
||||||
|
|
||||||
|
Assert.assertEquals("hello", normalized.get("user_input"));
|
||||||
|
Object attachments = normalized.get("attachments");
|
||||||
|
Assert.assertTrue(attachments instanceof List<?>);
|
||||||
|
Assert.assertEquals(2, ((List<?>) attachments).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowRunningParameterResolver newResolver() throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver();
|
||||||
|
ChainParser parser = ChainParser.builder()
|
||||||
|
.withDefaultParsers(true)
|
||||||
|
.build();
|
||||||
|
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||||
|
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
||||||
|
setField(resolver, "chainParser", parser);
|
||||||
|
setField(resolver, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Workflow workflow(String content) {
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.ONE);
|
||||||
|
workflow.setTitle("测试工作流");
|
||||||
|
workflow.setDescription("用于测试运行参数");
|
||||||
|
workflow.setIcon("icon.png");
|
||||||
|
workflow.setContent(content);
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String workflowContentWithStartParameters() {
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
startData.put("parameters", startParameters());
|
||||||
|
return workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))
|
||||||
|
),
|
||||||
|
array(edge("e1", "s1", "e1"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONArray startParameters() {
|
||||||
|
JSONArray parameters = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject userInput = new JSONObject();
|
||||||
|
userInput.put("name", "user_input");
|
||||||
|
userInput.put("dataType", "String");
|
||||||
|
userInput.put("refType", "input");
|
||||||
|
userInput.put("required", true);
|
||||||
|
userInput.put("contentType", "text");
|
||||||
|
userInput.put("formType", "textarea");
|
||||||
|
userInput.put("formLabel", "用户问题");
|
||||||
|
userInput.put("formPlaceholder", "请输入用户问题");
|
||||||
|
parameters.add(userInput);
|
||||||
|
|
||||||
|
JSONObject fileField = new JSONObject();
|
||||||
|
fileField.put("name", "attachments");
|
||||||
|
fileField.put("dataType", "File");
|
||||||
|
fileField.put("refType", "input");
|
||||||
|
fileField.put("required", false);
|
||||||
|
fileField.put("contentType", "file");
|
||||||
|
fileField.put("formType", "input");
|
||||||
|
fileField.put("formLabel", "附件");
|
||||||
|
parameters.add(fileField);
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> fileValue(String fileName, String filePath, Long size) {
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("fileName", fileName);
|
||||||
|
value.put("filePath", filePath);
|
||||||
|
value.put("size", size);
|
||||||
|
value.put("contentType", "application/pdf");
|
||||||
|
value.put("url", filePath);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String workflowJson(JSONArray nodes, JSONArray edges) {
|
||||||
|
JSONObject root = new JSONObject();
|
||||||
|
root.put("nodes", nodes);
|
||||||
|
root.put("edges", edges);
|
||||||
|
return root.toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONArray array(JSONObject... objects) {
|
||||||
|
JSONArray array = new JSONArray();
|
||||||
|
for (JSONObject object : objects) {
|
||||||
|
array.add(object);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
|
||||||
|
JSONObject node = new JSONObject();
|
||||||
|
node.put("id", id);
|
||||||
|
node.put("type", type);
|
||||||
|
if (parentId != null) {
|
||||||
|
node.put("parentId", parentId);
|
||||||
|
}
|
||||||
|
node.put("data", data);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject data(String title) {
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("title", title);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject edge(String id, String source, String target) {
|
||||||
|
JSONObject edge = new JSONObject();
|
||||||
|
edge.put("id", id);
|
||||||
|
edge.put("source", source);
|
||||||
|
edge.put("target", target);
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['choose']);
|
const emit = defineEmits(['choose']);
|
||||||
@@ -29,7 +33,7 @@ function closeDialog() {
|
|||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
function confirm() {
|
function confirm() {
|
||||||
emit('choose', currentChoose.value, props.attrName);
|
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
@@ -55,7 +59,11 @@ watch(
|
|||||||
:page-sizes="[8, 12, 16, 20]"
|
:page-sizes="[8, 12, 16, 20]"
|
||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
<ResourceCardList v-model="chooseResources" :data="pageList" />
|
<ResourceCardList
|
||||||
|
v-model="chooseResources"
|
||||||
|
:data="pageList"
|
||||||
|
:multiple="props.multiple"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageData>
|
</PageData>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ async function getWorkflowInfo(workflowId: any) {
|
|||||||
: {};
|
: {};
|
||||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||||
? createInitialWorkflowData()
|
? createInitialWorkflowData()
|
||||||
: parsedContent;
|
: normalizeWorkflowStartNodes(parsedContent);
|
||||||
syncNavTitle(workflowInfo.value?.title || '');
|
syncNavTitle(workflowInfo.value?.title || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import { $t } from '#/locales';
|
|||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendWorkflowFileValues,
|
||||||
buildWorkflowFileValueFromResource,
|
buildWorkflowFileValueFromResource,
|
||||||
buildWorkflowFileValueFromUpload,
|
buildWorkflowFileValueFromUpload,
|
||||||
formatWorkflowFileSize,
|
formatWorkflowFileSize,
|
||||||
isWorkflowFileValue,
|
normalizeWorkflowFileValues,
|
||||||
|
validateWorkflowFileSelection,
|
||||||
|
validateWorkflowFileValues,
|
||||||
|
WORKFLOW_FILE_LIMITS,
|
||||||
} from './workflowFileValue';
|
} from './workflowFileValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object as () => Record<string, any> | undefined,
|
type: [Array, Object],
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -26,9 +30,7 @@ const emit = defineEmits(['update:modelValue']);
|
|||||||
const uploadLoading = ref(false);
|
const uploadLoading = ref(false);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const currentFile = computed(() =>
|
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
||||||
isWorkflowFileValue(props.modelValue) ? props.modelValue : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (uploadLoading.value) {
|
if (uploadLoading.value) {
|
||||||
@@ -39,17 +41,24 @@ function triggerSelectFile() {
|
|||||||
|
|
||||||
async function handleNativeFileChange(event: Event) {
|
async function handleNativeFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const files = Array.from(input.files || []);
|
||||||
if (!file) {
|
if (files.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
validateWorkflowFileSelection(currentFiles.value, files);
|
||||||
|
const uploadedFiles = [];
|
||||||
|
for (const file of files) {
|
||||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
const fileValue = buildWorkflowFileValueFromUpload(file, res?.data?.path);
|
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
||||||
emit('update:modelValue', fileValue);
|
}
|
||||||
} catch (error) {
|
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
||||||
ElMessage.error('文件上传失败');
|
validateWorkflowFileValues(nextFiles);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '文件上传失败');
|
||||||
console.error('工作流文件上传失败', error);
|
console.error('工作流文件上传失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
uploadLoading.value = false;
|
uploadLoading.value = false;
|
||||||
@@ -57,17 +66,27 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChooseResource(resource: any) {
|
function handleChooseResource(resources: any) {
|
||||||
try {
|
try {
|
||||||
const fileValue = buildWorkflowFileValueFromResource(resource || {});
|
const resourceList = Array.isArray(resources) ? resources : [resources];
|
||||||
emit('update:modelValue', fileValue);
|
const fileValues = resourceList
|
||||||
|
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
||||||
|
.filter(Boolean);
|
||||||
|
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
||||||
|
validateWorkflowFileValues(nextFiles);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '素材文件选择失败');
|
ElMessage.error(error?.message || '素材文件选择失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFile() {
|
function removeFile(filePath: string) {
|
||||||
emit('update:modelValue', undefined);
|
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
emit('update:modelValue', []);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -77,18 +96,31 @@ function clearFile() {
|
|||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
class="workflow-file-input__native"
|
class="workflow-file-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="currentFile" class="workflow-file-input__summary">
|
<div class="workflow-file-input__hint">
|
||||||
|
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
||||||
|
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
||||||
|
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||||
|
<div
|
||||||
|
v-for="item in currentFiles"
|
||||||
|
:key="item.filePath"
|
||||||
|
class="workflow-file-input__summary"
|
||||||
|
>
|
||||||
|
<div class="workflow-file-input__content">
|
||||||
<div class="workflow-file-input__name">
|
<div class="workflow-file-input__name">
|
||||||
{{ currentFile.fileName }}
|
{{ item.fileName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="workflow-file-input__meta">
|
<div class="workflow-file-input__meta">
|
||||||
<span>{{ formatWorkflowFileSize(currentFile.size) }}</span>
|
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||||
<ElLink
|
<ElLink
|
||||||
v-if="currentFile.url || currentFile.filePath"
|
v-if="item.url || item.filePath"
|
||||||
:href="currentFile.url || currentFile.filePath"
|
:href="item.url || item.filePath"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
type="primary"
|
type="primary"
|
||||||
>
|
>
|
||||||
@@ -96,6 +128,11 @@ function clearFile() {
|
|||||||
</ElLink>
|
</ElLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
||||||
|
{{ $t('button.delete') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="workflow-file-input__actions">
|
<div class="workflow-file-input__actions">
|
||||||
<ElButton
|
<ElButton
|
||||||
@@ -104,16 +141,16 @@ function clearFile() {
|
|||||||
:loading="uploadLoading"
|
:loading="uploadLoading"
|
||||||
@click="triggerSelectFile"
|
@click="triggerSelectFile"
|
||||||
>
|
>
|
||||||
{{ currentFile ? $t('button.replace') : $t('button.upload') }}
|
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ChooseResource attr-name="file" @choose="handleChooseResource" />
|
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="currentFile"
|
v-if="currentFiles.length > 0"
|
||||||
text
|
text
|
||||||
type="danger"
|
type="danger"
|
||||||
@click="clearFile"
|
@click="clearFiles"
|
||||||
>
|
>
|
||||||
{{ $t('button.delete') }}
|
清空
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,13 +167,34 @@ function clearFile() {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-file-input__summary {
|
.workflow-file-input__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--el-border-color-light);
|
border: 1px solid var(--el-border-color-light);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-file-input__name {
|
.workflow-file-input__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
|
|
||||||
import { computed, onUnmounted, ref } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Position } from '@element-plus/icons-vue';
|
import { Position } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
||||||
@@ -35,9 +35,74 @@ defineExpose({
|
|||||||
const runForm = ref<FormInstance>();
|
const runForm = ref<FormInstance>();
|
||||||
const runParams = ref<any>({});
|
const runParams = ref<any>({});
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
const parameters = computed(() => {
|
const startFormMeta = computed(() => {
|
||||||
return props.workflowParams.parameters;
|
const meta = props.workflowParams?.startFormMeta || {};
|
||||||
|
return {
|
||||||
|
title: String(meta.title || '').trim() || props.workflowParams?.title || '',
|
||||||
|
description:
|
||||||
|
String(meta.description || '').trim() ||
|
||||||
|
props.workflowParams?.description ||
|
||||||
|
'',
|
||||||
|
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
const parameters = computed(() => {
|
||||||
|
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
||||||
|
? props.workflowParams.startFormSchema
|
||||||
|
: [];
|
||||||
|
if (schema.length === 0) {
|
||||||
|
return props.workflowParams.parameters || [];
|
||||||
|
}
|
||||||
|
return schema.map((field: any) => {
|
||||||
|
const type = String(field.type || '').trim() || 'text';
|
||||||
|
return {
|
||||||
|
name: field.key,
|
||||||
|
formLabel: field.label || field.key,
|
||||||
|
formDescription: field.description || '',
|
||||||
|
formPlaceholder: field.placeholder || '',
|
||||||
|
required: Boolean(field.required),
|
||||||
|
defaultValue: field.defaultValue,
|
||||||
|
enums: Array.isArray(field.options) ? field.options : [],
|
||||||
|
contentType: type === 'file' ? 'file' : 'text',
|
||||||
|
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
||||||
|
dataType:
|
||||||
|
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
parameters,
|
||||||
|
(items) => {
|
||||||
|
const nextRunParams = { ...runParams.value };
|
||||||
|
let changed = false;
|
||||||
|
for (const item of items || []) {
|
||||||
|
if (nextRunParams[item.name] !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.defaultValue !== undefined && item.defaultValue !== '') {
|
||||||
|
nextRunParams[item.name] = item.defaultValue;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.formType === 'checkbox') {
|
||||||
|
nextRunParams[item.name] = [];
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.contentType === 'file') {
|
||||||
|
nextRunParams[item.name] = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
runParams.value = nextRunParams;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
function resume(data: any) {
|
function resume(data: any) {
|
||||||
data.executeId = executeId.value;
|
data.executeId = executeId.value;
|
||||||
@@ -110,6 +175,20 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ElForm label-position="top" ref="runForm" :model="runParams">
|
<ElForm label-position="top" ref="runForm" :model="runParams">
|
||||||
|
<div
|
||||||
|
v-if="startFormMeta.title || startFormMeta.description"
|
||||||
|
class="workflow-form__header"
|
||||||
|
>
|
||||||
|
<div v-if="startFormMeta.title" class="workflow-form__title">
|
||||||
|
{{ startFormMeta.title }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="startFormMeta.description"
|
||||||
|
class="workflow-form__description"
|
||||||
|
>
|
||||||
|
{{ startFormMeta.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<WorkflowFormItem
|
<WorkflowFormItem
|
||||||
v-model:run-params="runParams"
|
v-model:run-params="runParams"
|
||||||
:parameters="parameters"
|
:parameters="parameters"
|
||||||
@@ -121,11 +200,28 @@ onUnmounted(() => {
|
|||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
:icon="Position"
|
:icon="Position"
|
||||||
>
|
>
|
||||||
{{ $t('button.run') }}
|
{{ startFormMeta.submitText }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.workflow-form__header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-form__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-form__description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ function getCheckboxOptions(item: any) {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
function buildRules(item: any) {
|
||||||
|
if (!item.required) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule: any, value: any, callback: any) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
callback(
|
||||||
|
Object.keys(value).length > 0
|
||||||
|
? undefined
|
||||||
|
: new Error($t('message.required')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(value ? undefined : new Error($t('message.required')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
function updateParam(name: string, value: any) {
|
function updateParam(name: string, value: any) {
|
||||||
const newValue = { ...props.runParams, [name]: value };
|
const newValue = { ...props.runParams, [name]: value };
|
||||||
emit('update:runParams', newValue);
|
emit('update:runParams', newValue);
|
||||||
@@ -68,9 +93,7 @@ function choose(data: any, propName: string) {
|
|||||||
:prop="`${propPrefix}${item.name}`"
|
:prop="`${propPrefix}${item.name}`"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:label="item.formLabel || item.name"
|
:label="item.formLabel || item.name"
|
||||||
:rules="
|
:rules="buildRules(item)"
|
||||||
item.required ? [{ required: true, message: $t('message.required') }] : []
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template v-if="getContentType(item) === 'text'">
|
<template v-if="getContentType(item) === 'text'">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendWorkflowFileValues,
|
||||||
buildWorkflowFileValueFromResource,
|
buildWorkflowFileValueFromResource,
|
||||||
buildWorkflowFileValueFromUpload,
|
buildWorkflowFileValueFromUpload,
|
||||||
formatWorkflowFileSize,
|
formatWorkflowFileSize,
|
||||||
isWorkflowFileValue,
|
isWorkflowFileValue,
|
||||||
|
normalizeWorkflowFileValues,
|
||||||
|
validateWorkflowFileSelection,
|
||||||
|
validateWorkflowFileValues,
|
||||||
} from '../workflowFileValue';
|
} from '../workflowFileValue';
|
||||||
|
|
||||||
describe('workflowFileValue', () => {
|
describe('workflowFileValue', () => {
|
||||||
@@ -49,4 +53,99 @@ describe('workflowFileValue', () => {
|
|||||||
expect(isWorkflowFileValue({ fileName: 'demo.pdf' })).toBe(false);
|
expect(isWorkflowFileValue({ fileName: 'demo.pdf' })).toBe(false);
|
||||||
expect(formatWorkflowFileSize(2048)).toBe('2.0 KB');
|
expect(formatWorkflowFileSize(2048)).toBe('2.0 KB');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('归一化并去重多文件值', () => {
|
||||||
|
const result = normalizeWorkflowFileValues([
|
||||||
|
{
|
||||||
|
fileName: 'a.pdf',
|
||||||
|
filePath: 'https://example.com/a.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'a-copy.pdf',
|
||||||
|
filePath: 'https://example.com/a.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'b.pdf',
|
||||||
|
filePath: 'https://example.com/b.pdf',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]?.fileName).toBe('a.pdf');
|
||||||
|
expect(result[1]?.fileName).toBe('b.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('追加文件时按 filePath 去重', () => {
|
||||||
|
const result = appendWorkflowFileValues(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fileName: 'a.pdf',
|
||||||
|
filePath: 'https://example.com/a.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fileName: 'a-duplicate.pdf',
|
||||||
|
filePath: 'https://example.com/a.pdf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'b.pdf',
|
||||||
|
filePath: 'https://example.com/b.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[1]?.fileName).toBe('b.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超出文件限制时抛出错误', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateWorkflowFileValues(
|
||||||
|
new Array(11).fill(null).map((_, index) => ({
|
||||||
|
fileName: `file-${index}.pdf`,
|
||||||
|
filePath: `https://example.com/${index}.pdf`,
|
||||||
|
size: 1024,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
).toThrow('最多上传 10 个文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传前校验文件数量限制', () => {
|
||||||
|
const currentFiles = new Array(9).fill(null).map((_, index) => ({
|
||||||
|
fileName: `file-${index}.pdf`,
|
||||||
|
filePath: `https://example.com/${index}.pdf`,
|
||||||
|
size: 1024,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateWorkflowFileSelection(currentFiles, [
|
||||||
|
new File(['a'], 'a.pdf'),
|
||||||
|
new File(['b'], 'b.pdf'),
|
||||||
|
]),
|
||||||
|
).toThrow('最多上传 10 个文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传前校验单文件大小限制', () => {
|
||||||
|
const oversizedFile = new File([new Uint8Array(6 * 1024 * 1024)], 'large.pdf');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateWorkflowFileSelection([], [oversizedFile]),
|
||||||
|
).toThrow('单个文件不能超过 5.0 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传前校验总大小限制', () => {
|
||||||
|
const currentFiles = [
|
||||||
|
{
|
||||||
|
fileName: 'existing.pdf',
|
||||||
|
filePath: 'https://example.com/existing.pdf',
|
||||||
|
size: 49 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const incomingFile = new File([new Uint8Array(2 * 1024 * 1024)], 'new.pdf');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateWorkflowFileSelection(currentFiles, [incomingFile]),
|
||||||
|
).toThrow('文件总大小不能超过 50.0 MB');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface WorkflowResourceLike {
|
|||||||
suffix?: string;
|
suffix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_FILE_LIMITS = {
|
||||||
|
maxCount: 10,
|
||||||
|
maxSingleSize: 5 * 1024 * 1024,
|
||||||
|
maxTotalSize: 50 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于上传结果构建工作流文件值。
|
* 基于上传结果构建工作流文件值。
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +84,87 @@ export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化单文件或多文件值。
|
||||||
|
*/
|
||||||
|
export function normalizeWorkflowFileValues(value: unknown): WorkflowFileValue[] {
|
||||||
|
const candidates = Array.isArray(value) ? value : [value];
|
||||||
|
const result: WorkflowFileValue[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!isWorkflowFileValue(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(candidate.filePath).trim();
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
result.push(candidate);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加文件并按 filePath 去重。
|
||||||
|
*/
|
||||||
|
export function appendWorkflowFileValues(
|
||||||
|
currentValue: unknown,
|
||||||
|
incomingValues: WorkflowFileValue[],
|
||||||
|
): WorkflowFileValue[] {
|
||||||
|
return normalizeWorkflowFileValues([
|
||||||
|
...normalizeWorkflowFileValues(currentValue),
|
||||||
|
...incomingValues,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在上传前基于原生 File 列表校验文件限制,避免无效文件先落库。
|
||||||
|
*/
|
||||||
|
export function validateWorkflowFileSelection(
|
||||||
|
currentValue: unknown,
|
||||||
|
incomingFiles: File[],
|
||||||
|
) {
|
||||||
|
const currentFiles = normalizeWorkflowFileValues(currentValue);
|
||||||
|
const totalCount = currentFiles.length + incomingFiles.length;
|
||||||
|
if (totalCount > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||||
|
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSize = currentFiles.reduce((sum, item) => sum + Number(item.size || 0), 0);
|
||||||
|
for (const file of incomingFiles) {
|
||||||
|
if (file.size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||||
|
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||||
|
}
|
||||||
|
totalSize += file.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||||
|
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验最终文件列表是否满足工作流限制。
|
||||||
|
*/
|
||||||
|
export function validateWorkflowFileValues(values: WorkflowFileValue[]) {
|
||||||
|
const normalized = normalizeWorkflowFileValues(values);
|
||||||
|
if (normalized.length > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||||
|
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||||
|
}
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const item of normalized) {
|
||||||
|
const size = Number(item.size || 0);
|
||||||
|
if (size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||||
|
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||||
|
}
|
||||||
|
totalSize += size;
|
||||||
|
}
|
||||||
|
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||||
|
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 友好格式化文件大小。
|
* 友好格式化文件大小。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -71,6 +71,47 @@
|
|||||||
const onRunTest = options.onRunTest;
|
const onRunTest = options.onRunTest;
|
||||||
|
|
||||||
const { updateEdgeData } = useUpdateEdgeData();
|
const { updateEdgeData } = useUpdateEdgeData();
|
||||||
|
const pendingParentRepairs = new Set<string>();
|
||||||
|
|
||||||
|
function scheduleOrphanParentRepair(nodeId: string) {
|
||||||
|
if (!nodeId || pendingParentRepairs.has(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingParentRepairs.add(nodeId);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
pendingParentRepairs.delete(nodeId);
|
||||||
|
const currentNode = getNode(nodeId);
|
||||||
|
const parentId = asString(currentNode?.parentId).trim();
|
||||||
|
if (!currentNode || !parentId || getNode(parentId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
svelteFlow.updateNode(nodeId, {
|
||||||
|
parentId: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolvedParentId(node: null | Node | undefined) {
|
||||||
|
const parentId = asString(node?.parentId).trim();
|
||||||
|
if (!parentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (getNode(parentId)) {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
if (node?.id) {
|
||||||
|
scheduleOrphanParentRepair(node.id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairOrphanParentNodes() {
|
||||||
|
store.getNodes().forEach((node) => {
|
||||||
|
if (asString(node.parentId).trim() && !getResolvedParentId(node)) {
|
||||||
|
scheduleOrphanParentRepair(node.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getEventClientPosition(event: any) {
|
function getEventClientPosition(event: any) {
|
||||||
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
|
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
|
||||||
@@ -353,24 +394,27 @@
|
|||||||
const isValidConnection = (conn: any) => {
|
const isValidConnection = (conn: any) => {
|
||||||
const sourceNode = getNode(conn.source)!;
|
const sourceNode = getNode(conn.source)!;
|
||||||
const targetNode = getNode(conn.target)!;
|
const targetNode = getNode(conn.target)!;
|
||||||
|
const sourceParentId = getResolvedParentId(sourceNode);
|
||||||
|
const targetParentId = getResolvedParentId(targetNode);
|
||||||
|
|
||||||
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
|
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
|
||||||
if (conn.sourceHandle === 'loop_handle' || sourceNode.parentId) {
|
if (conn.sourceHandle === 'loop_handle' || sourceParentId) {
|
||||||
const edges = svelteFlow.getEdges();
|
const edges = svelteFlow.getEdges();
|
||||||
for (let edge of edges) {
|
for (let edge of edges) {
|
||||||
if (edge.target === conn.target) {
|
if (edge.target === conn.target) {
|
||||||
const edgeSourceNode = getNode(edge.source) as Node;
|
const edgeSourceNode = getNode(edge.source) as Node;
|
||||||
if (conn.sourceHandle === 'loop_handle' && edgeSourceNode.parentId !== sourceNode.id) {
|
const edgeSourceParentId = getResolvedParentId(edgeSourceNode);
|
||||||
|
if (conn.sourceHandle === 'loop_handle' && edgeSourceParentId !== sourceNode.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sourceNode.parentId && edgeSourceNode.parentId !== sourceNode.parentId) {
|
if (sourceParentId && edgeSourceParentId !== sourceParentId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceNode.parentId && targetNode.parentId && targetNode.parentId !== sourceNode.id) {
|
if (!sourceParentId && targetParentId && targetParentId !== sourceNode.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,12 +490,14 @@
|
|||||||
closeNodePicker();
|
closeNodePicker();
|
||||||
|
|
||||||
const toNode = state.toNode as Node;
|
const toNode = state.toNode as Node;
|
||||||
if (toNode.parentId) {
|
const targetParentId = getResolvedParentId(toNode);
|
||||||
|
if (targetParentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromNode = state.fromNode as Node;
|
const fromNode = state.fromNode as Node;
|
||||||
const fromHande = state.fromHandle as any;
|
const fromHande = state.fromHandle as any;
|
||||||
|
const sourceParentId = getResolvedParentId(fromNode);
|
||||||
|
|
||||||
const newNode = {
|
const newNode = {
|
||||||
position: { ...toNode.position }
|
position: { ...toNode.position }
|
||||||
@@ -459,8 +505,8 @@
|
|||||||
|
|
||||||
if (fromHande.id === 'loop_handle') {
|
if (fromHande.id === 'loop_handle') {
|
||||||
newNode.parentId = fromNode.id;
|
newNode.parentId = fromNode.id;
|
||||||
} else if (fromNode.parentId) {
|
} else if (sourceParentId) {
|
||||||
newNode.parentId = fromNode.parentId;
|
newNode.parentId = sourceParentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newNode.parentId) {
|
if (newNode.parentId) {
|
||||||
@@ -514,10 +560,11 @@
|
|||||||
showEdgePanel = false;
|
showEdgePanel = false;
|
||||||
}
|
}
|
||||||
const targetNode = getNode(edge.target) as Node;
|
const targetNode = getNode(edge.target) as Node;
|
||||||
if (targetNode && targetNode.parentId) {
|
const targetParentId = getResolvedParentId(targetNode as Node);
|
||||||
|
if (targetNode && targetParentId) {
|
||||||
const nodeEdges = getEdgesByTarget(edge.target);
|
const nodeEdges = getEdgesByTarget(edge.target);
|
||||||
// const loopNode = getNode(targetNode.parentId) as Node;
|
// const loopNode = getNode(targetNode.parentId) as Node;
|
||||||
const { x, y } = getNodeRelativePosition(targetNode.parentId);
|
const { x, y } = getNodeRelativePosition(targetParentId);
|
||||||
if (nodeEdges.length === 0) {
|
if (nodeEdges.length === 0) {
|
||||||
svelteFlow.updateNode(targetNode.id, {
|
svelteFlow.updateNode(targetNode.id, {
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
@@ -543,7 +590,7 @@
|
|||||||
for (let i = 0; i < nodeEdges.length; i++) {
|
for (let i = 0; i < nodeEdges.length; i++) {
|
||||||
const edge = nodeEdges[i];
|
const edge = nodeEdges[i];
|
||||||
const sourceNode = getNode(edge.source) as Node;
|
const sourceNode = getNode(edge.source) as Node;
|
||||||
if (sourceNode.parentId || sourceNode.type === 'loopNode') {
|
if (getResolvedParentId(sourceNode) || sourceNode.type === 'loopNode') {
|
||||||
hasSameParent = true;
|
hasSameParent = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -680,6 +727,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
||||||
|
repairOrphanParentNodes();
|
||||||
if (!readonly) {
|
if (!readonly) {
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
window.addEventListener('paste', handleGlobalPaste);
|
window.addEventListener('paste', handleGlobalPaste);
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {contentTypes, startFormTypes} from '#consts';
|
import {contentTypes, startFormTypes} from '#consts';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter} from '#types';
|
||||||
|
import {store} from '#store/stores.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
|
import {
|
||||||
|
renameStartFieldReferencesInNodes,
|
||||||
|
removeStartFormField,
|
||||||
|
START_NODE_TYPE,
|
||||||
|
SYSTEM_START_PARAM_NAME,
|
||||||
|
updateStartFormField,
|
||||||
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { parameter, index }: {
|
const { parameter, index }: {
|
||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
@@ -20,10 +28,108 @@
|
|||||||
...(node?.current?.data?.parameters as Array<Parameter>)[index]
|
...(node?.current?.data?.parameters as Array<Parameter>)[index]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
let isSystemStartParam = $derived.by(() => {
|
||||||
|
return param.systemReserved === true && param.name === SYSTEM_START_PARAM_NAME;
|
||||||
|
});
|
||||||
|
let isStartNodeInputParam = $derived.by(() => {
|
||||||
|
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
|
||||||
|
});
|
||||||
|
let availableFormTypes = $derived.by(() => {
|
||||||
|
if (isSystemStartParam) {
|
||||||
|
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
|
||||||
|
}
|
||||||
|
return startFormTypes;
|
||||||
|
});
|
||||||
|
let displayFormTypeValue = $derived.by(() => {
|
||||||
|
if (isStartNodeInputParam && param.contentType === 'file') {
|
||||||
|
return ['file'];
|
||||||
|
}
|
||||||
|
return param.formType ? [param.formType] : [];
|
||||||
|
});
|
||||||
|
let displayParamName = $derived.by(() => {
|
||||||
|
if (isSystemStartParam) {
|
||||||
|
return '用户问题';
|
||||||
|
}
|
||||||
|
return param.name;
|
||||||
|
});
|
||||||
|
|
||||||
const { updateNodeData } = useSvelteFlow();
|
const { updateNodeData } = useSvelteFlow();
|
||||||
|
|
||||||
|
const toStartFormFieldType = (value: string) => {
|
||||||
|
if (value === 'input') {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimString = (value: unknown) => {
|
||||||
|
return value == null ? '' : String(value).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStartFieldPatch = (fieldKey: string, patch: Record<string, any>) => {
|
||||||
|
const currentFieldId = trimString(param.id);
|
||||||
|
store.updateNodes((nodes) => {
|
||||||
|
const edges = store.getEdges();
|
||||||
|
let nextFieldKey = fieldKey;
|
||||||
|
const nextNodes = nodes.map((currentNode) => {
|
||||||
|
if (currentNode.id !== currentNodeId) {
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
const nextData = updateStartFormField(currentNode.data as Record<string, any>, fieldKey, patch);
|
||||||
|
const nextSchema = Array.isArray(nextData.startFormSchema) ? nextData.startFormSchema : [];
|
||||||
|
const matchedField = nextSchema.find((field) => {
|
||||||
|
return (currentFieldId && trimString(field?.id) === currentFieldId)
|
||||||
|
|| trimString(field?.key) === fieldKey;
|
||||||
|
});
|
||||||
|
nextFieldKey = trimString(matchedField?.key) || fieldKey;
|
||||||
|
return {
|
||||||
|
...currentNode,
|
||||||
|
data: {
|
||||||
|
...((currentNode.data || {}) as Record<string, any>),
|
||||||
|
...nextData
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return renameStartFieldReferencesInNodes(
|
||||||
|
nextNodes,
|
||||||
|
edges,
|
||||||
|
currentNodeId,
|
||||||
|
fieldKey,
|
||||||
|
nextFieldKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateParameter = (key: string, value: any) => {
|
const updateParameter = (key: string, value: any) => {
|
||||||
|
if (isStartNodeInputParam) {
|
||||||
|
const fieldKey = param.name || '';
|
||||||
|
if (!fieldKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let patch: Record<string, any> = {};
|
||||||
|
if (key === 'name') {
|
||||||
|
patch = { key: value };
|
||||||
|
} else if (key === 'required') {
|
||||||
|
patch = { required: Boolean(value) };
|
||||||
|
} else if (key === 'formType') {
|
||||||
|
patch = { type: toStartFormFieldType(value) };
|
||||||
|
} else if (key === 'formLabel') {
|
||||||
|
patch = { label: value };
|
||||||
|
} else if (key === 'formDescription') {
|
||||||
|
patch = { description: value };
|
||||||
|
} else if (key === 'formPlaceholder') {
|
||||||
|
patch = { placeholder: value };
|
||||||
|
} else if (key === 'enums') {
|
||||||
|
patch = { options: Array.isArray(value) ? value : [] };
|
||||||
|
} else if (key === 'contentType') {
|
||||||
|
patch = { type: value === 'file' ? 'file' : 'text' };
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyStartFieldPatch(fieldKey, patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data.parameters as Array<Parameter>;
|
let parameters = node.data.parameters as Array<Parameter>;
|
||||||
(parameters[index] as any)[key] = value;
|
(parameters[index] as any)[key] = value;
|
||||||
@@ -51,6 +157,9 @@
|
|||||||
const updateFormType = (item: any) => {
|
const updateFormType = (item: any) => {
|
||||||
const newValue = item.value;
|
const newValue = item.value;
|
||||||
updateParameter('formType', newValue);
|
updateParameter('formType', newValue);
|
||||||
|
if (isSystemStartParam) {
|
||||||
|
updateParameter('contentType', 'text');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateContentType = (item: any) => {
|
const updateContentType = (item: any) => {
|
||||||
@@ -61,6 +170,13 @@
|
|||||||
|
|
||||||
let triggerObject: any;
|
let triggerObject: any;
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
if (isStartNodeInputParam) {
|
||||||
|
updateNodeData(currentNodeId, (node) => {
|
||||||
|
return removeStartFormField(node.data as Record<string, any>, param.name);
|
||||||
|
});
|
||||||
|
triggerObject?.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data.parameters as Array<Parameter>;
|
let parameters = node.data.parameters as Array<Parameter>;
|
||||||
parameters.splice(index, 1);
|
parameters.splice(index, 1);
|
||||||
@@ -75,7 +191,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
<Input style="width: 100%;" value={displayParamName} placeholder="请输入参数名称"
|
||||||
disabled={param.nameDisabled === true}
|
disabled={param.nameDisabled === true}
|
||||||
oninput={updateName} />
|
oninput={updateName} />
|
||||||
</div>
|
</div>
|
||||||
@@ -94,22 +210,21 @@
|
|||||||
<div class="input-more-setting">
|
<div class="input-more-setting">
|
||||||
{#if param.systemReserved}
|
{#if param.systemReserved}
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
系统入口参数,当前不可编辑。
|
系统入口参数,名称和必填规则固定,可调整展示标题、说明、占位符和输入方式。
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
数据内容:
|
数据内容:
|
||||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||||
value={param.contentType ? [param.contentType] : []}
|
value={param.contentType ? [param.contentType] : []}
|
||||||
disabled={param.systemReserved === true}
|
disabled={param.systemReserved === true || isStartNodeInputParam}
|
||||||
onSelect={updateContentType}
|
onSelect={updateContentType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
输入方式:
|
输入方式:
|
||||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||||
value={param.formType ? [param.formType] : []}
|
value={displayFormTypeValue}
|
||||||
disabled={param.systemReserved === true}
|
|
||||||
onSelect={updateFormType}
|
onSelect={updateFormType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,21 +242,21 @@
|
|||||||
数据标题:
|
数据标题:
|
||||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formLabel', event)
|
updateParamByEvent('formLabel', event)
|
||||||
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
}} value={param.formLabel} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
数据描述:
|
数据描述:
|
||||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formDescription', event)
|
updateParamByEvent('formDescription', event)
|
||||||
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
}} value={param.formDescription} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
占位符:
|
占位符:
|
||||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formPlaceholder', event)
|
updateParamByEvent('formPlaceholder', event)
|
||||||
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
}} value={param.formPlaceholder} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
} from '@xyflow/svelte';
|
} from '@xyflow/svelte';
|
||||||
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
|
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
|
||||||
import {type Snippet} from 'svelte';
|
import {type Snippet} from 'svelte';
|
||||||
|
import {onDestroy, onMount} from 'svelte';
|
||||||
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
|
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
|
||||||
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
@@ -73,6 +74,38 @@
|
|||||||
options.onNodeExecute?.(getNode(id)!);
|
options.onNodeExecute?.(getNode(id)!);
|
||||||
};
|
};
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
|
let wrapperElement: HTMLDivElement | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let resizeFrame = 0;
|
||||||
|
|
||||||
|
const scheduleUpdateNodeInternals = () => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resizeFrame) {
|
||||||
|
cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
|
updateNodeInternals(id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
scheduleUpdateNodeInternals();
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
scheduleUpdateNodeInternals();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(wrapperElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (resizeFrame) {
|
||||||
|
cancelAnimationFrame(resizeFrame);
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -250,7 +283,7 @@
|
|||||||
</NodeToolbar>
|
</NodeToolbar>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="tf-node-wrapper {wrapperClass}">
|
<div class="tf-node-wrapper {wrapperClass}" bind:this={wrapperElement}>
|
||||||
<div class="tf-node-wrapper-body">
|
<div class="tf-node-wrapper-body">
|
||||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {Heading} from '../base';
|
import {Heading, Input, Textarea} from '../base';
|
||||||
import {Button} from '../base/index.js';
|
import {Button} from '../base/index.js';
|
||||||
import {type NodeProps} from '@xyflow/svelte';
|
import {type NodeProps} from '@xyflow/svelte';
|
||||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
|
||||||
import {useSvelteFlow} from '@xyflow/svelte';
|
import {useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {
|
import {
|
||||||
ensureStartNodeParameters,
|
appendStartFormField,
|
||||||
hasSystemStartParameter,
|
|
||||||
isSystemStartParameter,
|
isSystemStartParameter,
|
||||||
|
normalizeStartNodeData,
|
||||||
|
normalizeStartFormMeta,
|
||||||
} from '../../utils/workflowNodeFields';
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
@@ -19,25 +19,39 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
const { addParameter } = useAddParameter();
|
|
||||||
const { updateNodeData } = useSvelteFlow();
|
const { updateNodeData } = useSvelteFlow();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const currentParameters = (data.parameters as Array<any>) || [];
|
const normalizedData = normalizeStartNodeData((data || {}) as Record<string, any>, {
|
||||||
if (!hasSystemStartParameter(currentParameters)) {
|
allowLegacyParametersOnly: true
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parameters = ensureStartNodeParameters(currentParameters);
|
|
||||||
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
|
|
||||||
updateNodeData(currentNodeId, {
|
|
||||||
parameters
|
|
||||||
});
|
});
|
||||||
|
const nextData = {
|
||||||
|
...(data || {}),
|
||||||
|
...normalizedData,
|
||||||
|
};
|
||||||
|
if (JSON.stringify(data || {}) !== JSON.stringify(nextData)) {
|
||||||
|
updateNodeData(currentNodeId, nextData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateStartFormMeta = (key: 'title' | 'description' | 'submitText', value: string) => {
|
||||||
|
updateNodeData(currentNodeId, (node) => {
|
||||||
|
const normalizedMeta = normalizeStartFormMeta(node.data?.startFormMeta as Record<string, any>);
|
||||||
|
return {
|
||||||
|
startFormMeta: {
|
||||||
|
...normalizedMeta,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
let currentParameters = $derived.by(() => {
|
let currentParameters = $derived.by(() => {
|
||||||
return ((data.parameters as Array<any>) || []);
|
return ((data.parameters as Array<any>) || []);
|
||||||
});
|
});
|
||||||
|
let startFormMeta = $derived.by(() => {
|
||||||
|
return normalizeStartFormMeta((data.startFormMeta as Record<string, any>) || {});
|
||||||
|
});
|
||||||
let systemParameters = $derived.by(() => {
|
let systemParameters = $derived.by(() => {
|
||||||
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
||||||
});
|
});
|
||||||
@@ -55,11 +69,48 @@
|
|||||||
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
<div class="param-section">
|
||||||
|
<div class="heading">
|
||||||
|
<Heading level={3}>开场表单</Heading>
|
||||||
|
</div>
|
||||||
|
<div class="section-description">配置开始问答的标题、说明和提交按钮文案。</div>
|
||||||
|
<div class="meta-form">
|
||||||
|
<div class="meta-form-item">
|
||||||
|
<div class="meta-label">标题</div>
|
||||||
|
<Input
|
||||||
|
style="width: 100%;"
|
||||||
|
value={startFormMeta.title}
|
||||||
|
placeholder="请输入表单标题"
|
||||||
|
oninput={(event) => updateStartFormMeta('title', (event.target as HTMLInputElement)?.value || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="meta-form-item">
|
||||||
|
<div class="meta-label">说明</div>
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
style="width: 100%;"
|
||||||
|
value={startFormMeta.description}
|
||||||
|
placeholder="请输入表单说明"
|
||||||
|
oninput={(event) => updateStartFormMeta('description', (event.target as HTMLTextAreaElement)?.value || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="meta-form-item">
|
||||||
|
<div class="meta-label">提交按钮</div>
|
||||||
|
<Input
|
||||||
|
style="width: 100%;"
|
||||||
|
value={startFormMeta.submitText}
|
||||||
|
placeholder="请输入按钮文案"
|
||||||
|
oninput={(event) => updateStartFormMeta('submitText', (event.target as HTMLInputElement)?.value || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="param-section">
|
<div class="param-section">
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Heading level={3}>系统入口</Heading>
|
<Heading level={3}>系统入口</Heading>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
<div class="section-description">固定主问题字段,名称与必填规则固定,可调整展示方式与提示文案。</div>
|
||||||
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,14 +118,20 @@
|
|||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Heading level={3}>自定义参数</Heading>
|
<Heading level={3}>自定义参数</Heading>
|
||||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
|
updateNodeData(currentNodeId, (node) => {
|
||||||
|
return appendStartFormField(node.data as Record<string, any>, {
|
||||||
|
label: '新字段',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '请输入内容',
|
||||||
|
});
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
|
<div class="section-description">这里添加额外收集字段,字段顺序按列表顺序保存,首版不提供拖拽排序。</div>
|
||||||
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
||||||
</div>
|
</div>
|
||||||
</NodeWrapper>
|
</NodeWrapper>
|
||||||
@@ -93,6 +150,23 @@
|
|||||||
border-top: 1px solid var(--tf-border-color);
|
border-top: 1px solid var(--tf-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-form-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tf-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.section-description {
|
.section-description {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const startFormTypes = [
|
|||||||
{ label: '下拉菜单', value: 'select' },
|
{ label: '下拉菜单', value: 'select' },
|
||||||
{ label: '单选', value: 'radio' },
|
{ label: '单选', value: 'radio' },
|
||||||
{ label: '多选', value: 'checkbox' },
|
{ label: '多选', value: 'checkbox' },
|
||||||
|
{ label: '文件上传', value: 'file' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const confirmFormTypes = [
|
export const confirmFormTypes = [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { Edge, Node } from '@xyflow/svelte';
|
import type { Edge, Node } from '@xyflow/svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendStartFormField,
|
||||||
buildAutoBindingPatch,
|
buildAutoBindingPatch,
|
||||||
buildSequentialFieldBindingPatches,
|
buildSequentialFieldBindingPatches,
|
||||||
buildFieldBindingPatch,
|
buildFieldBindingPatch,
|
||||||
@@ -12,7 +13,11 @@ import {
|
|||||||
createInitialWorkflowData,
|
createInitialWorkflowData,
|
||||||
ensureStartNodeParameters,
|
ensureStartNodeParameters,
|
||||||
FIELD_BINDING_META_KEY,
|
FIELD_BINDING_META_KEY,
|
||||||
|
normalizeStartNodeData,
|
||||||
normalizeWorkflowStartNodes,
|
normalizeWorkflowStartNodes,
|
||||||
|
renameStartFieldReferencesInNodes,
|
||||||
|
removeStartFormField,
|
||||||
|
updateStartFormField,
|
||||||
} from './workflowNodeFields';
|
} from './workflowNodeFields';
|
||||||
|
|
||||||
describe('workflow node fields', () => {
|
describe('workflow node fields', () => {
|
||||||
@@ -28,6 +33,246 @@ describe('workflow node fields', () => {
|
|||||||
expect(parameters[0]?.name).toBe('user_input');
|
expect(parameters[0]?.name).toBe('user_input');
|
||||||
expect(parameters[0]?.systemReserved).toBe(true);
|
expect(parameters[0]?.systemReserved).toBe(true);
|
||||||
expect(parameters[0]?.required).toBe(true);
|
expect(parameters[0]?.required).toBe(true);
|
||||||
|
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
|
||||||
|
title: '开始问答',
|
||||||
|
submitText: '开始',
|
||||||
|
});
|
||||||
|
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
|
||||||
|
key: 'user_input',
|
||||||
|
type: 'textarea',
|
||||||
|
systemReserved: true,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends custom start form field into schema source of truth', () => {
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const startNode = initial.nodes[0]!;
|
||||||
|
const nextData = appendStartFormField(startNode.data as Record<string, any>, {
|
||||||
|
type: 'select',
|
||||||
|
options: ['售前', '售后'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextData.startFormSchema).toHaveLength(2);
|
||||||
|
expect(nextData.startFormSchema?.[1]).toMatchObject({
|
||||||
|
key: 'select_field',
|
||||||
|
label: '下拉字段',
|
||||||
|
type: 'select',
|
||||||
|
options: ['售前', '售后'],
|
||||||
|
});
|
||||||
|
expect((nextData.parameters as any[])?.[1]).toMatchObject({
|
||||||
|
name: 'select_field',
|
||||||
|
formLabel: '下拉字段',
|
||||||
|
formType: 'select',
|
||||||
|
enums: ['售前', '售后'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates generated field key when switching field type', () => {
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateStartFormField(appended, 'text_field', {
|
||||||
|
type: 'file',
|
||||||
|
placeholder: '请选择文件',
|
||||||
|
});
|
||||||
|
expect(updated.startFormSchema?.find((item: any) => item.key === 'file_field'))
|
||||||
|
.toMatchObject({
|
||||||
|
key: 'file_field',
|
||||||
|
label: '文件字段',
|
||||||
|
type: 'file',
|
||||||
|
placeholder: '请选择文件',
|
||||||
|
});
|
||||||
|
expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
|
||||||
|
.toMatchObject({
|
||||||
|
name: 'file_field',
|
||||||
|
dataType: 'File',
|
||||||
|
contentType: 'file',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates and removes custom start form fields through schema source of truth', () => {
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||||
|
key: 'attachments',
|
||||||
|
label: '附件',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '请输入内容',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateStartFormField(appended, 'attachments', {
|
||||||
|
type: 'file',
|
||||||
|
placeholder: '请选择文件',
|
||||||
|
});
|
||||||
|
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
|
||||||
|
.toMatchObject({
|
||||||
|
type: 'file',
|
||||||
|
placeholder: '请选择文件',
|
||||||
|
});
|
||||||
|
expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
|
||||||
|
.toMatchObject({
|
||||||
|
dataType: 'File',
|
||||||
|
contentType: 'file',
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = removeStartFormField(updated, 'attachments');
|
||||||
|
expect(removed.startFormSchema).toHaveLength(1);
|
||||||
|
expect((removed.parameters as any[]).map((item) => item.name)).toEqual([
|
||||||
|
'user_input',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps custom start field parameter id stable when renaming key', () => {
|
||||||
|
const initial = createInitialWorkflowData();
|
||||||
|
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||||
|
type: 'text',
|
||||||
|
});
|
||||||
|
const previousField = appended.startFormSchema?.find((item: any) => item.key === 'text_field');
|
||||||
|
const previousParameter = (appended.parameters as any[]).find(
|
||||||
|
(item) => item.name === 'text_field',
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = updateStartFormField(appended, 'text_field', {
|
||||||
|
key: 'topic',
|
||||||
|
label: '主题',
|
||||||
|
});
|
||||||
|
const nextField = updated.startFormSchema?.find((item: any) => item.key === 'topic');
|
||||||
|
const nextParameter = (updated.parameters as any[]).find(
|
||||||
|
(item) => item.name === 'topic',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(previousField?.id).toBeTruthy();
|
||||||
|
expect(nextField?.id).toBe(previousField?.id);
|
||||||
|
expect(nextParameter?.id).toBe(previousParameter?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames downstream token and managed references when start field key changes', () => {
|
||||||
|
const initialStartData = appendStartFormField(
|
||||||
|
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const startNode: Node = {
|
||||||
|
id: 'start_1',
|
||||||
|
type: 'startNode',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '开始节点',
|
||||||
|
...initialStartData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const renamedStartData = updateStartFormField(
|
||||||
|
startNode.data as Record<string, any>,
|
||||||
|
'text_field',
|
||||||
|
{
|
||||||
|
key: 'topic',
|
||||||
|
label: '主题',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const llmNode: Node = {
|
||||||
|
id: 'llm_1',
|
||||||
|
type: 'llmNode',
|
||||||
|
position: { x: 120, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '大模型',
|
||||||
|
userPrompt: '请围绕 {{start_1.text_field}} 生成内容',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'param_1',
|
||||||
|
name: 'start_1.text_field',
|
||||||
|
ref: 'start_1.text_field',
|
||||||
|
refType: 'ref',
|
||||||
|
autoManaged: true,
|
||||||
|
formLabel: '开始节点 > 文本字段',
|
||||||
|
displayName: '开始节点 > 文本字段',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[FIELD_BINDING_META_KEY]: {
|
||||||
|
userPrompt: {
|
||||||
|
autoFilledFrom: 'start_1.text_field',
|
||||||
|
userModified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const edges: Edge[] = [
|
||||||
|
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextNodes = renameStartFieldReferencesInNodes(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...startNode,
|
||||||
|
data: {
|
||||||
|
...(startNode.data as Record<string, any>),
|
||||||
|
...renamedStartData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llmNode,
|
||||||
|
],
|
||||||
|
edges,
|
||||||
|
'start_1',
|
||||||
|
'text_field',
|
||||||
|
'topic',
|
||||||
|
);
|
||||||
|
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
|
||||||
|
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
|
||||||
|
|
||||||
|
expect(nextLlmNode.data?.userPrompt).toBe('请围绕 {{start_1.topic}} 生成内容');
|
||||||
|
expect(nextParameter?.name).toBe('start_1.topic');
|
||||||
|
expect(nextParameter?.ref).toBe('start_1.topic');
|
||||||
|
expect(nextParameter?.displayName).toBe('开始节点 > topic');
|
||||||
|
expect((nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]?.userPrompt).toMatchObject({
|
||||||
|
autoFilledFrom: 'start_1.topic',
|
||||||
|
userModified: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom start parameter name for reference display', () => {
|
||||||
|
const startData = appendStartFormField(
|
||||||
|
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||||
|
{
|
||||||
|
key: 'topic_name',
|
||||||
|
label: '下拉字段',
|
||||||
|
type: 'select',
|
||||||
|
options: ['A', 'B'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const startNode: Node = {
|
||||||
|
id: 'start_1',
|
||||||
|
type: 'startNode',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '开始节点',
|
||||||
|
...startData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const llmNode: Node = {
|
||||||
|
id: 'llm_1',
|
||||||
|
type: 'llmNode',
|
||||||
|
position: { x: 120, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '大模型',
|
||||||
|
parameters: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const edges: Edge[] = [
|
||||||
|
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||||
|
];
|
||||||
|
|
||||||
|
const parameters = buildEditorReferenceParameters(
|
||||||
|
'llm_1',
|
||||||
|
[startNode, llmNode],
|
||||||
|
edges,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const topicParameter = parameters.find((item) => item.name === 'start_1.topic_name');
|
||||||
|
|
||||||
|
expect(topicParameter?.displayName).toBe('开始节点 > topic_name');
|
||||||
|
expect(topicParameter?.formLabel).toBe('开始节点 > topic_name');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds upstream reference candidates from start node', () => {
|
it('builds upstream reference candidates from start node', () => {
|
||||||
@@ -69,6 +314,120 @@ describe('workflow node fields', () => {
|
|||||||
).toBe('流程开始 > 用户问题');
|
).toBe('流程开始 > 用户问题');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses output parameter name for reference display', () => {
|
||||||
|
const knowledgeNode: Node = {
|
||||||
|
id: 'knowledge_1',
|
||||||
|
type: 'knowledgeNode',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '知识库',
|
||||||
|
outputDefs: [
|
||||||
|
{
|
||||||
|
name: 'documents',
|
||||||
|
formLabel: '文档列表',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'content',
|
||||||
|
formLabel: '正文内容',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const llmNode: Node = {
|
||||||
|
id: 'llm_1',
|
||||||
|
type: 'llmNode',
|
||||||
|
position: { x: 120, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '大模型',
|
||||||
|
parameters: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const edges: Edge[] = [
|
||||||
|
{ id: 'edge_1', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||||
|
];
|
||||||
|
|
||||||
|
const parameters = buildEditorReferenceParameters(
|
||||||
|
'llm_1',
|
||||||
|
[knowledgeNode, llmNode],
|
||||||
|
edges,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const documentsParameter = parameters.find((item) => item.name === 'knowledge_1.documents');
|
||||||
|
const contentParameter = parameters.find((item) => item.name === 'knowledge_1.documents.content');
|
||||||
|
|
||||||
|
expect(documentsParameter?.displayName).toBe('知识库 > documents');
|
||||||
|
expect(documentsParameter?.formLabel).toBe('知识库 > documents');
|
||||||
|
expect(contentParameter?.displayName).toBe('知识库 > documents.content');
|
||||||
|
expect(contentParameter?.formLabel).toBe('知识库 > documents.content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses document node child outputs for reference display', () => {
|
||||||
|
const documentNode: Node = {
|
||||||
|
id: 'doc_1',
|
||||||
|
type: 'document-node',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '文档解析',
|
||||||
|
outputDefs: [
|
||||||
|
{
|
||||||
|
name: 'documents',
|
||||||
|
formLabel: '文档列表',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'fileName',
|
||||||
|
formLabel: '文件名',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'content',
|
||||||
|
formLabel: '正文内容',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'contents',
|
||||||
|
formLabel: '逐文件内容',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'count',
|
||||||
|
formLabel: '数量',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const llmNode: Node = {
|
||||||
|
id: 'llm_1',
|
||||||
|
type: 'llmNode',
|
||||||
|
position: { x: 120, y: 0 },
|
||||||
|
data: {
|
||||||
|
title: '大模型',
|
||||||
|
parameters: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const edges: Edge[] = [
|
||||||
|
{ id: 'edge_1', source: 'doc_1', target: 'llm_1' } as Edge,
|
||||||
|
];
|
||||||
|
|
||||||
|
const parameters = buildEditorReferenceParameters(
|
||||||
|
'llm_1',
|
||||||
|
[documentNode, llmNode],
|
||||||
|
edges,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName)
|
||||||
|
.toBe('文档解析 > documents');
|
||||||
|
expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName)
|
||||||
|
.toBe('文档解析 > documents.fileName');
|
||||||
|
expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName)
|
||||||
|
.toBe('文档解析 > documents.content');
|
||||||
|
expect(parameters.find((item) => item.name === 'doc_1.contents')?.displayName)
|
||||||
|
.toBe('文档解析 > contents');
|
||||||
|
expect(parameters.find((item) => item.name === 'doc_1.count')?.displayName)
|
||||||
|
.toBe('文档解析 > count');
|
||||||
|
});
|
||||||
|
|
||||||
it('applies default binding to llm user prompt after connect', () => {
|
it('applies default binding to llm user prompt after connect', () => {
|
||||||
const startNode: Node = {
|
const startNode: Node = {
|
||||||
id: 'start_1',
|
id: 'start_1',
|
||||||
@@ -436,6 +795,86 @@ describe('workflow node fields', () => {
|
|||||||
expect(result).toEqual(legacyParameters);
|
expect(result).toEqual(legacyParameters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes start node schema from parameters', () => {
|
||||||
|
const normalized = normalizeStartNodeData({
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'system_1',
|
||||||
|
name: 'user_input',
|
||||||
|
refType: 'input',
|
||||||
|
required: true,
|
||||||
|
formType: 'input',
|
||||||
|
formLabel: '问题',
|
||||||
|
formPlaceholder: '请输入问题',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file_1',
|
||||||
|
name: 'attachments',
|
||||||
|
refType: 'input',
|
||||||
|
dataType: 'File',
|
||||||
|
contentType: 'file',
|
||||||
|
formLabel: '附件',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.startFormSchema).toHaveLength(2);
|
||||||
|
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||||
|
key: 'user_input',
|
||||||
|
type: 'text',
|
||||||
|
systemReserved: true,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
expect(normalized.startFormSchema[1]).toMatchObject({
|
||||||
|
key: 'attachments',
|
||||||
|
type: 'file',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
expect(normalized.parameters[1]).toMatchObject({
|
||||||
|
name: 'attachments',
|
||||||
|
contentType: 'file',
|
||||||
|
dataType: 'File',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces invalid user_input schema back to required text input', () => {
|
||||||
|
const normalized = normalizeStartNodeData({
|
||||||
|
startFormSchema: [
|
||||||
|
{
|
||||||
|
key: 'user_input',
|
||||||
|
label: '主问题',
|
||||||
|
type: 'radio',
|
||||||
|
required: false,
|
||||||
|
options: ['A', 'B'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
startFormMeta: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
submitText: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||||
|
key: 'user_input',
|
||||||
|
type: 'textarea',
|
||||||
|
required: true,
|
||||||
|
systemReserved: true,
|
||||||
|
});
|
||||||
|
expect(normalized.startFormMeta).toMatchObject({
|
||||||
|
title: '开始问答',
|
||||||
|
description: '请先补充必要信息,再开始执行工作流。',
|
||||||
|
submitText: '开始',
|
||||||
|
});
|
||||||
|
expect(normalized.parameters[0]).toMatchObject({
|
||||||
|
name: 'user_input',
|
||||||
|
formType: 'textarea',
|
||||||
|
required: true,
|
||||||
|
contentType: 'text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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 = normalizeWorkflowStartNodes({
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -466,6 +905,9 @@ describe('workflow node fields', () => {
|
|||||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(normalizedWorkflow.nodes[0]?.data?.startFormSchema?.[0]?.key).toBe(
|
||||||
|
'user_input',
|
||||||
|
);
|
||||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,66 @@ export type SingleRunModel =
|
|||||||
fields: SingleRunFieldDescriptor[];
|
fields: SingleRunFieldDescriptor[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StartFormFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'textarea'
|
||||||
|
| 'radio'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'select'
|
||||||
|
| 'file';
|
||||||
|
|
||||||
|
export type StartFormMeta = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
submitText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StartFormFieldSchema = {
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: StartFormFieldType;
|
||||||
|
required: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
description?: string;
|
||||||
|
defaultValue?: string | string[];
|
||||||
|
options: string[];
|
||||||
|
systemReserved?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||||
|
'text',
|
||||||
|
'textarea',
|
||||||
|
'radio',
|
||||||
|
'checkbox',
|
||||||
|
'select',
|
||||||
|
'file',
|
||||||
|
]);
|
||||||
|
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||||
|
'radio',
|
||||||
|
'checkbox',
|
||||||
|
'select',
|
||||||
|
]);
|
||||||
|
const DEFAULT_START_FORM_TITLE = '开始问答';
|
||||||
|
const DEFAULT_START_FORM_DESCRIPTION = '请先补充必要信息,再开始执行工作流。';
|
||||||
|
const DEFAULT_START_FORM_SUBMIT_TEXT = '开始';
|
||||||
|
const START_FORM_DEFAULT_FIELD_KEY_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||||
|
text: 'text_field',
|
||||||
|
textarea: 'textarea_field',
|
||||||
|
radio: 'radio_field',
|
||||||
|
checkbox: 'checkbox_field',
|
||||||
|
select: 'select_field',
|
||||||
|
file: 'file_field',
|
||||||
|
};
|
||||||
|
const START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||||
|
text: '文本字段',
|
||||||
|
textarea: '长文本字段',
|
||||||
|
radio: '单选字段',
|
||||||
|
checkbox: '多选字段',
|
||||||
|
select: '下拉字段',
|
||||||
|
file: '文件字段',
|
||||||
|
};
|
||||||
|
|
||||||
type FieldBindingMeta = Record<
|
type FieldBindingMeta = Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -43,6 +103,25 @@ function asString(value: unknown) {
|
|||||||
return value == null ? '' : String(value);
|
return value == null ? '' : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimString(value: unknown) {
|
||||||
|
return asString(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStringArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.map((item) => asString(item).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedStartFormFieldType(value: unknown): value is StartFormFieldType {
|
||||||
|
return START_FORM_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionFieldType(value: unknown): value is StartFormFieldType {
|
||||||
|
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||||
|
}
|
||||||
|
|
||||||
function cloneParameter(parameter: Parameter): Parameter {
|
function cloneParameter(parameter: Parameter): Parameter {
|
||||||
return {
|
return {
|
||||||
...parameter,
|
...parameter,
|
||||||
@@ -74,6 +153,13 @@ function getParameterLabel(parameter?: Parameter | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReferenceParameterLabel(parameter?: Parameter | null) {
|
||||||
|
if (isSystemStartParameter(parameter)) {
|
||||||
|
return getParameterLabel(parameter);
|
||||||
|
}
|
||||||
|
return asString(parameter?.name).trim() || getParameterLabel(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
function buildDisconnectedDisplayName(parameter: Parameter) {
|
function buildDisconnectedDisplayName(parameter: Parameter) {
|
||||||
const displayName =
|
const displayName =
|
||||||
asString(parameter.displayName).trim() ||
|
asString(parameter.displayName).trim() ||
|
||||||
@@ -127,8 +213,8 @@ function flattenOutputDefs(
|
|||||||
|
|
||||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||||
const label = parentLabel
|
const label = parentLabel
|
||||||
? `${parentLabel}.${getParameterLabel(parameter)}`
|
? `${parentLabel}.${getReferenceParameterLabel(parameter)}`
|
||||||
: getParameterLabel(parameter);
|
: getReferenceParameterLabel(parameter);
|
||||||
const fullRef = `${node.id}.${path}`;
|
const fullRef = `${node.id}.${path}`;
|
||||||
const baseCandidate: Parameter = ensureParameterId({
|
const baseCandidate: Parameter = ensureParameterId({
|
||||||
name: fullRef,
|
name: fullRef,
|
||||||
@@ -167,8 +253,8 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
|
|||||||
name: `${node.id}.${asString(parameter.name).trim()}`,
|
name: `${node.id}.${asString(parameter.name).trim()}`,
|
||||||
ref: `${node.id}.${asString(parameter.name).trim()}`,
|
ref: `${node.id}.${asString(parameter.name).trim()}`,
|
||||||
refType: 'ref',
|
refType: 'ref',
|
||||||
displayName: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
displayName: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||||
formLabel: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
formLabel: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||||
nameDisabled: true,
|
nameDisabled: true,
|
||||||
dataTypeDisabled: true,
|
dataTypeDisabled: true,
|
||||||
deleteDisabled: true,
|
deleteDisabled: true,
|
||||||
@@ -233,17 +319,115 @@ function toManagedRefParameter(refPath: string, candidate?: Parameter): Paramete
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSystemStartParameter(): Parameter {
|
function normalizeSystemStartFormType(value: unknown): 'input' | 'textarea' {
|
||||||
|
return trimString(value) === 'input' ? 'input' : 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStartFormFieldUiType(
|
||||||
|
value: unknown,
|
||||||
|
fallback: StartFormFieldType = 'text',
|
||||||
|
): StartFormFieldType {
|
||||||
|
const normalized = trimString(value);
|
||||||
|
if (normalized === 'input') {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
if (isSupportedStartFormFieldType(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultStartFormFieldKeyBase(type: StartFormFieldType) {
|
||||||
|
return START_FORM_DEFAULT_FIELD_KEY_BY_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultStartFormFieldLabel(type: StartFormFieldType) {
|
||||||
|
return START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUniqueStartFormFieldKey(
|
||||||
|
type: StartFormFieldType,
|
||||||
|
existingKeys: string[],
|
||||||
|
) {
|
||||||
|
const normalizedExistingKeys = new Set(
|
||||||
|
existingKeys.map((item) => trimString(item)).filter(Boolean),
|
||||||
|
);
|
||||||
|
const baseKey = getDefaultStartFormFieldKeyBase(type);
|
||||||
|
if (!normalizedExistingKeys.has(baseKey)) {
|
||||||
|
return baseKey;
|
||||||
|
}
|
||||||
|
let index = 2;
|
||||||
|
while (normalizedExistingKeys.has(`${baseKey}_${index}`)) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${baseKey}_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoGeneratedStartFormFieldKey(key: unknown) {
|
||||||
|
const normalizedKey = trimString(key);
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^field_[A-Za-z0-9]+$/.test(normalizedKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.values(START_FORM_DEFAULT_FIELD_KEY_BY_TYPE).some((baseKey) => {
|
||||||
|
return normalizedKey === baseKey || new RegExp(`^${baseKey}_[0-9]+$`).test(normalizedKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefaultStartFormFieldLabel(label: unknown) {
|
||||||
|
const normalizedLabel = trimString(label);
|
||||||
|
if (!normalizedLabel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedLabel === '新字段') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.values(START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE).includes(normalizedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStartFormFieldId(
|
||||||
|
fieldId: unknown,
|
||||||
|
existingParameter?: Parameter | null,
|
||||||
|
) {
|
||||||
|
return trimString(fieldId) || trimString(existingParameter?.id) || genShortId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultStartFormMeta(): StartFormMeta {
|
||||||
|
return {
|
||||||
|
title: DEFAULT_START_FORM_TITLE,
|
||||||
|
description: DEFAULT_START_FORM_DESCRIPTION,
|
||||||
|
submitText: DEFAULT_START_FORM_SUBMIT_TEXT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStartFormMeta(meta?: Partial<StartFormMeta> | null): StartFormMeta {
|
||||||
|
const fallback = createDefaultStartFormMeta();
|
||||||
|
return {
|
||||||
|
title: trimString(meta?.title) || fallback.title,
|
||||||
|
description: trimString(meta?.description) || fallback.description,
|
||||||
|
submitText: trimString(meta?.submitText) || fallback.submitText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSystemStartParameter(parameter?: Parameter | null): Parameter {
|
||||||
|
const fallbackLabel = SYSTEM_START_PARAM_LABEL;
|
||||||
|
const formLabel = trimString(parameter?.formLabel) || fallbackLabel;
|
||||||
|
const formType = normalizeSystemStartFormType(parameter?.formType);
|
||||||
return ensureParameterId({
|
return ensureParameterId({
|
||||||
|
...cloneParameter(parameter || {}),
|
||||||
name: SYSTEM_START_PARAM_NAME,
|
name: SYSTEM_START_PARAM_NAME,
|
||||||
dataType: 'String',
|
dataType: 'String',
|
||||||
refType: 'input',
|
refType: 'input',
|
||||||
required: true,
|
required: true,
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
formType: 'input',
|
formType,
|
||||||
formLabel: SYSTEM_START_PARAM_LABEL,
|
formLabel,
|
||||||
formPlaceholder: '请输入用户问题',
|
formDescription: asString(parameter?.formDescription),
|
||||||
displayName: `流程开始 > ${SYSTEM_START_PARAM_LABEL}`,
|
formPlaceholder: trimString(parameter?.formPlaceholder) || '请输入用户问题',
|
||||||
|
defaultValue: asString(parameter?.defaultValue),
|
||||||
|
displayName: `流程开始 > ${formLabel}`,
|
||||||
nameDisabled: true,
|
nameDisabled: true,
|
||||||
dataTypeDisabled: true,
|
dataTypeDisabled: true,
|
||||||
deleteDisabled: true,
|
deleteDisabled: true,
|
||||||
@@ -252,6 +436,399 @@ export function createSystemStartParameter(): Parameter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartFormFieldType {
|
||||||
|
if (
|
||||||
|
trimString(parameter?.contentType) === 'file' ||
|
||||||
|
trimString(parameter?.dataType).toLowerCase() === 'file'
|
||||||
|
) {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
const formType = trimString(parameter?.formType);
|
||||||
|
if (formType === 'textarea') {
|
||||||
|
return 'textarea';
|
||||||
|
}
|
||||||
|
if (formType === 'select') {
|
||||||
|
return 'select';
|
||||||
|
}
|
||||||
|
if (formType === 'radio') {
|
||||||
|
return 'radio';
|
||||||
|
}
|
||||||
|
if (formType === 'checkbox') {
|
||||||
|
return 'checkbox';
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTypeToDataType(type: StartFormFieldType) {
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return 'Array';
|
||||||
|
}
|
||||||
|
if (type === 'file') {
|
||||||
|
return 'File';
|
||||||
|
}
|
||||||
|
return 'String';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTypeToContentType(type: StartFormFieldType) {
|
||||||
|
return type === 'file' ? 'file' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTypeToFormType(type: StartFormFieldType) {
|
||||||
|
if (type === 'text') {
|
||||||
|
return 'input';
|
||||||
|
}
|
||||||
|
if (type === 'file') {
|
||||||
|
return 'input';
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStartFormField(
|
||||||
|
field: Partial<StartFormFieldSchema> | null | undefined,
|
||||||
|
existingParameter?: Parameter,
|
||||||
|
): StartFormFieldSchema | null {
|
||||||
|
const key = trimString(field?.key) || trimString(existingParameter?.name);
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackType = parameterTypeToStartFormFieldType(existingParameter);
|
||||||
|
const requestedType = isSupportedStartFormFieldType(field?.type)
|
||||||
|
? field?.type
|
||||||
|
: fallbackType;
|
||||||
|
const isSystemField =
|
||||||
|
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
|
||||||
|
const type = isSystemField
|
||||||
|
? requestedType === 'text'
|
||||||
|
? 'text'
|
||||||
|
: 'textarea'
|
||||||
|
: requestedType;
|
||||||
|
const options = isOptionFieldType(type)
|
||||||
|
? ensureStringArray(field?.options || existingParameter?.enums)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const defaultValue = Array.isArray(field?.defaultValue)
|
||||||
|
? ensureStringArray(field?.defaultValue)
|
||||||
|
: asString(field?.defaultValue ?? existingParameter?.defaultValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ensureStartFormFieldId(field?.id, existingParameter),
|
||||||
|
key,
|
||||||
|
label:
|
||||||
|
trimString(field?.label) ||
|
||||||
|
trimString(existingParameter?.formLabel) ||
|
||||||
|
trimString(existingParameter?.displayName) ||
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
|
||||||
|
placeholder:
|
||||||
|
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
|
||||||
|
description:
|
||||||
|
trimString(field?.description) || trimString(existingParameter?.formDescription),
|
||||||
|
defaultValue,
|
||||||
|
options,
|
||||||
|
systemReserved: isSystemField,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterToStartFormField(parameter?: Parameter | null) {
|
||||||
|
return normalizeStartFormField(
|
||||||
|
{
|
||||||
|
id: trimString(parameter?.id),
|
||||||
|
key: trimString(parameter?.name),
|
||||||
|
label: trimString(parameter?.formLabel),
|
||||||
|
type: parameterTypeToStartFormFieldType(parameter),
|
||||||
|
required: Boolean(parameter?.required),
|
||||||
|
placeholder: trimString(parameter?.formPlaceholder),
|
||||||
|
description: trimString(parameter?.formDescription),
|
||||||
|
defaultValue: parameter?.defaultValue,
|
||||||
|
options: parameter?.enums || [],
|
||||||
|
systemReserved: isSystemStartParameter(parameter),
|
||||||
|
},
|
||||||
|
parameter || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFormFieldToParameter(
|
||||||
|
field: StartFormFieldSchema,
|
||||||
|
existingParameter?: Parameter,
|
||||||
|
): Parameter {
|
||||||
|
if (field.systemReserved || field.key === SYSTEM_START_PARAM_NAME) {
|
||||||
|
return normalizeSystemStartParameter({
|
||||||
|
...existingParameter,
|
||||||
|
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||||
|
formLabel: field.label,
|
||||||
|
formDescription: field.description,
|
||||||
|
formPlaceholder: field.placeholder,
|
||||||
|
formType: field.type === 'text' ? 'input' : 'textarea',
|
||||||
|
defaultValue: Array.isArray(field.defaultValue)
|
||||||
|
? field.defaultValue.join('\n')
|
||||||
|
: asString(field.defaultValue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLabel = trimString(field.label) || field.key;
|
||||||
|
return ensureParameterId({
|
||||||
|
...cloneParameter(existingParameter || {}),
|
||||||
|
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||||
|
name: field.key,
|
||||||
|
dataType: fieldTypeToDataType(field.type),
|
||||||
|
refType: 'input',
|
||||||
|
required: Boolean(field.required),
|
||||||
|
contentType: fieldTypeToContentType(field.type),
|
||||||
|
formType: fieldTypeToFormType(field.type),
|
||||||
|
formLabel,
|
||||||
|
formDescription: asString(field.description),
|
||||||
|
formPlaceholder: asString(field.placeholder),
|
||||||
|
defaultValue: Array.isArray(field.defaultValue)
|
||||||
|
? field.defaultValue.join('\n')
|
||||||
|
: asString(field.defaultValue),
|
||||||
|
enums: isOptionFieldType(field.type) ? ensureStringArray(field.options) : [],
|
||||||
|
displayName: `流程开始 > ${formLabel}`,
|
||||||
|
systemReserved: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStartFormSchema(
|
||||||
|
schema?: Partial<StartFormFieldSchema>[] | null,
|
||||||
|
parameters?: Parameter[] | null,
|
||||||
|
) {
|
||||||
|
const parameterCandidates = Array.isArray(parameters)
|
||||||
|
? parameters.map(ensureParameterId)
|
||||||
|
: [];
|
||||||
|
const parameterMap = new Map(
|
||||||
|
parameterCandidates
|
||||||
|
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||||
|
.filter(([key]) => key.length > 0),
|
||||||
|
);
|
||||||
|
const parameterIdMap = new Map(
|
||||||
|
parameterCandidates
|
||||||
|
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||||
|
.filter(([key]) => key.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = Array.isArray(schema) && schema.length > 0
|
||||||
|
? schema
|
||||||
|
: parameterCandidates
|
||||||
|
.map((parameter) => parameterToStartFormField(parameter))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const result: StartFormFieldSchema[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const item of source) {
|
||||||
|
const fieldId = trimString((item as Partial<StartFormFieldSchema>)?.id);
|
||||||
|
const key =
|
||||||
|
trimString((item as Partial<StartFormFieldSchema>)?.key) ||
|
||||||
|
trimString((item as any)?.name);
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = normalizeStartFormField(
|
||||||
|
item as Partial<StartFormFieldSchema>,
|
||||||
|
(fieldId ? parameterIdMap.get(fieldId) : undefined) || parameterMap.get(key),
|
||||||
|
);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized.key);
|
||||||
|
if (normalized.key === SYSTEM_START_PARAM_NAME) {
|
||||||
|
result.unshift(normalized);
|
||||||
|
} else {
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.has(SYSTEM_START_PARAM_NAME)) {
|
||||||
|
const systemParameter = parameterCandidates.find((parameter) =>
|
||||||
|
isSystemStartParameter(parameter),
|
||||||
|
);
|
||||||
|
result.unshift(
|
||||||
|
normalizeStartFormField(
|
||||||
|
{
|
||||||
|
id: trimString(systemParameter?.id),
|
||||||
|
key: SYSTEM_START_PARAM_NAME,
|
||||||
|
label: trimString(systemParameter?.formLabel) || SYSTEM_START_PARAM_LABEL,
|
||||||
|
type:
|
||||||
|
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
|
||||||
|
? 'text'
|
||||||
|
: 'textarea',
|
||||||
|
required: true,
|
||||||
|
placeholder: trimString(systemParameter?.formPlaceholder),
|
||||||
|
description: trimString(systemParameter?.formDescription),
|
||||||
|
defaultValue: asString(systemParameter?.defaultValue),
|
||||||
|
options: [],
|
||||||
|
systemReserved: true,
|
||||||
|
},
|
||||||
|
systemParameter,
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomStartFormField(
|
||||||
|
field?: Partial<StartFormFieldSchema> | null,
|
||||||
|
existingKeys: string[] = [],
|
||||||
|
): StartFormFieldSchema {
|
||||||
|
const type = normalizeStartFormFieldUiType(field?.type, 'text');
|
||||||
|
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
|
||||||
|
return {
|
||||||
|
id: ensureStartFormFieldId(field?.id),
|
||||||
|
key,
|
||||||
|
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
|
||||||
|
type,
|
||||||
|
required: Boolean(field?.required),
|
||||||
|
placeholder:
|
||||||
|
trimString(field?.placeholder) ||
|
||||||
|
(type === 'file' ? '请选择文件' : '请输入内容'),
|
||||||
|
description: trimString(field?.description),
|
||||||
|
defaultValue: Array.isArray(field?.defaultValue)
|
||||||
|
? ensureStringArray(field?.defaultValue)
|
||||||
|
: asString(field?.defaultValue),
|
||||||
|
options: isOptionFieldType(type) ? ensureStringArray(field?.options) : [],
|
||||||
|
systemReserved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendStartFormField(
|
||||||
|
data?: Record<string, any> | null,
|
||||||
|
field?: Partial<StartFormFieldSchema> | null,
|
||||||
|
) {
|
||||||
|
const currentData = (data || {}) as Record<string, any>;
|
||||||
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
|
? (currentData.parameters as Parameter[])
|
||||||
|
: [];
|
||||||
|
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||||
|
const nextField = createCustomStartFormField(
|
||||||
|
field,
|
||||||
|
schema.map((item) => item.key),
|
||||||
|
);
|
||||||
|
return normalizeStartNodeData({
|
||||||
|
...currentData,
|
||||||
|
startFormSchema: [...schema, nextField],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStartFormField(
|
||||||
|
data: Record<string, any> | null | undefined,
|
||||||
|
currentKey: string,
|
||||||
|
patch: Partial<StartFormFieldSchema>,
|
||||||
|
) {
|
||||||
|
const currentData = (data || {}) as Record<string, any>;
|
||||||
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
|
? (currentData.parameters as Parameter[])
|
||||||
|
: [];
|
||||||
|
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||||
|
const nextSchema = schema.map((field) => {
|
||||||
|
if (field.key !== currentKey) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
const nextType = normalizeStartFormFieldUiType(patch.type, field.type);
|
||||||
|
const shouldAutoRenameKey =
|
||||||
|
!trimString(patch.key) &&
|
||||||
|
patch.type != null &&
|
||||||
|
isAutoGeneratedStartFormFieldKey(field.key);
|
||||||
|
const shouldAutoRenameLabel =
|
||||||
|
!trimString(patch.label) &&
|
||||||
|
patch.type != null &&
|
||||||
|
isDefaultStartFormFieldLabel(field.label);
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
...patch,
|
||||||
|
key: trimString(patch.key) || (
|
||||||
|
shouldAutoRenameKey
|
||||||
|
? buildUniqueStartFormFieldKey(
|
||||||
|
nextType,
|
||||||
|
schema
|
||||||
|
.filter((item) => item.key !== field.key)
|
||||||
|
.map((item) => item.key),
|
||||||
|
)
|
||||||
|
: field.key
|
||||||
|
),
|
||||||
|
label: trimString(patch.label) || (
|
||||||
|
shouldAutoRenameLabel ? getDefaultStartFormFieldLabel(nextType) : field.label
|
||||||
|
),
|
||||||
|
type: nextType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return normalizeStartNodeData({
|
||||||
|
...currentData,
|
||||||
|
startFormSchema: nextSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStartFormField(
|
||||||
|
data?: Record<string, any> | null,
|
||||||
|
key?: string | null,
|
||||||
|
) {
|
||||||
|
const currentKey = trimString(key);
|
||||||
|
if (!currentKey || currentKey === SYSTEM_START_PARAM_NAME) {
|
||||||
|
return normalizeStartNodeData(data || {});
|
||||||
|
}
|
||||||
|
const currentData = (data || {}) as Record<string, any>;
|
||||||
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
|
? (currentData.parameters as Parameter[])
|
||||||
|
: [];
|
||||||
|
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||||
|
return normalizeStartNodeData({
|
||||||
|
...currentData,
|
||||||
|
startFormSchema: schema.filter((field) => field.key !== currentKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStartNodeData(
|
||||||
|
data?: Record<string, any> | null,
|
||||||
|
options?: {
|
||||||
|
allowLegacyParametersOnly?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const currentData = (data || {}) as Record<string, any>;
|
||||||
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
|
? (currentData.parameters as Parameter[])
|
||||||
|
: [];
|
||||||
|
const hasSchema = Array.isArray(currentData.startFormSchema);
|
||||||
|
const hasSystemParameter = hasSystemStartParameter(currentParameters);
|
||||||
|
|
||||||
|
if (!hasSchema && !hasSystemParameter && options?.allowLegacyParametersOnly) {
|
||||||
|
return {
|
||||||
|
parameters: currentParameters.map(ensureParameterId),
|
||||||
|
startFormMeta: currentData.startFormMeta,
|
||||||
|
startFormSchema: currentData.startFormSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||||
|
const parameterMap = new Map(
|
||||||
|
currentParameters
|
||||||
|
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||||
|
.filter(([key]) => key.length > 0),
|
||||||
|
);
|
||||||
|
const parameterIdMap = new Map(
|
||||||
|
currentParameters
|
||||||
|
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||||
|
.filter(([key]) => key.length > 0),
|
||||||
|
);
|
||||||
|
const parameters = schema.map((field) =>
|
||||||
|
startFormFieldToParameter(
|
||||||
|
field,
|
||||||
|
(trimString(field.id) ? parameterIdMap.get(trimString(field.id)) : undefined) ||
|
||||||
|
parameterMap.get(field.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameters,
|
||||||
|
startFormMeta: normalizeStartFormMeta(currentData.startFormMeta),
|
||||||
|
startFormSchema: schema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSystemStartParameter(): Parameter {
|
||||||
|
return normalizeSystemStartParameter();
|
||||||
|
}
|
||||||
|
|
||||||
export function isSystemStartParameter(parameter?: Parameter | null) {
|
export function isSystemStartParameter(parameter?: Parameter | null) {
|
||||||
if (!parameter) {
|
if (!parameter) {
|
||||||
return false;
|
return false;
|
||||||
@@ -273,18 +850,12 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
|||||||
const source = Array.isArray(parameters)
|
const source = Array.isArray(parameters)
|
||||||
? parameters.map(cloneParameter)
|
? parameters.map(cloneParameter)
|
||||||
: [];
|
: [];
|
||||||
const fixed = createSystemStartParameter();
|
|
||||||
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
|
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const existing = source[index]!;
|
source[index] = normalizeSystemStartParameter(source[index]!);
|
||||||
source[index] = ensureParameterId({
|
|
||||||
...existing,
|
|
||||||
...fixed,
|
|
||||||
id: existing.id || fixed.id,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
source.unshift(fixed);
|
source.unshift(createSystemStartParameter());
|
||||||
}
|
}
|
||||||
|
|
||||||
const customParameters = source
|
const customParameters = source
|
||||||
@@ -295,6 +866,14 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialWorkflowData() {
|
export function createInitialWorkflowData() {
|
||||||
|
const startData = normalizeStartNodeData(
|
||||||
|
{
|
||||||
|
parameters: ensureStartNodeParameters(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowLegacyParametersOnly: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -303,7 +882,7 @@ export function createInitialWorkflowData() {
|
|||||||
position: { x: 80, y: 180 },
|
position: { x: 80, y: 180 },
|
||||||
data: {
|
data: {
|
||||||
title: '开始节点',
|
title: '开始节点',
|
||||||
parameters: ensureStartNodeParameters(),
|
...startData,
|
||||||
},
|
},
|
||||||
} satisfies Node,
|
} satisfies Node,
|
||||||
],
|
],
|
||||||
@@ -326,22 +905,29 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data:
|
|||||||
if (node?.type !== START_NODE_TYPE) {
|
if (node?.type !== START_NODE_TYPE) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
const currentParameters = Array.isArray(node.data?.parameters)
|
const currentData = (node.data || {}) as Record<string, any>;
|
||||||
? (node.data.parameters as Parameter[])
|
const currentParameters = Array.isArray(currentData.parameters)
|
||||||
|
? (currentData.parameters as Parameter[])
|
||||||
: [];
|
: [];
|
||||||
if (!hasSystemStartParameter(currentParameters)) {
|
const shouldNormalize =
|
||||||
|
Array.isArray(currentData.startFormSchema) ||
|
||||||
|
hasSystemStartParameter(currentParameters);
|
||||||
|
if (!shouldNormalize) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
const normalizedParameters = ensureStartNodeParameters(currentParameters);
|
const normalizedData = normalizeStartNodeData(currentData);
|
||||||
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
|
const nextData = {
|
||||||
|
...currentData,
|
||||||
|
...normalizedData,
|
||||||
|
};
|
||||||
|
if (JSON.stringify(currentData) === JSON.stringify(nextData)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...(node.data || {}),
|
...nextData,
|
||||||
parameters: normalizedParameters,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -563,6 +1149,148 @@ function getSupportedFieldKeys(nodeType: string | undefined) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceStartFieldReferenceValue(
|
||||||
|
value: unknown,
|
||||||
|
oldRefPath: string,
|
||||||
|
newRefPath: string,
|
||||||
|
): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const oldToken = toToken(oldRefPath);
|
||||||
|
const newToken = toToken(newRefPath);
|
||||||
|
if (value === oldRefPath) {
|
||||||
|
return newRefPath;
|
||||||
|
}
|
||||||
|
if (!value.includes(oldToken)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.split(oldToken).join(newToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
let changed = false;
|
||||||
|
const nextValue = value.map((item) => {
|
||||||
|
const nextItem = replaceStartFieldReferenceValue(item, oldRefPath, newRefPath);
|
||||||
|
if (nextItem !== item) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return nextItem;
|
||||||
|
});
|
||||||
|
return changed ? nextValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
let changed = false;
|
||||||
|
const nextValue = Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, itemValue]) => {
|
||||||
|
const nextItemValue = replaceStartFieldReferenceValue(
|
||||||
|
itemValue,
|
||||||
|
oldRefPath,
|
||||||
|
newRefPath,
|
||||||
|
);
|
||||||
|
if (nextItemValue !== itemValue) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return [key, nextItemValue];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return changed ? nextValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
|
||||||
|
const nodeIds = new Set<string>();
|
||||||
|
|
||||||
|
const visit = (nodeId: string) => {
|
||||||
|
if (!nodeId || nodeIds.has(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodeIds.add(nodeId);
|
||||||
|
edges
|
||||||
|
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
||||||
|
.forEach((edge) => {
|
||||||
|
if (edge.target) {
|
||||||
|
visit(edge.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(rootNodeId);
|
||||||
|
nodeIds.delete(rootNodeId);
|
||||||
|
return Array.from(nodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameStartFieldReferencesInNodes(
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[],
|
||||||
|
startNodeId: string,
|
||||||
|
currentKey: string,
|
||||||
|
nextKey: string,
|
||||||
|
) {
|
||||||
|
const normalizedStartNodeId = trimString(startNodeId);
|
||||||
|
const normalizedCurrentKey = trimString(currentKey);
|
||||||
|
const normalizedNextKey = trimString(nextKey);
|
||||||
|
if (
|
||||||
|
!normalizedStartNodeId ||
|
||||||
|
!normalizedCurrentKey ||
|
||||||
|
!normalizedNextKey ||
|
||||||
|
normalizedCurrentKey === normalizedNextKey
|
||||||
|
) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
|
||||||
|
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
|
||||||
|
|
||||||
|
const nextNodes = nodes.map((node) => {
|
||||||
|
if (node.id === normalizedStartNodeId) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const nextData = replaceStartFieldReferenceValue(
|
||||||
|
node.data,
|
||||||
|
oldRefPath,
|
||||||
|
newRefPath,
|
||||||
|
) as Record<string, any> | undefined;
|
||||||
|
if (nextData === node.data) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: nextData,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const affectedNodeIds = collectDownstreamNodeIds(normalizedStartNodeId, edges);
|
||||||
|
if (affectedNodeIds.length === 0) {
|
||||||
|
return nextNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches = buildSequentialFieldBindingPatches(
|
||||||
|
affectedNodeIds,
|
||||||
|
nextNodes,
|
||||||
|
edges,
|
||||||
|
);
|
||||||
|
if (patches.length === 0) {
|
||||||
|
return nextNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchMap = new Map(patches.map((item) => [item.nodeId, item.patch] as const));
|
||||||
|
return nextNodes.map((node) => {
|
||||||
|
const patch = patchMap.get(node.id);
|
||||||
|
if (!patch) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...((node.data || {}) as Record<string, any>),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getSingleRunFieldDescriptors(
|
function getSingleRunFieldDescriptors(
|
||||||
node: Pick<Node, 'type' | 'data'>,
|
node: Pick<Node, 'type' | 'data'>,
|
||||||
): SingleRunFieldDescriptor[] {
|
): SingleRunFieldDescriptor[] {
|
||||||
@@ -778,12 +1506,15 @@ export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === START_NODE_TYPE) {
|
if (node.type === START_NODE_TYPE) {
|
||||||
const parameters = Array.isArray(node.data.parameters)
|
const normalizedData = normalizeStartNodeData(
|
||||||
? ((node.data.parameters as Parameter[]) || [])
|
(node.data || {}) as Record<string, any>,
|
||||||
|
{
|
||||||
|
allowLegacyParametersOnly: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Array.isArray(normalizedData.parameters)
|
||||||
|
? (normalizedData.parameters as Parameter[])
|
||||||
: [];
|
: [];
|
||||||
return hasSystemStartParameter(parameters)
|
|
||||||
? ensureStartNodeParameters(parameters)
|
|
||||||
: parameters;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = Array.isArray(node.data.parameters)
|
const parameters = Array.isArray(node.data.parameters)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['choose']);
|
const emit = defineEmits(['choose']);
|
||||||
@@ -27,7 +31,7 @@ function closeDialog() {
|
|||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
function confirm() {
|
function confirm() {
|
||||||
emit('choose', currentChoose.value, props.attrName);
|
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
@@ -56,7 +60,11 @@ watch(
|
|||||||
:page-sizes="[8, 12, 16, 20]"
|
:page-sizes="[8, 12, 16, 20]"
|
||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
<ResourceCardList v-model="chooseResources" :data="pageList" />
|
<ResourceCardList
|
||||||
|
v-model="chooseResources"
|
||||||
|
:data="pageList"
|
||||||
|
:multiple="props.multiple"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageData>
|
</PageData>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { ElButton, ElLink, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendWorkflowFileValues,
|
||||||
|
buildWorkflowFileValueFromResource,
|
||||||
|
buildWorkflowFileValueFromUpload,
|
||||||
|
formatWorkflowFileSize,
|
||||||
|
normalizeWorkflowFileValues,
|
||||||
|
validateWorkflowFileSelection,
|
||||||
|
validateWorkflowFileValues,
|
||||||
|
WORKFLOW_FILE_LIMITS,
|
||||||
|
} from './workflowFileValue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [Array, Object],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const uploadLoading = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
||||||
|
|
||||||
|
function triggerSelectFile() {
|
||||||
|
if (uploadLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNativeFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files || []);
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadLoading.value = true;
|
||||||
|
try {
|
||||||
|
validateWorkflowFileSelection(currentFiles.value, files);
|
||||||
|
const uploadedFiles = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
|
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
||||||
|
}
|
||||||
|
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
||||||
|
validateWorkflowFileValues(nextFiles);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '文件上传失败');
|
||||||
|
console.error('工作流文件上传失败', error);
|
||||||
|
} finally {
|
||||||
|
uploadLoading.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChooseResource(resources: any) {
|
||||||
|
try {
|
||||||
|
const resourceList = Array.isArray(resources) ? resources : [resources];
|
||||||
|
const fileValues = resourceList
|
||||||
|
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
||||||
|
.filter(Boolean);
|
||||||
|
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
||||||
|
validateWorkflowFileValues(nextFiles);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || '素材文件选择失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(filePath: string) {
|
||||||
|
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
||||||
|
emit('update:modelValue', nextFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
emit('update:modelValue', []);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="workflow-file-input">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="workflow-file-input__native"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
@change="handleNativeFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="workflow-file-input__hint">
|
||||||
|
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
||||||
|
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
||||||
|
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||||
|
<div
|
||||||
|
v-for="item in currentFiles"
|
||||||
|
:key="item.filePath"
|
||||||
|
class="workflow-file-input__summary"
|
||||||
|
>
|
||||||
|
<div class="workflow-file-input__content">
|
||||||
|
<div class="workflow-file-input__name">
|
||||||
|
{{ item.fileName }}
|
||||||
|
</div>
|
||||||
|
<div class="workflow-file-input__meta">
|
||||||
|
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||||
|
<ElLink
|
||||||
|
v-if="item.url || item.filePath"
|
||||||
|
:href="item.url || item.filePath"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{{ $t('button.view') }}
|
||||||
|
</ElLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
||||||
|
{{ $t('button.delete') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workflow-file-input__actions">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:loading="uploadLoading"
|
||||||
|
@click="triggerSelectFile"
|
||||||
|
>
|
||||||
|
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
|
||||||
|
</ElButton>
|
||||||
|
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
|
||||||
|
<ElButton
|
||||||
|
v-if="currentFiles.length > 0"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
@click="clearFiles"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-file-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__native {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
|
|
||||||
import { computed, onUnmounted, ref } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Position } from '@element-plus/icons-vue';
|
import { Position } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
||||||
@@ -34,9 +34,74 @@ defineExpose({
|
|||||||
const runForm = ref<FormInstance>();
|
const runForm = ref<FormInstance>();
|
||||||
const runParams = ref<any>({});
|
const runParams = ref<any>({});
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
const parameters = computed(() => {
|
const startFormMeta = computed(() => {
|
||||||
return props.workflowParams.parameters;
|
const meta = props.workflowParams?.startFormMeta || {};
|
||||||
|
return {
|
||||||
|
title: String(meta.title || '').trim() || props.workflowParams?.title || '',
|
||||||
|
description:
|
||||||
|
String(meta.description || '').trim() ||
|
||||||
|
props.workflowParams?.description ||
|
||||||
|
'',
|
||||||
|
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
const parameters = computed(() => {
|
||||||
|
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
||||||
|
? props.workflowParams.startFormSchema
|
||||||
|
: [];
|
||||||
|
if (schema.length === 0) {
|
||||||
|
return props.workflowParams.parameters || [];
|
||||||
|
}
|
||||||
|
return schema.map((field: any) => {
|
||||||
|
const type = String(field.type || '').trim() || 'text';
|
||||||
|
return {
|
||||||
|
name: field.key,
|
||||||
|
formLabel: field.label || field.key,
|
||||||
|
formDescription: field.description || '',
|
||||||
|
formPlaceholder: field.placeholder || '',
|
||||||
|
required: Boolean(field.required),
|
||||||
|
defaultValue: field.defaultValue,
|
||||||
|
enums: Array.isArray(field.options) ? field.options : [],
|
||||||
|
contentType: type === 'file' ? 'file' : 'text',
|
||||||
|
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
||||||
|
dataType:
|
||||||
|
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
parameters,
|
||||||
|
(items) => {
|
||||||
|
const nextRunParams = { ...runParams.value };
|
||||||
|
let changed = false;
|
||||||
|
for (const item of items || []) {
|
||||||
|
if (nextRunParams[item.name] !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.defaultValue !== undefined && item.defaultValue !== '') {
|
||||||
|
nextRunParams[item.name] = item.defaultValue;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.formType === 'checkbox') {
|
||||||
|
nextRunParams[item.name] = [];
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.contentType === 'file') {
|
||||||
|
nextRunParams[item.name] = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
runParams.value = nextRunParams;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
function resume(data: any) {
|
function resume(data: any) {
|
||||||
data.executeId = executeId.value;
|
data.executeId = executeId.value;
|
||||||
@@ -109,6 +174,20 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ElForm label-position="top" ref="runForm" :model="runParams">
|
<ElForm label-position="top" ref="runForm" :model="runParams">
|
||||||
|
<div
|
||||||
|
v-if="startFormMeta.title || startFormMeta.description"
|
||||||
|
class="workflow-form__header"
|
||||||
|
>
|
||||||
|
<div v-if="startFormMeta.title" class="workflow-form__title">
|
||||||
|
{{ startFormMeta.title }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="startFormMeta.description"
|
||||||
|
class="workflow-form__description"
|
||||||
|
>
|
||||||
|
{{ startFormMeta.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<WorkflowFormItem
|
<WorkflowFormItem
|
||||||
v-model:run-params="runParams"
|
v-model:run-params="runParams"
|
||||||
:parameters="parameters"
|
:parameters="parameters"
|
||||||
@@ -120,11 +199,28 @@ onUnmounted(() => {
|
|||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
:icon="Position"
|
:icon="Position"
|
||||||
>
|
>
|
||||||
{{ $t('button.run') }}
|
{{ startFormMeta.submitText }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.workflow-form__header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-form__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-form__description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||||
|
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -27,10 +28,19 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const emit = defineEmits(['update:runParams']);
|
const emit = defineEmits(['update:runParams']);
|
||||||
function getContentType(item: any) {
|
function getContentType(item: any) {
|
||||||
return item.contentType || 'text';
|
if (item.contentType) {
|
||||||
|
return item.contentType;
|
||||||
|
}
|
||||||
|
if (String(item.dataType || '').toLowerCase() === 'file') {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
}
|
}
|
||||||
function isResource(contentType: any) {
|
function isResource(contentType: any) {
|
||||||
return ['audio', 'file', 'image', 'video'].includes(contentType);
|
return ['audio', 'image', 'video'].includes(contentType);
|
||||||
|
}
|
||||||
|
function isFileContentType(contentType: any) {
|
||||||
|
return contentType === 'file';
|
||||||
}
|
}
|
||||||
function getCheckboxOptions(item: any) {
|
function getCheckboxOptions(item: any) {
|
||||||
if (item.enums) {
|
if (item.enums) {
|
||||||
@@ -43,6 +53,31 @@ function getCheckboxOptions(item: any) {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
function buildRules(item: any) {
|
||||||
|
if (!item.required) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule: any, value: any, callback: any) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
callback(
|
||||||
|
Object.keys(value).length > 0
|
||||||
|
? undefined
|
||||||
|
: new Error($t('message.required')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(value ? undefined : new Error($t('message.required')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
function updateParam(name: string, value: any) {
|
function updateParam(name: string, value: any) {
|
||||||
const newValue = { ...props.runParams, [name]: value };
|
const newValue = { ...props.runParams, [name]: value };
|
||||||
emit('update:runParams', newValue);
|
emit('update:runParams', newValue);
|
||||||
@@ -58,9 +93,7 @@ function choose(data: any, propName: string) {
|
|||||||
:prop="`${propPrefix}${item.name}`"
|
:prop="`${propPrefix}${item.name}`"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:label="item.formLabel || item.name"
|
:label="item.formLabel || item.name"
|
||||||
:rules="
|
:rules="buildRules(item)"
|
||||||
item.required ? [{ required: true, message: $t('message.required') }] : []
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template v-if="getContentType(item) === 'text'">
|
<template v-if="getContentType(item) === 'text'">
|
||||||
<ElInput
|
<ElInput
|
||||||
@@ -105,6 +138,12 @@ function choose(data: any, propName: string) {
|
|||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="isFileContentType(getContentType(item))">
|
||||||
|
<WorkflowFileInput
|
||||||
|
:model-value="runParams[item.name]"
|
||||||
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-if="isResource(getContentType(item))">
|
<template v-if="isResource(getContentType(item))">
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 工作流运行态单文件值。
|
||||||
|
*/
|
||||||
|
export interface WorkflowFileValue {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
contentType?: string;
|
||||||
|
size?: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材对象最小字段约束。
|
||||||
|
*/
|
||||||
|
export interface WorkflowResourceLike {
|
||||||
|
fileSize?: number | string;
|
||||||
|
resourceName?: string;
|
||||||
|
resourceUrl?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_FILE_LIMITS = {
|
||||||
|
maxCount: 10,
|
||||||
|
maxSingleSize: 5 * 1024 * 1024,
|
||||||
|
maxTotalSize: 50 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于上传结果构建工作流文件值。
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFileValueFromUpload(
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
): WorkflowFileValue {
|
||||||
|
const resolvedPath = String(path || '').trim();
|
||||||
|
if (!resolvedPath) {
|
||||||
|
throw new Error('上传结果缺少文件路径');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fileName: file.name,
|
||||||
|
filePath: resolvedPath,
|
||||||
|
contentType: file.type || '',
|
||||||
|
size: file.size,
|
||||||
|
url: resolvedPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于素材对象构建工作流文件值。
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFileValueFromResource(
|
||||||
|
resource: WorkflowResourceLike,
|
||||||
|
): WorkflowFileValue {
|
||||||
|
const resourceUrl = String(resource?.resourceUrl || '').trim();
|
||||||
|
if (!resourceUrl) {
|
||||||
|
throw new Error('素材缺少 resourceUrl');
|
||||||
|
}
|
||||||
|
const resourceName = String(resource?.resourceName || '').trim();
|
||||||
|
const suffix = String(resource?.suffix || '').trim();
|
||||||
|
const fallbackFileName = resourceUrl.split('/').pop()?.split('?')[0] || '';
|
||||||
|
return {
|
||||||
|
fileName:
|
||||||
|
resourceName && suffix
|
||||||
|
? `${resourceName}.${suffix}`
|
||||||
|
: resourceName || fallbackFileName,
|
||||||
|
filePath: resourceUrl,
|
||||||
|
contentType: '',
|
||||||
|
size: toNumber(resource?.fileSize),
|
||||||
|
url: resourceUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为有效的工作流文件值。
|
||||||
|
*/
|
||||||
|
export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = value as Partial<WorkflowFileValue>;
|
||||||
|
return Boolean(
|
||||||
|
String(candidate.fileName || '').trim() &&
|
||||||
|
String(candidate.filePath || '').trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化单文件或多文件值。
|
||||||
|
*/
|
||||||
|
export function normalizeWorkflowFileValues(value: unknown): WorkflowFileValue[] {
|
||||||
|
const candidates = Array.isArray(value) ? value : [value];
|
||||||
|
const result: WorkflowFileValue[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!isWorkflowFileValue(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(candidate.filePath).trim();
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
result.push(candidate);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加文件并按 filePath 去重。
|
||||||
|
*/
|
||||||
|
export function appendWorkflowFileValues(
|
||||||
|
currentValue: unknown,
|
||||||
|
incomingValues: WorkflowFileValue[],
|
||||||
|
): WorkflowFileValue[] {
|
||||||
|
return normalizeWorkflowFileValues([
|
||||||
|
...normalizeWorkflowFileValues(currentValue),
|
||||||
|
...incomingValues,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在上传前基于原生 File 列表校验文件限制,避免无效文件先落库。
|
||||||
|
*/
|
||||||
|
export function validateWorkflowFileSelection(
|
||||||
|
currentValue: unknown,
|
||||||
|
incomingFiles: File[],
|
||||||
|
) {
|
||||||
|
const currentFiles = normalizeWorkflowFileValues(currentValue);
|
||||||
|
const totalCount = currentFiles.length + incomingFiles.length;
|
||||||
|
if (totalCount > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||||
|
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSize = currentFiles.reduce((sum, item) => sum + Number(item.size || 0), 0);
|
||||||
|
for (const file of incomingFiles) {
|
||||||
|
if (file.size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||||
|
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||||
|
}
|
||||||
|
totalSize += file.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||||
|
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验最终文件列表是否满足工作流限制。
|
||||||
|
*/
|
||||||
|
export function validateWorkflowFileValues(values: WorkflowFileValue[]) {
|
||||||
|
const normalized = normalizeWorkflowFileValues(values);
|
||||||
|
if (normalized.length > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||||
|
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||||
|
}
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const item of normalized) {
|
||||||
|
const size = Number(item.size || 0);
|
||||||
|
if (size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||||
|
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||||
|
}
|
||||||
|
totalSize += size;
|
||||||
|
}
|
||||||
|
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||||
|
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 友好格式化文件大小。
|
||||||
|
*/
|
||||||
|
export function formatWorkflowFileSize(size?: number): string {
|
||||||
|
if (!size || Number.isNaN(size) || size <= 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`;
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024 * 1024) {
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value?: number | string) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user