feat: 完成工作流 Public API 授权闭环

- 新增访问令牌工作流 API 全局授权与 Public Workflow API 权限断言

- 补齐 API Key 执行记录归属、状态查询与下线后不可恢复边界

- 增加管理端接口调用说明与访问令牌授权开关
This commit is contained in:
2026-05-14 20:41:34 +08:00
parent 47c2bad839
commit da58077d59
15 changed files with 919 additions and 62 deletions

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService; import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -46,6 +47,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService; private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
@Resource @Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService; private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
/** /**
* 添加(保存)数据 * 添加(保存)数据
* *
@@ -88,6 +91,9 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
if (entity.getKnowledgeShareEnabled() != null) { if (entity.getKnowledgeShareEnabled() != null) {
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled()); knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
} }
if (entity.getWorkflowApiEnabled() != null) {
workflowApiPermissionService.replaceWorkflowApiEnabled(entity.getId(), entity.getWorkflowApiEnabled());
}
} }
@Override @Override
@@ -129,5 +135,11 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId()) .eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE"); .eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0); entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 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);
} }
} }

View File

@@ -1,12 +1,17 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.system.entity.SysApiKeyResource; import tech.easyflow.system.entity.SysApiKeyResource;
import tech.easyflow.system.service.SysApiKeyResourceService; import tech.easyflow.system.service.SysApiKeyResourceService;
import java.util.List;
/** /**
* 请求接口表 控制层。 * 请求接口表 控制层。
* *
@@ -20,4 +25,26 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
public SysApiKeyResourceController(SysApiKeyResourceService service) { public SysApiKeyResourceController(SysApiKeyResourceService service) {
super(service); super(service);
} }
/**
* 查询普通 API Key 接口授权资源。
*
* <p>工作流 Public API 使用独立的全局授权开关,不进入普通接口授权列表,避免用户误以为勾选
* 具体接口资源即可完成工作流调用授权。</p>
*
* @param entity 查询条件
* @param asTree 是否树形返回
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 普通接口授权资源
*/
@Override
@GetMapping("list")
public Result<List<SysApiKeyResource>> 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));
}
} }

View File

@@ -4,22 +4,31 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tech.easyflow.approval.annotation.RequirePublishedAccess; 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.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.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow; 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.WorkflowService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.HashMap; import java.util.HashMap;
@@ -43,6 +52,10 @@ public class PublicWorkflowController {
private WorkflowCheckService workflowCheckService; private WorkflowCheckService workflowCheckService;
@Resource @Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver; private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
/** /**
* 通过id或别名获取工作流详情 * 通过id或别名获取工作流详情
@@ -54,8 +67,11 @@ public class PublicWorkflowController {
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
public Result<Workflow> getByIdOrAlias( public Result<Workflow> getByIdOrAlias(
@RequestParam @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); Workflow workflow = workflowService.getPublishedDetail(key);
assertStrictPublishedWorkflow(workflow);
return Result.ok(workflow); return Result.ok(workflow);
} }
@@ -88,19 +104,20 @@ public class PublicWorkflowController {
* 运行工作流 - v2 * 运行工作流 - v2
*/ */
@PostMapping("/runAsync") @PostMapping("/runAsync")
@SaCheckPermission("/api/v1/workflow/save")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") @RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id, public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
@JsonBody("variables") Map<String, Object> variables) { @JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
if (variables == null) { if (variables == null) {
variables = new HashMap<>(); variables = new HashMap<>();
} }
Workflow workflow = workflowService.getPublishedById(id); Workflow workflow = workflowService.getPublishedById(id);
if (workflow == null) { assertStrictPublishedWorkflow(workflow);
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); 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); String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
return Result.ok(executeId); return Result.ok(executeId);
} }
@@ -110,7 +127,10 @@ public class PublicWorkflowController {
*/ */
@PostMapping("/getChainStatus") @PostMapping("/getChainStatus")
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId, public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) { @JsonBody("nodes") List<NodeInfo> nodes,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
assertApiKeyExecutionOwnership(apiKey, executeId);
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes); ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
return Result.ok(res); return Result.ok(res);
} }
@@ -119,22 +139,23 @@ public class PublicWorkflowController {
* 恢复工作流运行 - v2 * 恢复工作流运行 - v2
*/ */
@PostMapping("/resume") @PostMapping("/resume")
@SaCheckPermission("/api/v1/workflow/save")
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId, public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) { @JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
chainExecutor.resumeAsync(executeId, confirmParams); chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok(); return Result.ok();
} }
@GetMapping("getRunningParameters") @GetMapping("getRunningParameters")
@SaCheckPermission("/api/v1/workflow/query")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布") @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); Workflow workflow = workflowService.getPublishedById(id);
if (workflow == null) { assertStrictPublishedWorkflow(workflow);
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); Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) { if (res == null) {
@@ -142,4 +163,72 @@ public class PublicWorkflowController {
} }
return Result.ok(res); 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("工作流已下线或不可恢复执行");
}
}
} }

