diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java index 3a007c5..6f9fa8e 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.ai.service.KnowledgeSharePermissionService; +import tech.easyflow.ai.service.WorkflowApiPermissionService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; @@ -46,6 +47,8 @@ public class SysApiKeyController extends BaseCurdController 0); + + QueryWrapper workflowWrapper = QueryWrapper.create() + .select(SysApiKeyResourceMapping::getId) + .eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId()) + .eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW); + entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0); } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyResourceController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyResourceController.java index 00caa48..91b6c20 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyResourceController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyResourceController.java @@ -1,12 +1,17 @@ package tech.easyflow.admin.controller.system; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.common.annotation.UsePermission; +import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.controller.BaseCurdController; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.system.entity.SysApiKeyResource; import tech.easyflow.system.service.SysApiKeyResourceService; +import java.util.List; + /** * 请求接口表 控制层。 * @@ -20,4 +25,26 @@ public class SysApiKeyResourceController extends BaseCurdController工作流 Public API 使用独立的全局授权开关,不进入普通接口授权列表,避免用户误以为勾选 + * 具体接口资源即可完成工作流调用授权。

+ * + * @param entity 查询条件 + * @param asTree 是否树形返回 + * @param sortKey 排序字段 + * @param sortType 排序方向 + * @return 普通接口授权资源 + */ + @Override + @GetMapping("list") + public Result> list(SysApiKeyResource entity, Boolean asTree, String sortKey, String sortType) { + QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); + queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/workflow/"); + queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/knowledge-share/"); + queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); + return Result.ok(service.list(queryWrapper)); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java index 4cd5045..7c0e1ab 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java @@ -4,22 +4,31 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotBlank; import org.springframework.web.bind.annotation.*; import tech.easyflow.approval.annotation.RequirePublishedAccess; -import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; +import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.entity.WorkflowExecResult; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.WorkflowExecResultService; import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowApiPermissionService; +import tech.easyflow.ai.utils.WorkFlowUtil; import tech.easyflow.common.constant.Constants; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.system.entity.SysApiKey; import java.math.BigInteger; import java.util.HashMap; @@ -43,6 +52,10 @@ public class PublicWorkflowController { private WorkflowCheckService workflowCheckService; @Resource private WorkflowRunningParameterResolver workflowRunningParameterResolver; + @Resource + private WorkflowApiPermissionService workflowApiPermissionService; + @Resource + private WorkflowExecResultService workflowExecResultService; /** * 通过id或别名获取工作流详情 @@ -54,8 +67,11 @@ public class PublicWorkflowController { @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布") public Result getByIdOrAlias( @RequestParam - @NotBlank(message = "key不能为空") String key) { + @NotBlank(message = "key不能为空") String key, + HttpServletRequest request) { + workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI()); Workflow workflow = workflowService.getPublishedDetail(key); + assertStrictPublishedWorkflow(workflow); return Result.ok(workflow); } @@ -88,19 +104,20 @@ public class PublicWorkflowController { * 运行工作流 - v2 */ @PostMapping("/runAsync") - @SaCheckPermission("/api/v1/workflow/save") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") public Result runAsync(@JsonBody(value = "id", required = true) BigInteger id, - @JsonBody("variables") Map variables) { + @JsonBody("variables") Map variables, + HttpServletRequest request) { + SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI()); if (variables == null) { variables = new HashMap<>(); } Workflow workflow = workflowService.getPublishedById(id); - if (workflow == null) { - throw new RuntimeException("工作流不存在"); - } + assertStrictPublishedWorkflow(workflow); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); + variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey)); + variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY); String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables); return Result.ok(executeId); } @@ -110,7 +127,10 @@ public class PublicWorkflowController { */ @PostMapping("/getChainStatus") public Result getChainStatus(@JsonBody(value = "executeId") String executeId, - @JsonBody("nodes") List nodes) { + @JsonBody("nodes") List nodes, + HttpServletRequest request) { + SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI()); + assertApiKeyExecutionOwnership(apiKey, executeId); ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes); return Result.ok(res); } @@ -119,22 +139,23 @@ public class PublicWorkflowController { * 恢复工作流运行 - v2 */ @PostMapping("/resume") - @SaCheckPermission("/api/v1/workflow/save") public Result resume(@JsonBody(value = "executeId", required = true) String executeId, - @JsonBody("confirmParams") Map confirmParams) { + @JsonBody("confirmParams") Map confirmParams, + HttpServletRequest request) { + SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI()); + WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId); + assertWorkflowExecutionResumable(execResult); chainExecutor.resumeAsync(executeId, confirmParams); return Result.ok(); } @GetMapping("getRunningParameters") - @SaCheckPermission("/api/v1/workflow/query") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") - public Result getRunningParameters(@RequestParam BigInteger id) { + public Result getRunningParameters(@RequestParam BigInteger id, HttpServletRequest request) { + workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI()); Workflow workflow = workflowService.getPublishedById(id); - if (workflow == null) { - return Result.fail(1, "can not find the workflow by id: " + id); - } + assertStrictPublishedWorkflow(workflow); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); Map res = workflowRunningParameterResolver.buildRunningParametersView(workflow); if (res == null) { @@ -142,4 +163,72 @@ public class PublicWorkflowController { } return Result.ok(res); } + + /** + * 构建 API Key 调用方的运行身份。 + * + * @param apiKey 访问令牌 + * @return 工作流运行身份 + */ + private LoginAccount buildApiKeyLoginAccount(SysApiKey apiKey) { + LoginAccount account = new LoginAccount(); + account.setId(apiKey.getId()); + account.setDeptId(apiKey.getDeptId() == null ? BigInteger.ZERO : apiKey.getDeptId()); + account.setTenantId(apiKey.getTenantId() == null ? BigInteger.ZERO : apiKey.getTenantId()); + account.setLoginName("apikey:" + apiKey.getId()); + account.setNickname("API 调用方"); + return account; + } + + /** + * 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。 + * + * @param workflow 工作流发布视图 + */ + private void assertStrictPublishedWorkflow(Workflow workflow) { + if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus()) + || workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("工作流尚未发布"); + } + } + + /** + * 校验 Public Workflow API 后续操作只能作用于当前 API Key 发起的执行实例。 + * + * @param apiKey 当前 API Key + * @param executeId 执行 ID + * @return 已通过归属校验的执行记录 + */ + private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) { + if (executeId == null || executeId.isBlank()) { + throw new BusinessException("执行ID不能为空"); + } + WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId); + if (execResult == null) { + throw new BusinessException("工作流执行记录不存在,请稍后重试"); + } + if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey()) + || apiKey == null + || apiKey.getId() == null + || !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) { + throw new BusinessException("无权限访问当前工作流执行记录"); + } + return execResult; + } + + /** + * 校验当前执行实例是否仍允许恢复。 + * + * @param execResult 执行记录 + */ + private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) { + if (execResult == null || execResult.getWorkflowId() == null) { + throw new BusinessException("工作流执行记录不存在,请稍后重试"); + } + Workflow workflow = workflowService.getById(execResult.getWorkflowId()); + if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus()) + || workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("工作流已下线或不可恢复执行"); + } + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java index ebd6a6b..4b89b1e 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java @@ -82,7 +82,7 @@ public class ChainEventListenerForSave implements ChainEventListener { record.setWorkflowJson(workflow.getContent()); record.setStartTime(new Date()); record.setStatus(state.getStatus().getValue()); - record.setCreatedKey(WorkFlowUtil.USER_KEY); + record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain)); record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString()); try { workflowExecResultService.save(record); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowApiPermissionService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowApiPermissionService.java new file mode 100644 index 0000000..5118bda --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowApiPermissionService.java @@ -0,0 +1,38 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.system.entity.SysApiKey; + +import java.math.BigInteger; + +/** + * 工作流 Public API 访问令牌授权服务。 + */ +public interface WorkflowApiPermissionService { + + /** + * 工作流资源类型。 + */ + String RESOURCE_TYPE_WORKFLOW = "WORKFLOW"; + + /** + * 工作流 Public API 调用动作范围。 + */ + String ACTION_SCOPE_INVOKE = "INVOKE"; + + /** + * 按访问令牌维度开启或关闭工作流 Public API 调用授权。 + * + * @param apiKeyId 系统访问令牌 ID + * @param enabled 是否启用工作流 API 调用授权 + */ + void replaceWorkflowApiEnabled(BigInteger apiKeyId, boolean enabled); + + /** + * 断言当前令牌具备工作流 Public API 调用权限。 + * + * @param apiKey 原始访问令牌 + * @param requestUri 请求地址 + * @return 已校验通过的访问令牌实体 + */ + SysApiKey assertWorkflowApi(String apiKey, String requestUri); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java new file mode 100644 index 0000000..5a0a98e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java @@ -0,0 +1,130 @@ +package tech.easyflow.ai.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.service.WorkflowApiPermissionService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; +import tech.easyflow.system.entity.SysApiKeyResource; +import tech.easyflow.system.entity.SysApiKeyResourceMapping; +import tech.easyflow.system.service.SysApiKeyResourceMappingService; +import tech.easyflow.system.service.SysApiKeyResourceService; +import tech.easyflow.system.service.SysApiKeyService; + +import javax.annotation.Resource; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * 工作流 Public API 访问令牌授权服务实现。 + */ +@Service +public class WorkflowApiPermissionServiceImpl implements WorkflowApiPermissionService { + + private static final String RESOURCE_TITLE = "工作流 API 调用"; + + private static final List WORKFLOW_API_URIS = List.of( + "/public-api/workflow/getByIdOrAlias", + "/public-api/workflow/getRunningParameters", + "/public-api/workflow/runAsync", + "/public-api/workflow/getChainStatus", + "/public-api/workflow/resume" + ); + + @Resource + private SysApiKeyService sysApiKeyService; + @Resource + private SysApiKeyResourceService resourceService; + @Resource + private SysApiKeyResourceMappingService mappingService; + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void replaceWorkflowApiEnabled(BigInteger apiKeyId, boolean enabled) { + if (apiKeyId == null) { + throw new BusinessException("系统访问令牌不能为空"); + } + SysApiKey apiKey = sysApiKeyService.getById(apiKeyId); + if (apiKey == null) { + throw new BusinessException("系统访问令牌不存在"); + } + mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_WORKFLOW); + if (!enabled) { + return; + } + + List rows = new ArrayList<>(WORKFLOW_API_URIS.size()); + for (String uri : WORKFLOW_API_URIS) { + SysApiKeyResource resource = ensureResource(uri); + SysApiKeyResourceMapping row = new SysApiKeyResourceMapping(); + row.setApiKeyId(apiKeyId); + row.setApiKeyResourceId(resource.getId()); + row.setResourceType(RESOURCE_TYPE_WORKFLOW); + row.setActionScope(ACTION_SCOPE_INVOKE); + rows.add(row); + } + if (!rows.isEmpty()) { + mappingService.saveBatch(rows); + } + } + + /** + * {@inheritDoc} + */ + @Override + public SysApiKey assertWorkflowApi(String apiKey, String requestUri) { + SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey); + SysApiKeyResource resource = getResource(requestUri); + QueryWrapper wrapper = QueryWrapper.create() + .eq(SysApiKeyResourceMapping::getApiKeyId, sysApiKey.getId()) + .eq(SysApiKeyResourceMapping::getApiKeyResourceId, resource.getId()) + .eq(SysApiKeyResourceMapping::getResourceType, RESOURCE_TYPE_WORKFLOW) + .isNull(SysApiKeyResourceMapping::getResourceTargetId) + .eq(SysApiKeyResourceMapping::getActionScope, ACTION_SCOPE_INVOKE); + if (mappingService.count(wrapper) == 0) { + throw new BusinessException("该apiKey无权限调用工作流 API"); + } + return sysApiKey; + } + + /** + * 获取工作流 Public API 资源。 + * + * @param requestInterface 请求地址 + * @return API 资源 + */ + private SysApiKeyResource getResource(String requestInterface) { + QueryWrapper wrapper = QueryWrapper.create() + .eq(SysApiKeyResource::getRequestInterface, requestInterface); + SysApiKeyResource resource = resourceService.getOne(wrapper); + if (resource == null) { + throw new BusinessException("该接口不存在"); + } + return resource; + } + + /** + * 确保工作流 Public API 资源已存在。 + * + * @param requestInterface 请求地址 + * @return API 资源 + */ + private SysApiKeyResource ensureResource(String requestInterface) { + QueryWrapper wrapper = QueryWrapper.create() + .eq(SysApiKeyResource::getRequestInterface, requestInterface); + SysApiKeyResource resource = resourceService.getOne(wrapper); + if (resource != null) { + return resource; + } + resource = new SysApiKeyResource(); + resource.setRequestInterface(requestInterface); + resource.setTitle(RESOURCE_TITLE); + resourceService.save(resource); + return resource; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/utils/WorkFlowUtil.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/utils/WorkFlowUtil.java index 7d6d07f..72c1a52 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/utils/WorkFlowUtil.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/utils/WorkFlowUtil.java @@ -12,7 +12,9 @@ import java.math.BigInteger; public class WorkFlowUtil { public final static String USER_KEY = "user"; + public final static String API_KEY = "API_KEY"; public final static String WORKFLOW_KEY = "workflow"; + public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey"; public static String removeSensitiveInfo(String originJson) { JSONObject workflowInfo = JSON.parseObject(originJson); @@ -35,6 +37,17 @@ public class WorkFlowUtil { return cache == null ? defaultAccount() : (LoginAccount) cache; } + /** + * 获取工作流执行记录的执行人标识。 + * + * @param chain 当前工作流执行链 + * @return 执行人标识 + */ + public static String getCreatedKey(Chain chain) { + Object value = chain.getState().getMemory().get(CREATED_KEY_MEMORY_KEY); + return value == null ? USER_KEY : String.valueOf(value); + } + public static LoginAccount defaultAccount() { LoginAccount account = new LoginAccount(); account.setId(new BigInteger("0")); diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java index 0871f58..579f30d 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java @@ -26,6 +26,9 @@ public class SysApiKey extends SysApiKeyBase { @Column(ignore = true) private Boolean knowledgeShareEnabled; + @Column(ignore = true) + private Boolean workflowApiEnabled; + @RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping") private List resourcePermissions; @@ -52,4 +55,12 @@ public class SysApiKey extends SysApiKeyBase { public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) { this.knowledgeShareEnabled = knowledgeShareEnabled; } + + public Boolean getWorkflowApiEnabled() { + return workflowApiEnabled; + } + + public void setWorkflowApiEnabled(Boolean workflowApiEnabled) { + this.workflowApiEnabled = workflowApiEnabled; + } } diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V17__mysql_workflow_public_api_resources.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V17__mysql_workflow_public_api_resources.sql new file mode 100644 index 0000000..983bf25 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V17__mysql_workflow_public_api_resources.sql @@ -0,0 +1,8 @@ +INSERT INTO `tb_sys_api_key_resource` (`id`, `request_interface`, `title`) +VALUES + (366700000000000003, '/public-api/workflow/getByIdOrAlias', '工作流 API 调用'), + (366700000000000004, '/public-api/workflow/getRunningParameters', '工作流 API 调用'), + (366700000000000005, '/public-api/workflow/runAsync', '工作流 API 调用'), + (366700000000000006, '/public-api/workflow/getChainStatus', '工作流 API 调用'), + (366700000000000007, '/public-api/workflow/resume', '工作流 API 调用') +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index a7a0143..6104b40 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -80,6 +80,15 @@ "publishStatusOffline": "Offline", "publishStatusDeletePending": "Delete Pending", "publishStatusLabel": "Release", + "apiInstruction": "API Instructions", + "apiInstructionPublishRequired": "Publish the workflow before viewing API instructions", + "apiEndpoint": "Endpoint", + "apiRequestExample": "Request Example", + "apiResponseExample": "Response Example", + "apiVariables": "Variables", + "apiVariablesEmpty": "This workflow has no start parameters", + "apiStatusExample": "Status Query Example", + "apiResumeExample": "Resume Example", "submitPublishApprovalConfirm": "Publish the current workflow now?", "submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitOfflineApprovalConfirm": "Take the current workflow offline?", diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json index 8f3f67b..9c13277 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json @@ -15,5 +15,6 @@ }, "permissions": "AuthInterface", "knowledgeSharePermission": "Knowledge Share", + "workflowApiPermission": "Workflow API", "addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index 5337026..4fd3eed 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -80,6 +80,15 @@ "publishStatusOffline": "已下线", "publishStatusDeletePending": "删除审批中", "publishStatusLabel": "发布状态", + "apiInstruction": "接口调用说明", + "apiInstructionPublishRequired": "发布后可获取 API 调用说明", + "apiEndpoint": "调用地址", + "apiRequestExample": "请求示例", + "apiResponseExample": "返回示例", + "apiVariables": "入参说明", + "apiVariablesEmpty": "当前工作流没有开始参数", + "apiStatusExample": "状态查询示例", + "apiResumeExample": "恢复执行示例", "submitPublishApprovalConfirm": "确认发布当前工作流吗?", "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json index fe21b23..a638663 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json @@ -15,5 +15,6 @@ }, "permissions": "授权接口", "knowledgeSharePermission": "知识库分享授权", + "workflowApiPermission": "工作流 API 调用授权", "addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成" } diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index bc87603..ba70f34 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -5,11 +5,15 @@ import type { ActionButton, CardPrimaryAction, } from '#/components/page/CardList.vue'; +import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact'; import { computed, h, markRaw, onMounted, ref } from 'vue'; +import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js'; + import { useAccess } from '@easyflow/access'; import { EasyFlowFormModal } from '@easyflow/common-ui'; +import { useAppConfig } from '@easyflow/hooks'; import { Check, @@ -17,6 +21,7 @@ import { Delete, Download, Edit, + Link, Lock, OfficeBuilding, Plus, @@ -26,6 +31,7 @@ import { VideoPlay, } from '@element-plus/icons-vue'; import { + ElDialog, ElForm, ElFormItem, ElIcon, @@ -49,10 +55,7 @@ import { $t } from '#/locales'; import { router } from '#/router'; import { useDictStore } from '#/store'; import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue'; -import { - buildOfflineImpactMessage, - type OfflineImpactCheck, -} from '#/views/ai/shared/offline-impact'; +import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact'; import { canAiResourceDelete, canAiResourceOffline, @@ -64,6 +67,8 @@ import { import WorkflowModal from './WorkflowModal.vue'; +const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); + interface FieldDefinition { // 字段名称 prop: string; @@ -79,6 +84,15 @@ interface FieldDefinition { type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC'; +interface ApiFieldDoc { + key: string; + label: string; + type: string; + required: boolean; + description: string; + placeholder: string; +} + const primaryAction: CardPrimaryAction = { icon: DesignIcon, text: $t('button.design'), @@ -94,6 +108,8 @@ const canManageWorkflow = computed(() => ); const updatingScopeId = ref(null); const visibilityScopePopoverRefs = ref>({}); +const apiInstructionVisible = ref(false); +const apiInstructionRow = ref(null); const visibilityScopeMeta = computed(() => ({ PRIVATE: { label: $t('aiWorkflow.visibilityScopePrivate'), @@ -158,6 +174,14 @@ const actions: ActionButton[] = [ }); }, }, + { + icon: Link, + text: $t('aiWorkflow.apiInstruction'), + placement: 'menu', + onClick: (row: any) => { + showApiInstruction(row); + }, + }, { icon: Download, text: $t('button.export'), @@ -199,7 +223,8 @@ const actions: ActionButton[] = [ text: $t('button.offline'), permission: '/api/v1/workflow/save', placement: 'menu', - visible: (row: any) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus), + visible: (row: any) => + canAiResourceOffline(row.displayPublishStatus, row.publishStatus), onClick: (row: any) => { submitOfflineAction(row); }, @@ -210,7 +235,8 @@ const actions: ActionButton[] = [ tone: 'danger', permission: '/api/v1/workflow/remove', placement: 'menu', - visible: (row: any) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus), + visible: (row: any) => + canAiResourceDelete(row.displayPublishStatus, row.publishStatus), onClick: (row: any) => { submitDeleteApproval(row); }, @@ -306,6 +332,321 @@ function resolveNavTitle(row: any) { function isRepublishAction(row: any) { return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus); } +function isWorkflowPublished(row: any) { + return ( + resolveAiResourceDisplayStatus( + row.displayPublishStatus, + row.publishStatus, + ) === 'PUBLISHED' + ); +} +function showApiInstruction(row: any) { + if (!isWorkflowPublished(row) || !getPublishedWorkflowContent(row)) { + ElMessage.warning($t('aiWorkflow.apiInstructionPublishRequired')); + return; + } + apiInstructionRow.value = row; + apiInstructionVisible.value = true; +} +function getPublishedWorkflowContent(row: any) { + const snapshot = row?.publishedSnapshotJson; + if (snapshot && typeof snapshot === 'object' && snapshot.content) { + return snapshot.content; + } + return null; +} +function parseWorkflowContent(row: any) { + const content = getPublishedWorkflowContent(row); + if (!content) { + return null; + } + if (typeof content === 'object') { + return content; + } + try { + return JSON.parse(String(content)); + } catch { + return null; + } +} +function normalizeApiOrigin(value?: string) { + const rawValue = String(value || '').trim(); + if (!rawValue) { + return window.location.origin; + } + if (/^https?:\/\//i.test(rawValue)) { + return rawValue.replace(/\/+$/, ''); + } + const normalizedPath = rawValue.startsWith('/') ? rawValue : `/${rawValue}`; + return `${window.location.origin}${normalizedPath}`.replace(/\/+$/, ''); +} +function resolveApiBaseUrl() { + return `${normalizeApiOrigin(apiURL)}/public-api/workflow`; +} +function resolveStartNodeData(row: any) { + const workflow = parseWorkflowContent(row); + const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : []; + const startNode = nodes.find((node: any) => node?.type === 'startNode'); + return startNode?.data || {}; +} +function resolveApiFields(row: any): ApiFieldDoc[] { + const startNodeData = resolveStartNodeData(row); + const schema = Array.isArray(startNodeData.startFormSchema) + ? startNodeData.startFormSchema + : []; + const parameters = Array.isArray(startNodeData.parameters) + ? startNodeData.parameters + : []; + const source = schema.length > 0 ? schema : parameters; + return source + .map((item: any) => { + const key = String(item?.key || item?.name || '').trim(); + if (!key) { + return null; + } + return { + key, + label: String(item?.label || item?.title || item?.name || key), + type: String(item?.type || item?.contentType || 'text'), + required: Boolean(item?.required), + description: String(item?.description || item?.formDescription || ''), + placeholder: String(item?.placeholder || item?.formPlaceholder || ''), + }; + }) + .filter(Boolean) as ApiFieldDoc[]; +} +function buildExampleVariables(row: any) { + const variables: Record = {}; + for (const field of resolveApiFields(row)) { + if (field.type === 'file') { + variables[field.key] = [ + { + fileName: 'example.pdf', + filePath: 'https://example.com/example.pdf', + }, + ]; + continue; + } + if (field.type === 'checkbox') { + variables[field.key] = []; + continue; + } + variables[field.key] = field.placeholder || field.label; + } + return variables; +} +function buildRunRequestExample(row: any) { + return JSON.stringify( + { + id: row?.id, + variables: buildExampleVariables(row), + }, + null, + 2, + ); +} +function buildRunResponseExample() { + return JSON.stringify( + { + errorCode: 0, + message: '成功', + data: '执行ID', + }, + null, + 2, + ); +} +function buildResumeRequestExample() { + return JSON.stringify( + { + executeId: '执行ID', + confirmParams: { + confirm: true, + }, + }, + null, + 2, + ); +} +async function copyApiContent(content: string) { + try { + await navigator.clipboard.writeText(content); + ElMessage.success($t('message.copySuccess')); + } catch { + ElMessage.error($t('message.copyFail')); + } +} +function handleApiDocClick(e: MouseEvent) { + const target = (e.target as HTMLElement).closest('.api-url-copy-btn'); + if (!target) return; + const url = (target as HTMLElement).dataset.copy; + if (url) copyApiContent(url); +} +function apiUrlLine(method: string, url: string) { + const escaped = url.replace(/"/g, '"'); + return `\`${method}\` \`${url}\` `; +} + +const apiDocMarkdown = computed(() => { + const row = apiInstructionRow.value; + if (!row) return ''; + + const baseUrl = resolveApiBaseUrl(); + const fields = resolveApiFields(row); + + const lines: string[] = []; + + // ---- 概述 ---- + lines.push(`## 概述`); + lines.push(``); + lines.push(`通过 Public API 可异步执行已发布的工作流并获取执行结果。`); + lines.push(``); + lines.push(`**调用流程**:发起执行 → 轮询查询执行结果 → (若有确认节点)恢复执行`); + lines.push(``); + + // ---- 鉴权 ---- + lines.push(`## 鉴权`); + lines.push(``); + lines.push(`所有请求需在 Header 中携带访问令牌,访问令牌需开启 **工作流 API 调用授权**。`); + lines.push(``); + lines.push('```'); + lines.push(`ApiKey: `); + lines.push('```'); + lines.push(``); + lines.push(`---`); + lines.push(``); + + // ---- 1. 发起执行 ---- + lines.push(`## 1. 发起执行`); + lines.push(``); + lines.push(apiUrlLine('POST', `${baseUrl}/runAsync`)); + lines.push(``); + lines.push(`异步执行工作流,立即返回执行 ID。工作流必须已发布且存在发布快照。`); + lines.push(``); + lines.push(`### 请求体`); + lines.push(``); + lines.push('```json'); + lines.push(buildRunRequestExample(row)); + lines.push('```'); + lines.push(``); + + // 入参说明 + lines.push(`### 入参说明`); + lines.push(``); + lines.push(`| 参数 | 类型 | 必填 | 说明 |`); + lines.push(`| --- | --- | --- | --- |`); + lines.push(`| id | string | 是 | 工作流 ID |`); + lines.push(`| variables | object | 否 | 运行参数,字段说明见下方 |`); + lines.push(``); + + if (fields.length > 0) { + lines.push(`#### variables 字段明细`); + lines.push(``); + lines.push(`以下字段基于当前发布快照的开始节点配置生成。`); + lines.push(``); + lines.push(`| 参数 | 类型 | 必填 | 说明 |`); + lines.push(`| --- | --- | --- | --- |`); + for (const f of fields) { + const desc = [f.label, f.description].filter(Boolean).join(' · '); + lines.push(`| ${f.key} | ${f.type} | ${f.required ? '是' : '否'} | ${desc} |`); + } + lines.push(``); + } + + lines.push(`### 响应`); + lines.push(``); + lines.push('```json'); + lines.push(buildRunResponseExample()); + lines.push('```'); + lines.push(``); + lines.push(`\`data\` 为 **执行 ID**(executeId),后续查询和恢复均需使用此 ID。`); + lines.push(``); + lines.push(`---`); + lines.push(``); + + // ---- 2. 查询执行结果 ---- + lines.push(`## 2. 查询执行结果`); + lines.push(``); + lines.push(apiUrlLine('POST', `${baseUrl}/getChainStatus`)); + lines.push(``); + lines.push(`查询工作流执行的整体状态与各节点执行详情。工作流为异步执行,建议**轮询**该接口直到状态为终态。`); + lines.push(``); + lines.push(`### 请求体`); + lines.push(``); + lines.push('```json'); + lines.push(JSON.stringify({ executeId: '' }, null, 2)); + lines.push('```'); + lines.push(``); + lines.push(`> \`nodes\` 参数可选。不传则只返回工作流整体状态;传入节点 ID 数组可额外获取对应节点的执行详情。`); + lines.push(``); + lines.push(`### 响应示例`); + lines.push(``); + lines.push('```json'); + lines.push(JSON.stringify({ + errorCode: 0, + message: '成功', + data: { + executeId: 'abc5358c-a310-4caa-97ec-455062b2235e', + status: 'FINISHED', + message: null, + result: { output: '工作流执行结果' }, + nodes: {}, + }, + }, null, 2)); + lines.push('```'); + lines.push(``); + lines.push(`### 状态值说明`); + lines.push(``); + lines.push(`| 状态 | 说明 |`); + lines.push(`| --- | --- |`); + lines.push(`| READY | 就绪,尚未开始 |`); + lines.push(`| RUNNING | 执行中 |`); + lines.push(`| SUSPEND | 挂起,等待确认节点恢复 |`); + lines.push(`| FINISHED | 执行完成 |`); + lines.push(`| FAILED | 执行失败 |`); + lines.push(`| ERROR | 执行异常 |`); + lines.push(``); + + // ---- 3. 恢复执行 ---- + lines.push(`---`); + lines.push(``); + lines.push(`## 3. 恢复执行(确认节点)`); + lines.push(``); + lines.push(apiUrlLine('POST', `${baseUrl}/resume`)); + lines.push(``); + lines.push(`当工作流包含**确认节点**时,执行到该节点后状态变为 \`SUSPEND\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`); + lines.push(``); + lines.push(`### 请求体`); + lines.push(``); + lines.push('```json'); + lines.push(buildResumeRequestExample()); + lines.push('```'); + lines.push(``); + lines.push(`### 响应`); + lines.push(``); + lines.push('```json'); + lines.push(JSON.stringify({ errorCode: 0, message: '成功', data: null }, null, 2)); + lines.push('```'); + lines.push(``); + lines.push(`恢复后可继续轮询 \`getChainStatus\` 获取后续执行状态。`); + lines.push(``); + + // ---- 错误码 ---- + lines.push(`---`); + lines.push(``); + lines.push(`## 错误处理`); + lines.push(``); + lines.push(`当请求失败时,\`errorCode\` 不为 0,\`message\` 包含错误原因。常见错误:`); + lines.push(``); + lines.push(`| 场景 | 说明 |`); + lines.push(`| --- | --- |`); + lines.push(`| ApiKey 无效或过期 | 检查访问令牌状态与有效期 |`); + lines.push(`| 未授权工作流 API 调用 | 在访问令牌中开启「工作流 API 调用授权」 |`); + lines.push(`| 工作流尚未发布 | 仅已发布且存在发布快照的工作流可通过 API 调用 |`); + + return lines.join('\n'); +}); async function submitPublishAction(row: any) { if ( @@ -329,12 +670,9 @@ async function submitPublishAction(row: any) { } catch { return; } - const res = await api.post( - '/api/v1/workflow/submitPublishApproval', - { - id: row.id, - }, - ); + const res = await api.post('/api/v1/workflow/submitPublishApproval', { + id: row.id, + }); if (res.errorCode === 0) { ElMessage.success(res.message || $t('message.saveOkMessage')); pageDataRef.value?.reload?.(); @@ -350,17 +688,15 @@ async function submitOfflineAction(row: any) { const impactRes = await api.get<{ data: OfflineImpactCheck; errorCode: number; - }>( - '/api/v1/workflow/offlineImpactCheck', - { - params: { id: row.id }, - }, - ); + }>('/api/v1/workflow/offlineImpactCheck', { + params: { id: row.id }, + }); if (impactRes.errorCode !== 0) { return; } try { const sections = []; + let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundBotsFooter'); if (impactRes.data?.hasBotBindings) { sections.push( buildOfflineImpactMessage( @@ -383,32 +719,23 @@ async function submitOfflineAction(row: any) { ), ); } + if (impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings) { + offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundMixedFooter'); + } else if (impactRes.data?.hasPluginBindings) { + offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundPluginsFooter'); + } const impactMessage = sections.length > 0 ? h('div', [ ...sections, - h( - 'p', - { - style: 'margin-top: 12px;', - }, - impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings - ? $t('aiWorkflow.offlineImpactBoundMixedFooter') - : impactRes.data?.hasPluginBindings - ? $t('aiWorkflow.offlineImpactBoundPluginsFooter') - : $t('aiWorkflow.offlineImpactBoundBotsFooter'), - ), + h('p', { style: 'margin-top: 12px;' }, offlineImpactFooter), ]) : $t('aiWorkflow.submitOfflineApprovalConfirm'); - await ElMessageBox.confirm( - impactMessage, - $t('message.noticeTitle'), - { - confirmButtonText: $t('button.confirm'), - cancelButtonText: $t('button.cancel'), - type: 'warning', - }, - ); + await ElMessageBox.confirm(impactMessage, $t('message.noticeTitle'), { + confirmButtonText: $t('button.confirm'), + cancelButtonText: $t('button.cancel'), + type: 'warning', + }); } catch { return; } @@ -459,18 +786,18 @@ function resolvePublishStatusMetaByInstance( tone: 'danger', }; } - case 'OFFLINE_PENDING': { - return { - label: $t('aiWorkflow.publishStatusOfflinePending'), - tone: 'pending', - }; - } case 'OFFLINE': { return { label: $t('aiWorkflow.publishStatusOffline'), tone: 'draft', }; } + case 'OFFLINE_PENDING': { + return { + label: $t('aiWorkflow.publishStatusOfflinePending'), + tone: 'pending', + }; + } case 'PUBLISH_PENDING': { return { label: $t('aiWorkflow.publishStatusPublishPending'), @@ -668,6 +995,25 @@ function handleHeaderButtonClick(data: any) { @@ -1102,4 +1451,153 @@ button.workflow-scope-chip:disabled { border-radius: 16px; box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 20%); } + +.workflow-api-markdown-wrap { + max-height: 65vh; + padding: 0 4px; + overflow-y: auto; + font-size: 14px; + line-height: 1.7; + color: hsl(var(--foreground)); +} + +.workflow-api-markdown-wrap::-webkit-scrollbar { + width: 6px; +} + +.workflow-api-markdown-wrap::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 20%); + border-radius: 3px; +} + +.workflow-api-markdown-wrap :deep(h2) { + padding-bottom: 8px; + margin-top: 28px; + margin-bottom: 16px; + font-size: 18px; + font-weight: 700; + color: hsl(var(--foreground)); + border-bottom: 1px solid hsl(var(--border)); +} + +.workflow-api-markdown-wrap :deep(h2:first-child) { + margin-top: 0; +} + +.workflow-api-markdown-wrap :deep(h3) { + margin-top: 20px; + margin-bottom: 10px; + font-size: 15px; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.workflow-api-markdown-wrap :deep(h4) { + margin-top: 16px; + margin-bottom: 8px; + font-size: 14px; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.workflow-api-markdown-wrap :deep(hr) { + margin: 24px 0; + border: none; + border-top: 1px solid hsl(var(--border)); +} + +.workflow-api-markdown-wrap :deep(p) { + margin: 8px 0; +} + +.workflow-api-markdown-wrap :deep(code) { + padding: 2px 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 50%); + border-radius: 4px; +} + +.workflow-api-markdown-wrap :deep(pre) { + max-height: 280px; + padding: 14px 16px; + margin: 10px 0; + overflow: auto; + background: hsl(220 14% 96%); + border: 1px solid hsl(var(--border)); + border-radius: 8px; +} + +.workflow-api-markdown-wrap :deep(pre code) { + padding: 0; + font-size: 12.5px; + color: hsl(var(--foreground)); + background: transparent; + border-radius: 0; +} + +.workflow-api-markdown-wrap :deep(table) { + width: 100%; + margin: 10px 0; + border-collapse: collapse; +} + +.workflow-api-markdown-wrap :deep(th), +.workflow-api-markdown-wrap :deep(td) { + padding: 8px 12px; + font-size: 13px; + text-align: left; + border: 1px solid hsl(var(--border)); +} + +.workflow-api-markdown-wrap :deep(th) { + font-weight: 600; + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 40%); +} + +.workflow-api-markdown-wrap :deep(td) { + color: hsl(var(--foreground) / 85%); +} + +.workflow-api-markdown-wrap :deep(blockquote) { + padding: 8px 16px; + margin: 10px 0; + color: hsl(var(--muted-foreground)); + border-left: 3px solid hsl(var(--primary) / 40%); +} + +.workflow-api-markdown-wrap :deep(strong) { + font-weight: 600; + color: hsl(var(--foreground)); +} + +.workflow-api-markdown-wrap :deep(.api-url-copy-btn) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin-left: 6px; + color: hsl(var(--muted-foreground)); + vertical-align: middle; + cursor: pointer; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + transition: all 0.15s; +} + +.workflow-api-markdown-wrap :deep(.api-url-copy-btn:hover) { + color: hsl(var(--primary)); + background: hsl(var(--muted) / 50%); + border-color: hsl(var(--primary) / 40%); +} + +:global(.workflow-api-dialog .el-dialog__body) { + padding: 0 20px 20px; + overflow: hidden; +} diff --git a/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue b/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue index cc62180..065fb5d 100644 --- a/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue +++ b/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue @@ -33,6 +33,7 @@ interface Entity { expiredAt: Date | null | string; permissionIds: (number | string)[]; // 绑定值:权限 ID 数组 knowledgeShareEnabled: boolean; + workflowApiEnabled: boolean; id?: number; // 编辑时的主键 } @@ -51,6 +52,7 @@ const entity = ref({ expiredAt: null, permissionIds: [], knowledgeShareEnabled: false, + workflowApiEnabled: false, }); // 加载状态 const btnLoading = ref(false); @@ -120,6 +122,7 @@ function getResourcePermissionList() { function createDefaultEntity(row: Partial = {}): Entity { const permissionIds = row.permissionIds || []; const knowledgeShareEnabled = Boolean(row.knowledgeShareEnabled); + const workflowApiEnabled = Boolean(row.workflowApiEnabled); return { apiKey: '', status: '', @@ -128,6 +131,7 @@ function createDefaultEntity(row: Partial = {}): Entity { ...row, permissionIds, knowledgeShareEnabled, + workflowApiEnabled, }; } @@ -183,6 +187,7 @@ function closeDialog() { expiredAt: null, permissionIds: [], knowledgeShareEnabled: false, + workflowApiEnabled: false, }; isAdd.value = true; dialogVisible.value = false; @@ -261,6 +266,12 @@ defineExpose({ > {{ $t('sysApiKey.knowledgeSharePermission') }} + + {{ $t('sysApiKey.workflowApiPermission') }} +