View File

@@ -82,7 +82,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setWorkflowJson(workflow.getContent()); record.setWorkflowJson(workflow.getContent());
record.setStartTime(new Date()); record.setStartTime(new Date());
record.setStatus(state.getStatus().getValue()); record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.USER_KEY); record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString()); record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
try { try {
workflowExecResultService.save(record); workflowExecResultService.save(record);

View File

@@ -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);
}

View File

@@ -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<String> 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<SysApiKeyResourceMapping> 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;
}
}

View File

@@ -12,7 +12,9 @@ import java.math.BigInteger;
public class WorkFlowUtil { public class WorkFlowUtil {
public final static String USER_KEY = "user"; 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 WORKFLOW_KEY = "workflow";
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
public static String removeSensitiveInfo(String originJson) { public static String removeSensitiveInfo(String originJson) {
JSONObject workflowInfo = JSON.parseObject(originJson); JSONObject workflowInfo = JSON.parseObject(originJson);
@@ -35,6 +37,17 @@ public class WorkFlowUtil {
return cache == null ? defaultAccount() : (LoginAccount) cache; 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() { public static LoginAccount defaultAccount() {
LoginAccount account = new LoginAccount(); LoginAccount account = new LoginAccount();
account.setId(new BigInteger("0")); account.setId(new BigInteger("0"));

View File

@@ -26,6 +26,9 @@ public class SysApiKey extends SysApiKeyBase {
@Column(ignore = true) @Column(ignore = true)
private Boolean knowledgeShareEnabled; private Boolean knowledgeShareEnabled;
@Column(ignore = true)
private Boolean workflowApiEnabled;
@RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping") @RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping")
private List<SysApiKeyResourceMapping> resourcePermissions; private List<SysApiKeyResourceMapping> resourcePermissions;
@@ -52,4 +55,12 @@ public class SysApiKey extends SysApiKeyBase {
public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) { public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) {
this.knowledgeShareEnabled = knowledgeShareEnabled; this.knowledgeShareEnabled = knowledgeShareEnabled;
} }
public Boolean getWorkflowApiEnabled() {
return workflowApiEnabled;
}
public void setWorkflowApiEnabled(Boolean workflowApiEnabled) {
this.workflowApiEnabled = workflowApiEnabled;
}
} }

View File

@@ -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`);

View File

@@ -80,6 +80,15 @@
"publishStatusOffline": "Offline", "publishStatusOffline": "Offline",
"publishStatusDeletePending": "Delete Pending", "publishStatusDeletePending": "Delete Pending",
"publishStatusLabel": "Release", "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?", "submitPublishApprovalConfirm": "Publish the current workflow now?",
"submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?", "submitOfflineApprovalConfirm": "Take the current workflow offline?",

View File

@@ -15,5 +15,6 @@
}, },
"permissions": "AuthInterface", "permissions": "AuthInterface",
"knowledgeSharePermission": "Knowledge Share", "knowledgeSharePermission": "Knowledge Share",
"workflowApiPermission": "Workflow API",
"addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed" "addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed"
} }

View File

@@ -80,6 +80,15 @@
"publishStatusOffline": "已下线", "publishStatusOffline": "已下线",
"publishStatusDeletePending": "删除审批中", "publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态", "publishStatusLabel": "发布状态",
"apiInstruction": "接口调用说明",
"apiInstructionPublishRequired": "发布后可获取 API 调用说明",
"apiEndpoint": "调用地址",
"apiRequestExample": "请求示例",
"apiResponseExample": "返回示例",
"apiVariables": "入参说明",
"apiVariablesEmpty": "当前工作流没有开始参数",
"apiStatusExample": "状态查询示例",
"apiResumeExample": "恢复执行示例",
"submitPublishApprovalConfirm": "确认发布当前工作流吗?", "submitPublishApprovalConfirm": "确认发布当前工作流吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?",

View File

@@ -15,5 +15,6 @@
}, },
"permissions": "授权接口", "permissions": "授权接口",
"knowledgeSharePermission": "知识库分享授权", "knowledgeSharePermission": "知识库分享授权",
"workflowApiPermission": "工作流 API 调用授权",
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成" "addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
} }

View File

@@ -5,11 +5,15 @@ import type {
ActionButton, ActionButton,
CardPrimaryAction, CardPrimaryAction,
} from '#/components/page/CardList.vue'; } from '#/components/page/CardList.vue';
import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact';
import { computed, h, markRaw, onMounted, ref } from 'vue'; 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 { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
import { useAppConfig } from '@easyflow/hooks';
import { import {
Check, Check,
@@ -17,6 +21,7 @@ import {
Delete, Delete,
Download, Download,
Edit, Edit,
Link,
Lock, Lock,
OfficeBuilding, OfficeBuilding,
Plus, Plus,
@@ -26,6 +31,7 @@ import {
VideoPlay, VideoPlay,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
import { import {
ElDialog,
ElForm, ElForm,
ElFormItem, ElFormItem,
ElIcon, ElIcon,
@@ -49,10 +55,7 @@ import { $t } from '#/locales';
import { router } from '#/router'; import { router } from '#/router';
import { useDictStore } from '#/store'; import { useDictStore } from '#/store';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue'; import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import { import {
canAiResourceDelete, canAiResourceDelete,
canAiResourceOffline, canAiResourceOffline,
@@ -64,6 +67,8 @@ import {
import WorkflowModal from './WorkflowModal.vue'; import WorkflowModal from './WorkflowModal.vue';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
interface FieldDefinition { interface FieldDefinition {
// 字段名称 // 字段名称
prop: string; prop: string;
@@ -79,6 +84,15 @@ interface FieldDefinition {
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC'; type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
interface ApiFieldDoc {
key: string;
label: string;
type: string;
required: boolean;
description: string;
placeholder: string;
}
const primaryAction: CardPrimaryAction = { const primaryAction: CardPrimaryAction = {
icon: DesignIcon, icon: DesignIcon,
text: $t('button.design'), text: $t('button.design'),
@@ -94,6 +108,8 @@ const canManageWorkflow = computed(() =>
); );
const updatingScopeId = ref<null | number | string>(null); const updatingScopeId = ref<null | number | string>(null);
const visibilityScopePopoverRefs = ref<Record<string, any>>({}); const visibilityScopePopoverRefs = ref<Record<string, any>>({});
const apiInstructionVisible = ref(false);
const apiInstructionRow = ref<any>(null);
const visibilityScopeMeta = computed(() => ({ const visibilityScopeMeta = computed(() => ({
PRIVATE: { PRIVATE: {
label: $t('aiWorkflow.visibilityScopePrivate'), 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, icon: Download,
text: $t('button.export'), text: $t('button.export'),
@@ -199,7 +223,8 @@ const actions: ActionButton[] = [
text: $t('button.offline'), text: $t('button.offline'),
permission: '/api/v1/workflow/save', permission: '/api/v1/workflow/save',
placement: 'menu', placement: 'menu',
visible: (row: any) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus), visible: (row: any) =>
canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => { onClick: (row: any) => {
submitOfflineAction(row); submitOfflineAction(row);
}, },
@@ -210,7 +235,8 @@ const actions: ActionButton[] = [
tone: 'danger', tone: 'danger',
permission: '/api/v1/workflow/remove', permission: '/api/v1/workflow/remove',
placement: 'menu', placement: 'menu',
visible: (row: any) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus), visible: (row: any) =>
canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => { onClick: (row: any) => {
submitDeleteApproval(row); submitDeleteApproval(row);
}, },
@@ -306,6 +332,321 @@ function resolveNavTitle(row: any) {
function isRepublishAction(row: any) { function isRepublishAction(row: any) {
return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus); 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<string, any> = {};
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, '&quot;');
return `\`${method}\` \`${url}\` <button class="api-url-copy-btn" data-copy="${escaped}" title="复制">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
}
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: <your-api-key>`);
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: '<runAsync 返回的执行 ID>' }, 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) { async function submitPublishAction(row: any) {
if ( if (
@@ -329,12 +670,9 @@ async function submitPublishAction(row: any) {
} catch { } catch {
return; return;
} }
const res = await api.post( const res = await api.post('/api/v1/workflow/submitPublishApproval', {
'/api/v1/workflow/submitPublishApproval', id: row.id,
{ });
id: row.id,
},
);
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage')); ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.(); pageDataRef.value?.reload?.();
@@ -350,17 +688,15 @@ async function submitOfflineAction(row: any) {
const impactRes = await api.get<{ const impactRes = await api.get<{
data: OfflineImpactCheck; data: OfflineImpactCheck;
errorCode: number; errorCode: number;
}>( }>('/api/v1/workflow/offlineImpactCheck', {
'/api/v1/workflow/offlineImpactCheck', params: { id: row.id },
{ });
params: { id: row.id },
},
);
if (impactRes.errorCode !== 0) { if (impactRes.errorCode !== 0) {
return; return;
} }
try { try {
const sections = []; const sections = [];
let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundBotsFooter');
if (impactRes.data?.hasBotBindings) { if (impactRes.data?.hasBotBindings) {
sections.push( sections.push(
buildOfflineImpactMessage( 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 = const impactMessage =
sections.length > 0 sections.length > 0
? h('div', [ ? h('div', [
...sections, ...sections,
h( h('p', { style: 'margin-top: 12px;' }, offlineImpactFooter),
'p',
{
style: 'margin-top: 12px;',
},
impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundMixedFooter')
: impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundPluginsFooter')
: $t('aiWorkflow.offlineImpactBoundBotsFooter'),
),
]) ])
: $t('aiWorkflow.submitOfflineApprovalConfirm'); : $t('aiWorkflow.submitOfflineApprovalConfirm');
await ElMessageBox.confirm( await ElMessageBox.confirm(impactMessage, $t('message.noticeTitle'), {
impactMessage, confirmButtonText: $t('button.confirm'),
$t('message.noticeTitle'), cancelButtonText: $t('button.cancel'),
{ type: 'warning',
confirmButtonText: $t('button.confirm'), });
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch { } catch {
return; return;
} }
@@ -459,18 +786,18 @@ function resolvePublishStatusMetaByInstance(
tone: 'danger', tone: 'danger',
}; };
} }
case 'OFFLINE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': { case 'OFFLINE': {
return { return {
label: $t('aiWorkflow.publishStatusOffline'), label: $t('aiWorkflow.publishStatusOffline'),
tone: 'draft', tone: 'draft',
}; };
} }
case 'OFFLINE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'PUBLISH_PENDING': { case 'PUBLISH_PENDING': {
return { return {
label: $t('aiWorkflow.publishStatusPublishPending'), label: $t('aiWorkflow.publishStatusPublishPending'),
@@ -668,6 +995,25 @@ function handleHeaderButtonClick(data: any) {
<template> <template>
<div class="flex h-full flex-col gap-6 p-6"> <div class="flex h-full flex-col gap-6 p-6">
<WorkflowModal ref="saveDialog" @reload="reset" /> <WorkflowModal ref="saveDialog" @reload="reset" />
<ElDialog
v-model="apiInstructionVisible"
:title="$t('aiWorkflow.apiInstruction')"
width="780px"
class="workflow-api-dialog"
:footer="false"
>
<div
v-if="apiInstructionRow"
class="workflow-api-markdown-wrap"
@click="handleApiDocClick"
>
<ElXMarkdown
:markdown="apiDocMarkdown"
:allow-html="true"
:sanitize="false"
/>
</div>
</ElDialog>
<HeaderSearch <HeaderSearch
:buttons="headerButtons" :buttons="headerButtons"
@search="handleSearch" @search="handleSearch"
@@ -705,7 +1051,10 @@ function handleHeaderButtonClick(data: any) {
> >
<span class="workflow-publish-chip__dot"></span> <span class="workflow-publish-chip__dot"></span>
<span>{{ <span>{{
resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).label resolvePublishStatusMetaByInstance(
item.displayPublishStatus,
item.publishStatus,
).label
}}</span> }}</span>
</div> </div>
</template> </template>
@@ -1102,4 +1451,153 @@ button.workflow-scope-chip:disabled {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 20%); 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;
}
</style> </style>

View File

@@ -33,6 +33,7 @@ interface Entity {
expiredAt: Date | null | string; expiredAt: Date | null | string;
permissionIds: (number | string)[]; // 绑定值:权限 ID 数组 permissionIds: (number | string)[]; // 绑定值:权限 ID 数组
knowledgeShareEnabled: boolean; knowledgeShareEnabled: boolean;
workflowApiEnabled: boolean;
id?: number; // 编辑时的主键 id?: number; // 编辑时的主键
} }
@@ -51,6 +52,7 @@ const entity = ref<Entity>({
expiredAt: null, expiredAt: null,
permissionIds: [], permissionIds: [],
knowledgeShareEnabled: false, knowledgeShareEnabled: false,
workflowApiEnabled: false,
}); });
// 加载状态 // 加载状态
const btnLoading = ref(false); const btnLoading = ref(false);
@@ -120,6 +122,7 @@ function getResourcePermissionList() {
function createDefaultEntity(row: Partial<Entity> = {}): Entity { function createDefaultEntity(row: Partial<Entity> = {}): Entity {
const permissionIds = row.permissionIds || []; const permissionIds = row.permissionIds || [];
const knowledgeShareEnabled = Boolean(row.knowledgeShareEnabled); const knowledgeShareEnabled = Boolean(row.knowledgeShareEnabled);
const workflowApiEnabled = Boolean(row.workflowApiEnabled);
return { return {
apiKey: '', apiKey: '',
status: '', status: '',
@@ -128,6 +131,7 @@ function createDefaultEntity(row: Partial<Entity> = {}): Entity {
...row, ...row,
permissionIds, permissionIds,
knowledgeShareEnabled, knowledgeShareEnabled,
workflowApiEnabled,
}; };
} }
@@ -183,6 +187,7 @@ function closeDialog() {
expiredAt: null, expiredAt: null,
permissionIds: [], permissionIds: [],
knowledgeShareEnabled: false, knowledgeShareEnabled: false,
workflowApiEnabled: false,
}; };
isAdd.value = true; isAdd.value = true;
dialogVisible.value = false; dialogVisible.value = false;
@@ -261,6 +266,12 @@ defineExpose({
> >
{{ $t('sysApiKey.knowledgeSharePermission') }} {{ $t('sysApiKey.knowledgeSharePermission') }}
</ElCheckbox> </ElCheckbox>
<ElCheckbox
v-model="entity.workflowApiEnabled"
class="permission-checkbox"
>
{{ $t('sysApiKey.workflowApiPermission') }}
</ElCheckbox>
</div> </div>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>