feat: 新增环境配置管理和接口编辑增强

- 新增环境配置(EnvironmentConfig)的增删改查和激活功能
- API 接口支持 description、request_params、response_params 字段
- 接口管理页面支持抽屉内编辑(替代跳转编辑页)
- 新增版本管理页面和工作流列表页面
- catalog_snapshot 返回 environments 数据

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
qihongkun 2026-05-25 15:44:21 +08:00
parent 32cfb20999
commit 3ca520e398
9 changed files with 1087 additions and 38 deletions

View File

@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
from .database import Base, SessionLocal, engine, get_db from .database import Base, SessionLocal, engine, get_db
from .models import ( from .models import (
ApiDefinition, ApiDefinition,
EnvironmentConfig,
FolderEntry, FolderEntry,
McpToolConfig, McpToolConfig,
MockDataset, MockDataset,
@ -77,6 +78,9 @@ from .schemas import (
SshShortcutOut, SshShortcutOut,
SshShortcutUpdate, SshShortcutUpdate,
RunWorkflowRequest, RunWorkflowRequest,
EnvironmentConfigCreate,
EnvironmentConfigOut,
EnvironmentConfigUpdate,
UserApiKeyCreate, UserApiKeyCreate,
UserApiKeyCreateResponse, UserApiKeyCreateResponse,
UserApiKeyOut, UserApiKeyOut,
@ -287,6 +291,9 @@ MCP_MUTATION_TOOLS = frozenset(
"ssh_script_upsert", "ssh_script_upsert",
"workflow_batch_create", "workflow_batch_create",
"workflow_batch_update", "workflow_batch_update",
"env_upsert",
"env_activate",
"env_delete",
} }
) )
@ -614,7 +621,7 @@ def _delete_ssh_script_files(row: SshScript) -> None:
MCP_TOOL_SPECS = [ MCP_TOOL_SPECS = [
{ {
"name": "api_upsert", "name": "api_upsert",
"description": "Create or update one API definition.", "description": "Create or update one API definition (supports description and request/response param schema).",
"input_schema": { "input_schema": {
"type": "object", "type": "object",
"required": ["name", "method", "url"], "required": ["name", "method", "url"],
@ -624,11 +631,38 @@ MCP_TOOL_SPECS = [
"folder_path": {"type": "string"}, "folder_path": {"type": "string"},
"method": {"type": "string"}, "method": {"type": "string"},
"url": {"type": "string"}, "url": {"type": "string"},
"description": {"type": "string", "description": "API description / notes"},
"headers": {"type": "object"}, "headers": {"type": "object"},
"body": {"type": "object"}, "body": {"type": "object"},
"query": {"type": "object"}, "query": {"type": "object"},
"path_params": {"type": "object"}, "path_params": {"type": "object"},
"timeout_seconds": {"type": "number"}, "timeout_seconds": {"type": "number"},
"request_params": {
"type": "array",
"description": "Request parameter schema",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["string", "integer", "number", "boolean", "object", "array"]},
"required": {"type": "boolean"},
"description": {"type": "string"},
},
},
},
"response_params": {
"type": "array",
"description": "Response parameter schema",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["string", "integer", "number", "boolean", "object", "array"]},
"required": {"type": "boolean"},
"description": {"type": "string"},
},
},
},
}, },
}, },
}, },
@ -966,6 +1000,44 @@ MCP_TOOL_SPECS = [
}, },
}, },
}, },
{
"name": "env_list",
"description": "List all environment configurations (base_url + auth_token versions).",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "env_upsert",
"description": "Create or update an environment configuration (base_url, auth_token). Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["name", "base_url"],
"properties": {
"env_id": {"type": "integer", "description": "Pass to update existing; omit to create new"},
"name": {"type": "string"},
"base_url": {"type": "string"},
"auth_token": {"type": "string"},
"description": {"type": "string"},
},
},
},
{
"name": "env_activate",
"description": "Set an environment as the active version. Archives the previously active one. Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["env_id"],
"properties": {"env_id": {"type": "integer"}},
},
},
{
"name": "env_delete",
"description": "Delete an environment configuration. Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["env_id"],
"properties": {"env_id": {"type": "integer"}},
},
},
] ]
@ -1968,6 +2040,7 @@ async def _dispatch_mcp_tool(
if not _is_superadmin(actor): if not _is_superadmin(actor):
batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id) batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id)
batch_rows = batch_query.order_by(WorkflowBatch.id.desc()).limit(50).all() batch_rows = batch_query.order_by(WorkflowBatch.id.desc()).limit(50).all()
env_rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, actor).order_by(EnvironmentConfig.id.desc()).all()
data = { data = {
"apis": apis, "apis": apis,
"workflows": workflows, "workflows": workflows,
@ -1975,6 +2048,7 @@ async def _dispatch_mcp_tool(
"workflow_batches": [_model_to_batch_dict(row) for row in batch_rows], "workflow_batches": [_model_to_batch_dict(row) for row in batch_rows],
"mcp_tools": configs, "mcp_tools": configs,
"ssh_tree": ssh_tree, "ssh_tree": ssh_tree,
"environments": [EnvironmentConfigOut.model_validate(row).model_dump() for row in env_rows],
"folders": { "folders": {
"apis": list_folders(target="apis", db=db, user=actor)["folders"], "apis": list_folders(target="apis", db=db, user=actor)["folders"],
"workflows": list_folders(target="workflows", db=db, user=actor)["folders"], "workflows": list_folders(target="workflows", db=db, user=actor)["folders"],
@ -2055,6 +2129,67 @@ async def _dispatch_mcp_tool(
data = await run_script_collect(profile, script, password, timeout_seconds=timeout_seconds) data = await run_script_collect(profile, script, password, timeout_seconds=timeout_seconds)
return McpInvokeResponse(ok=bool(data.get("ok")), tool=tool, data=data) return McpInvokeResponse(ok=bool(data.get("ok")), tool=tool, data=data)
if tool == "env_list":
rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, actor).order_by(EnvironmentConfig.id.desc()).all()
items = [EnvironmentConfigOut.model_validate(row).model_dump() for row in rows]
return McpInvokeResponse(ok=True, tool=tool, data={"items": items})
if tool == "env_upsert":
env_id = args.get("env_id")
if env_id:
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == int(env_id)).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
row.name = str(args.get("name", row.name)).strip()
row.base_url = str(args.get("base_url", row.base_url)).strip()
row.auth_token = str(args.get("auth_token", row.auth_token)).strip()
row.description = str(args.get("description", row.description)).strip()
db.commit()
db.refresh(row)
else:
row = EnvironmentConfig(
name=str(args["name"]).strip(),
base_url=str(args.get("base_url", "")).strip(),
auth_token=str(args.get("auth_token", "")).strip(),
description=str(args.get("description", "")).strip(),
status="draft",
creator_id=actor.id,
creator_name=actor.display_name or actor.username,
)
db.add(row)
db.commit()
db.refresh(row)
data = EnvironmentConfigOut.model_validate(row).model_dump()
return McpInvokeResponse(ok=True, tool=tool, data=data)
if tool == "env_activate":
env_id = int(args["env_id"])
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
active_rows = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, actor
).all()
for active_row in active_rows:
active_row.status = "archived"
row.status = "active"
db.commit()
db.refresh(row)
data = EnvironmentConfigOut.model_validate(row).model_dump()
return McpInvokeResponse(ok=True, tool=tool, data=data)
if tool == "env_delete":
env_id = int(args["env_id"])
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
db.delete(row)
db.commit()
return McpInvokeResponse(ok=True, tool=tool, data={"deleted": env_id})
raise HTTPException(status_code=404, detail=f"unknown mcp tool: {tool}") raise HTTPException(status_code=404, detail=f"unknown mcp tool: {tool}")
except HTTPException as exc: except HTTPException as exc:
return McpInvokeResponse(ok=False, tool=tool, error=str(exc.detail)) return McpInvokeResponse(ok=False, tool=tool, error=str(exc.detail))
@ -2386,6 +2521,9 @@ def create_api(payload: ApiCreate, db: Session = Depends(get_db), user: User = D
"query": payload.query, "query": payload.query,
"path_params": payload.path_params, "path_params": payload.path_params,
"timeout_seconds": payload.timeout_seconds, "timeout_seconds": payload.timeout_seconds,
"description": payload.description,
"request_params": [p.model_dump() for p in payload.request_params],
"response_params": [p.model_dump() for p in payload.response_params],
} }
row = ApiDefinition( row = ApiDefinition(
name=payload.name, name=payload.name,
@ -2426,6 +2564,9 @@ def update_api(api_id: int, payload: ApiUpdate, db: Session = Depends(get_db), u
"query": payload.query, "query": payload.query,
"path_params": payload.path_params, "path_params": payload.path_params,
"timeout_seconds": payload.timeout_seconds, "timeout_seconds": payload.timeout_seconds,
"description": payload.description,
"request_params": [p.model_dump() for p in payload.request_params],
"response_params": [p.model_dump() for p in payload.response_params],
}, },
ensure_ascii=False, ensure_ascii=False,
) )
@ -3131,6 +3272,82 @@ def upsert_mcp_tool(payload: McpToolCreate, db: Session = Depends(get_db), user:
return model_to_mcp_out(row) return model_to_mcp_out(row)
@app.get("/api/environments", response_model=list[EnvironmentConfigOut])
def list_environments(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, user).order_by(EnvironmentConfig.id.desc()).all()
return [EnvironmentConfigOut.model_validate(row) for row in rows]
@app.post("/api/environments", response_model=EnvironmentConfigOut)
def create_environment(payload: EnvironmentConfigCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = EnvironmentConfig(
name=payload.name.strip(),
base_url=payload.base_url.strip(),
auth_token=payload.auth_token.strip(),
description=payload.description.strip(),
status="draft",
creator_id=user.id,
creator_name=user.display_name or user.username,
)
db.add(row)
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.put("/api/environments/{env_id}", response_model=EnvironmentConfigOut)
def update_environment(env_id: int, payload: EnvironmentConfigUpdate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
row.name = payload.name.strip()
row.base_url = payload.base_url.strip()
row.auth_token = payload.auth_token.strip()
row.description = payload.description.strip()
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.post("/api/environments/{env_id}/activate")
def activate_environment(env_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
active_rows = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, user
).all()
for active_row in active_rows:
active_row.status = "archived"
row.status = "active"
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.delete("/api/environments/{env_id}")
def delete_environment(env_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
db.delete(row)
db.commit()
return {"ok": True}
@app.get("/api/environments/active", response_model=EnvironmentConfigOut | None)
def get_active_environment(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, user
).first()
if not row:
return None
return EnvironmentConfigOut.model_validate(row)
@app.get("/api/ssh-profiles", response_model=list[SshProfileOut]) @app.get("/api/ssh-profiles", response_model=list[SshProfileOut])
def list_ssh_profiles(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): def list_ssh_profiles(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
rows = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all() rows = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all()

View File

@ -217,3 +217,18 @@ class WorkflowRun(Base):
summary_json = Column(Text, nullable=False, default="{}") summary_json = Column(Text, nullable=False, default="{}")
payload_json = Column(Text, nullable=False, default="{}") payload_json = Column(Text, nullable=False, default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
class EnvironmentConfig(Base):
__tablename__ = "environment_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
base_url = Column(String(512), nullable=False, default="")
auth_token = Column(String(512), nullable=False, default="")
description = Column(Text, nullable=False, default="")
status = Column(String(16), nullable=False, default="draft") # active | draft | archived
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@ -3,16 +3,26 @@ from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ApiParamField(BaseModel):
name: str = ""
type: str = "string"
required: bool = False
description: str = ""
class ApiBase(BaseModel): class ApiBase(BaseModel):
name: str name: str
folder_path: str = "" folder_path: str = ""
method: str = "GET" method: str = "GET"
url: str url: str
description: str = ""
headers: dict[str, Any] = Field(default_factory=dict) headers: dict[str, Any] = Field(default_factory=dict)
body: dict[str, Any] = Field(default_factory=dict) body: dict[str, Any] = Field(default_factory=dict)
query: dict[str, Any] = Field(default_factory=dict) query: dict[str, Any] = Field(default_factory=dict)
path_params: dict[str, Any] = Field(default_factory=dict) path_params: dict[str, Any] = Field(default_factory=dict)
timeout_seconds: float = 10.0 timeout_seconds: float = 10.0
request_params: list[ApiParamField] = Field(default_factory=list)
response_params: list[ApiParamField] = Field(default_factory=list)
class ApiCreate(ApiBase): class ApiCreate(ApiBase):
@ -387,6 +397,33 @@ class FolderMoveRequest(BaseModel):
to_path: str to_path: str
class EnvironmentConfigBase(BaseModel):
name: str
base_url: str = ""
auth_token: str = ""
description: str = ""
class EnvironmentConfigCreate(EnvironmentConfigBase):
pass
class EnvironmentConfigUpdate(EnvironmentConfigBase):
pass
class EnvironmentConfigOut(EnvironmentConfigBase):
id: int
status: str = "draft"
creator_id: int = 0
creator_name: str = ""
created_at: Any = None
updated_at: Any = None
class Config:
from_attributes = True
class McpInvokeRequest(BaseModel): class McpInvokeRequest(BaseModel):
tool: str tool: str
arguments: dict[str, Any] = Field(default_factory=dict) arguments: dict[str, Any] = Field(default_factory=dict)

View File

@ -5,11 +5,13 @@ import {
DataBoard, DataBoard,
Key, Key,
Link, Link,
List,
Lock, Lock,
Monitor, Monitor,
Operation, Operation,
Setting, Setting,
SwitchButton, SwitchButton,
Tickets,
User, User,
VideoPlay, VideoPlay,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
@ -27,8 +29,10 @@ const profileDrawerVisible = ref(false);
const menuItems = computed(() => [ const menuItems = computed(() => [
{ index: "/", label: "工作台", icon: DataBoard }, { index: "/", label: "工作台", icon: DataBoard },
{ index: "/workflow-batches", label: "跑批工作流执行", icon: VideoPlay },
{ index: "/apis", label: "接口管理", icon: Connection }, { index: "/apis", label: "接口管理", icon: Connection },
{ index: "/workflows", label: "工作流", icon: List },
{ index: "/workflow-batches", label: "跑批工作流", icon: VideoPlay },
{ index: "/versions", label: "整体版本", icon: Tickets },
{ index: "/ssh", label: "SSH 管理", icon: Key }, { index: "/ssh", label: "SSH 管理", icon: Key },
...(authState.user?.role === "superadmin" ? [{ index: "/users", label: "用户管理", icon: User }] : []), ...(authState.user?.role === "superadmin" ? [{ index: "/users", label: "用户管理", icon: User }] : []),
{ index: "/mcp-config", label: "MCP 配置", icon: Setting }, { index: "/mcp-config", label: "MCP 配置", icon: Setting },
@ -36,8 +40,10 @@ const menuItems = computed(() => [
const activeMenu = computed(() => { const activeMenu = computed(() => {
const path = route.path || "/"; const path = route.path || "/";
if (path.startsWith("/workflows")) return "/workflows";
if (path.startsWith("/workflow-batches") || path.startsWith("/workflow-runtime")) return "/workflow-batches"; if (path.startsWith("/workflow-batches") || path.startsWith("/workflow-runtime")) return "/workflow-batches";
if (path.startsWith("/apis")) return "/apis"; if (path.startsWith("/apis")) return "/apis";
if (path.startsWith("/versions")) return "/versions";
if (path.startsWith("/ssh")) return "/ssh"; if (path.startsWith("/ssh")) return "/ssh";
if (path.startsWith("/mcp-config")) return "/mcp-config"; if (path.startsWith("/mcp-config")) return "/mcp-config";
if (path.startsWith("/users")) return "/users"; if (path.startsWith("/users")) return "/users";

View File

@ -8,7 +8,9 @@ import LoginView from "../views/LoginView.vue";
import McpManagementView from "../views/McpManagementView.vue"; import McpManagementView from "../views/McpManagementView.vue";
import SshManagementView from "../views/SshManagementView.vue"; import SshManagementView from "../views/SshManagementView.vue";
import UserManagementView from "../views/UserManagementView.vue"; import UserManagementView from "../views/UserManagementView.vue";
import VersionView from "../views/VersionView.vue";
import WorkflowBatchView from "../views/WorkflowBatchView.vue"; import WorkflowBatchView from "../views/WorkflowBatchView.vue";
import WorkflowListView from "../views/WorkflowListView.vue";
const routes = [ const routes = [
{ {
@ -40,6 +42,55 @@ const routes = [
title: "接口管理", title: "接口管理",
}, },
}, },
{
path: "apis/:apiId/edit",
name: "api-edit",
redirect: (to) => ({ name: "apis" }),
},
{
path: "workflows",
name: "workflows",
component: WorkflowListView,
meta: {
title: "工作流",
},
},
{
path: "workflows/create",
name: "workflow-create",
component: ApiEditorView,
meta: {
title: "新建工作流",
},
},
{
path: "workflows/:workflowId/edit",
name: "workflow-edit",
component: ApiEditorView,
meta: {
title: "编辑工作流",
},
},
{
path: "workflow-batches",
name: "workflow-batches",
component: WorkflowBatchView,
meta: {
title: "跑批工作流",
},
},
{
path: "workflow-runtime",
redirect: "/workflow-batches",
},
{
path: "versions",
name: "versions",
component: VersionView,
meta: {
title: "整体版本",
},
},
{ {
path: "ssh", path: "ssh",
name: "ssh", name: "ssh",
@ -48,18 +99,6 @@ const routes = [
title: "SSH 管理", title: "SSH 管理",
}, },
}, },
{
path: "workflow-batches",
name: "workflow-batches",
component: WorkflowBatchView,
meta: {
title: "跑批工作流执行",
},
},
{
path: "workflow-runtime",
redirect: "/workflow-batches",
},
{ {
path: "mcp-config", path: "mcp-config",
name: "mcp", name: "mcp",
@ -68,14 +107,6 @@ const routes = [
title: "MCP 配置", title: "MCP 配置",
}, },
}, },
{
path: "apis/:apiId/edit",
name: "api-edit",
component: ApiEditorView,
meta: {
title: "接口编辑",
},
},
{ {
path: "users", path: "users",
name: "users", name: "users",

View File

@ -86,6 +86,7 @@ const nodeMethodOptions = [
const conditionOps = ["==", "!=", ">", "<", ">=", "<="]; const conditionOps = ["==", "!=", ">", "<", ">=", "<="];
const apiId = computed(() => Number(route.params.apiId || 0)); const apiId = computed(() => Number(route.params.apiId || 0));
const routeWorkflowId = computed(() => Number(route.params.workflowId || 0));
const workflowStatusText = computed(() => { const workflowStatusText = computed(() => {
if (workflowLoading.value) return "正在匹配已有流程..."; if (workflowLoading.value) return "正在匹配已有流程...";
return workflowId.value ? `已关联流程:${workflowName.value}` : "当前为未保存流程"; return workflowId.value ? `已关联流程:${workflowName.value}` : "当前为未保存流程";
@ -102,9 +103,15 @@ const upstreamVariables = computed(() => {
}); });
watch( watch(
() => route.params.apiId, () => [route.params.apiId, route.params.workflowId],
() => { () => {
if (routeWorkflowId.value) {
loadPageByWorkflow();
} else if (apiId.value) {
loadPage(); loadPage();
} else if (route.name === "workflow-create") {
loadNewWorkflowPage();
}
}, },
{ immediate: true } { immediate: true }
); );
@ -117,7 +124,11 @@ watch(
); );
function goBack() { function goBack() {
if (routeWorkflowId.value) {
router.push({ name: "workflows" });
} else {
router.push({ name: "apis" }); router.push({ name: "apis" });
}
} }
function cloneValue(value) { function cloneValue(value) {
@ -500,6 +511,89 @@ function syncCurrentApiNodesToCanvas() {
} }
} }
async function loadPageByWorkflow() {
if (!routeWorkflowId.value) {
pageError.value = "缺少工作流编号";
return;
}
loading.value = true;
pageError.value = "";
workflowError.value = "";
selectedNode.value = null;
resetNodeEditor();
try {
const workflow = await apiGet(`/api/workflows/${routeWorkflowId.value}`);
workflowId.value = workflow.id;
workflowName.value = workflow.name || "工作流";
workflowFolderPath.value = workflow.folder_path || "";
workflowDefaultHeaders.value = cloneValue(workflow.default_headers || {});
const def = workflow.definition || buildEmptyWorkflow();
workflowDefinition.value = def;
const httpNodes = (def.nodes || []).filter((n) => n.data?.type === "http");
const firstApiId = httpNodes[0]?.data?.api_id;
if (firstApiId) {
try {
const api = await apiGet(`/api/apis/${firstApiId}`);
applyApi(api);
matchedNodeIds.value = findCurrentApiNodeIds(def, api);
} catch {
applyEmptyApi();
}
} else {
applyEmptyApi();
}
loadApiCatalog();
await nextTick();
applyWorkflowToCanvas(def, {
selectedNodeId: httpNodes[0]?.id || def.nodes[0]?.id || "",
});
} catch (error) {
pageError.value = error instanceof Error ? error.message : "工作流加载失败";
ElMessage.error(pageError.value);
} finally {
loading.value = false;
}
}
function applyEmptyApi() {
formState.id = null;
formState.name = "";
formState.folder_path = "";
formState.method = "GET";
formState.url = "";
formState.timeout_seconds = 10;
formState.body_text = "{}";
formState.query_text = "{}";
headerRows.value = [{ key: "", value: "" }];
pathParamRows.value = [{ key: "", value: "" }];
matchedNodeIds.value = [];
}
async function loadNewWorkflowPage() {
loading.value = true;
pageError.value = "";
workflowError.value = "";
selectedNode.value = null;
resetNodeEditor();
applyEmptyApi();
workflowId.value = null;
workflowName.value = "新工作流";
workflowFolderPath.value = route.query.folder || "";
workflowDefaultHeaders.value = {};
workflowDefinition.value = buildEmptyWorkflow();
loadApiCatalog();
await nextTick();
applyWorkflowToCanvas(workflowDefinition.value);
loading.value = false;
}
async function loadPage() { async function loadPage() {
if (!apiId.value) { if (!apiId.value) {
pageError.value = "缺少接口编号"; pageError.value = "缺少接口编号";
@ -726,12 +820,12 @@ async function submitForm() {
<div class="api-editor-hero"> <div class="api-editor-hero">
<div class="api-editor-hero__meta"> <div class="api-editor-hero__meta">
<el-button text :icon="ArrowLeft" class="api-editor-hero__back" @click="goBack"> <el-button text :icon="ArrowLeft" class="api-editor-hero__back" @click="goBack">
返回接口列表 {{ routeWorkflowId ? '返回工作流列表' : '返回接口列表' }}
</el-button> </el-button>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading || workflowLoading || catalogLoading" @click="loadPage"> <el-button :icon="Refresh" :loading="loading || workflowLoading || catalogLoading" @click="routeWorkflowId ? loadPageByWorkflow() : loadPage()">
刷新 刷新
</el-button> </el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存接口和流程</el-button> <el-button type="primary" :loading="saving" @click="submitForm">保存接口和流程</el-button>

View File

@ -2,17 +2,17 @@
import { Delete, Edit, FolderAdd, FolderOpened, Link, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue"; import { Delete, Edit, FolderAdd, FolderOpened, Link, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { apiDelete, apiGet, apiPost, apiPut } from "../api/http";
import { apiDelete, apiGet, apiPost } from "../api/http";
import FolderCreateDialog from "../components/FolderCreateDialog.vue"; import FolderCreateDialog from "../components/FolderCreateDialog.vue";
import RequestParamsTabs from "../components/RequestParamsTabs.vue"; import RequestParamsTabs from "../components/RequestParamsTabs.vue";
import { buildFolderTreeRows, collectFolderPaths, normalizeFolderPath } from "../utils/folderTree"; import { buildFolderTreeRows, collectFolderPaths, normalizeFolderPath } from "../utils/folderTree";
import { collectKeyValueObject, normalizeKeyValueRows } from "../utils/keyValue"; import { collectKeyValueObject, normalizeKeyValueRows } from "../utils/keyValue";
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const drawerVisible = ref(false); const drawerVisible = ref(false);
const drawerMode = ref("create");
const editingId = ref(null);
const searchKeyword = ref(""); const searchKeyword = ref("");
const rawRows = ref([]); const rawRows = ref([]);
const declaredFolderPaths = ref([]); const declaredFolderPaths = ref([]);
@ -26,6 +26,7 @@ const formState = reactive({
folder_path: "", folder_path: "",
method: "GET", method: "GET",
url: "", url: "",
description: "",
timeout_seconds: 10, timeout_seconds: 10,
body_text: "{}", body_text: "{}",
query_text: "{}", query_text: "{}",
@ -33,6 +34,26 @@ const formState = reactive({
const headerRows = ref([{ key: "", value: "" }]); const headerRows = ref([{ key: "", value: "" }]);
const pathParamRows = ref([{ key: "", value: "" }]); const pathParamRows = ref([{ key: "", value: "" }]);
const requestParams = ref([]);
const responseParams = ref([]);
const paramTypeOptions = ["string", "integer", "number", "boolean", "object", "array"];
function addRequestParam() {
requestParams.value.push({ name: "", type: "string", required: false, description: "" });
}
function removeRequestParam(index) {
requestParams.value.splice(index, 1);
}
function addResponseParam() {
responseParams.value.push({ name: "", type: "string", required: false, description: "" });
}
function removeResponseParam(index) {
responseParams.value.splice(index, 1);
}
const methodOptions = ["GET", "POST", "PUT", "DELETE", "PATCH"]; const methodOptions = ["GET", "POST", "PUT", "DELETE", "PATCH"];
@ -64,24 +85,49 @@ function resetForm() {
formState.folder_path = ""; formState.folder_path = "";
formState.method = "GET"; formState.method = "GET";
formState.url = ""; formState.url = "";
formState.description = "";
formState.timeout_seconds = 10; formState.timeout_seconds = 10;
formState.body_text = "{}"; formState.body_text = "{}";
formState.query_text = "{}"; formState.query_text = "{}";
headerRows.value = [{ key: "", value: "" }]; headerRows.value = [{ key: "", value: "" }];
pathParamRows.value = [{ key: "", value: "" }]; pathParamRows.value = [{ key: "", value: "" }];
requestParams.value = [];
responseParams.value = [];
} }
function openCreateDrawer() { function openCreateDrawer() {
drawerMode.value = "create";
editingId.value = null;
resetForm(); resetForm();
drawerVisible.value = true; drawerVisible.value = true;
} }
function openCreateDrawerFromFolder(folderPath) { function openCreateDrawerFromFolder(folderPath) {
drawerMode.value = "create";
editingId.value = null;
resetForm(); resetForm();
formState.folder_path = folderPath || ""; formState.folder_path = folderPath || "";
drawerVisible.value = true; drawerVisible.value = true;
} }
function openEditDrawer(row) {
drawerMode.value = "edit";
editingId.value = row.id;
formState.name = row.name || "";
formState.folder_path = row.folder_path || "";
formState.method = row.method || "GET";
formState.url = row.url || "";
formState.description = row.description || "";
formState.timeout_seconds = row.timeout_seconds || 10;
formState.body_text = JSON.stringify(row.body || {}, null, 2);
formState.query_text = JSON.stringify(row.query || {}, null, 2);
headerRows.value = normalizeKeyValueRows(row.headers || {});
pathParamRows.value = normalizeKeyValueRows(row.path_params || {});
requestParams.value = (row.request_params || []).map((p) => ({ ...p }));
responseParams.value = (row.response_params || []).map((p) => ({ ...p }));
drawerVisible.value = true;
}
function openCreateFolderDialog(parentPath = "") { function openCreateFolderDialog(parentPath = "") {
folderDialogMode.value = "create"; folderDialogMode.value = "create";
folderDialogEditPath.value = ""; folderDialogEditPath.value = "";
@ -100,10 +146,6 @@ async function handleFolderCreated() {
await loadApis(); await loadApis();
} }
function openEditPage(row) {
router.push({ name: "api-edit", params: { apiId: row.id } });
}
function parseJsonObject(text, label) { function parseJsonObject(text, label) {
try { try {
const parsed = JSON.parse(text || "{}"); const parsed = JSON.parse(text || "{}");
@ -122,11 +164,14 @@ function buildPayload() {
folder_path: formState.folder_path.trim(), folder_path: formState.folder_path.trim(),
method: formState.method, method: formState.method,
url: formState.url.trim(), url: formState.url.trim(),
description: formState.description.trim(),
timeout_seconds: Number(formState.timeout_seconds || 10), timeout_seconds: Number(formState.timeout_seconds || 10),
headers: collectKeyValueObject(headerRows.value, "Headers"), headers: collectKeyValueObject(headerRows.value, "Headers"),
body: parseJsonObject(formState.body_text, "Body"), body: parseJsonObject(formState.body_text, "Body"),
query: parseJsonObject(formState.query_text, "Query"), query: parseJsonObject(formState.query_text, "Query"),
path_params: collectKeyValueObject(pathParamRows.value, "Path Params"), path_params: collectKeyValueObject(pathParamRows.value, "Path Params"),
request_params: requestParams.value.filter((p) => p.name.trim()),
response_params: responseParams.value.filter((p) => p.name.trim()),
}; };
} }
@ -189,8 +234,13 @@ async function submitForm() {
saving.value = true; saving.value = true;
try { try {
if (drawerMode.value === "edit" && editingId.value) {
await apiPut(`/api/apis/${editingId.value}`, payload);
ElMessage.success("接口已更新");
} else {
await apiPost("/api/apis", payload); await apiPost("/api/apis", payload);
ElMessage.success("接口已创建"); ElMessage.success("接口已创建");
}
drawerVisible.value = false; drawerVisible.value = false;
await loadApis(); await loadApis();
} catch (error) { } catch (error) {
@ -311,7 +361,7 @@ onMounted(() => {
<el-button text :icon="Plus" @click="openCreateDrawerFromFolder(row.folder_path)">新建接口</el-button> <el-button text :icon="Plus" @click="openCreateDrawerFromFolder(row.folder_path)">新建接口</el-button>
</div> </div>
<div v-else class="row-actions"> <div v-else class="row-actions">
<el-button text :icon="Edit" @click="openEditPage(row)">编辑</el-button> <el-button text :icon="Edit" @click="openEditDrawer(row)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button> <el-button text type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</div> </div>
</template> </template>
@ -321,7 +371,7 @@ onMounted(() => {
<el-drawer <el-drawer
v-model="drawerVisible" v-model="drawerVisible"
title="新建接口" :title="drawerMode === 'edit' ? '编辑接口' : '新建接口'"
size="760px" size="760px"
destroy-on-close destroy-on-close
> >
@ -355,6 +405,15 @@ onMounted(() => {
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="接口说明">
<el-input
v-model="formState.description"
type="textarea"
:rows="3"
placeholder="描述接口用途、注意事项等…"
/>
</el-form-item>
<el-form-item label="超时(秒)"> <el-form-item label="超时(秒)">
<el-input-number v-model="formState.timeout_seconds" :min="1" :step="1" class="full-width" /> <el-input-number v-model="formState.timeout_seconds" :min="1" :step="1" class="full-width" />
</el-form-item> </el-form-item>
@ -367,6 +426,76 @@ onMounted(() => {
v-model:query-text="formState.query_text" v-model:query-text="formState.query_text"
v-model:path-param-rows="pathParamRows" v-model:path-param-rows="pathParamRows"
/> />
<el-divider content-position="left">入参结构</el-divider>
<div class="param-struct-section">
<el-table :data="requestParams" border size="small" class="param-struct-table">
<el-table-column label="字段名" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="字段名" size="small" />
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-select v-model="row.type" size="small">
<el-option v-for="t in paramTypeOptions" :key="t" :label="t" :value="t" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必填" width="70" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.required" />
</template>
</el-table-column>
<el-table-column label="字段说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.description" placeholder="字段说明" size="small" />
</template>
</el-table-column>
<el-table-column label="" width="60" align="center">
<template #default="{ $index }">
<el-button text type="danger" size="small" @click="removeRequestParam($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" @click="addRequestParam" style="margin-top: 8px">+ 添加入参字段</el-button>
</div>
<el-divider content-position="left">出参结构</el-divider>
<div class="param-struct-section">
<el-table :data="responseParams" border size="small" class="param-struct-table">
<el-table-column label="字段名" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="字段名" size="small" />
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-select v-model="row.type" size="small">
<el-option v-for="t in paramTypeOptions" :key="t" :label="t" :value="t" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必填" width="70" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.required" />
</template>
</el-table-column>
<el-table-column label="字段说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.description" placeholder="字段说明" size="small" />
</template>
</el-table-column>
<el-table-column label="" width="60" align="center">
<template #default="{ $index }">
<el-button text type="danger" size="small" @click="removeResponseParam($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" @click="addResponseParam" style="margin-top: 8px">+ 添加出参字段</el-button>
</div>
</el-form> </el-form>
</div> </div>

View File

@ -0,0 +1,298 @@
<script setup>
import { CircleCheck, Delete, Edit, Plus, Refresh, Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api/http";
const loading = ref(false);
const environments = ref([]);
const searchKeyword = ref("");
const dialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null);
const saving = ref(false);
const form = ref({
name: "",
base_url: "",
auth_token: "",
description: "",
});
const filteredEnvironments = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return environments.value;
return environments.value.filter((v) =>
[v.name, v.base_url, v.description, v.status].some((val) => String(val || "").toLowerCase().includes(keyword))
);
});
const activeEnv = computed(() => environments.value.find((v) => v.status === "active"));
async function loadEnvironments() {
loading.value = true;
try {
const data = await apiGet("/api/environments");
environments.value = data;
} catch {
environments.value = [];
} finally {
loading.value = false;
}
}
function openCreateDialog() {
dialogMode.value = "create";
editingId.value = null;
form.value = { name: "", base_url: "", auth_token: "", description: "" };
dialogVisible.value = true;
}
function openEditDialog(row) {
dialogMode.value = "edit";
editingId.value = row.id;
form.value = {
name: row.name || "",
base_url: row.base_url || "",
auth_token: row.auth_token || "",
description: row.description || "",
};
dialogVisible.value = true;
}
async function handleSubmit() {
if (!form.value.name.trim()) {
ElMessage.warning("请输入版本名称");
return;
}
if (!form.value.base_url.trim()) {
ElMessage.warning("请输入 Base URL");
return;
}
saving.value = true;
try {
if (dialogMode.value === "edit" && editingId.value) {
await apiPut(`/api/environments/${editingId.value}`, form.value);
ElMessage.success("已更新");
} else {
await apiPost("/api/environments", form.value);
ElMessage.success("已创建");
}
dialogVisible.value = false;
await loadEnvironments();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "操作失败");
} finally {
saving.value = false;
}
}
async function activateEnv(row) {
try {
await ElMessageBox.confirm(`确定将「${row.name}」设为当前活跃版本吗?之前的活跃版本将被归档。`, "确认激活", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
});
await apiPost(`/api/environments/${row.id}/activate`);
ElMessage.success("已激活");
await loadEnvironments();
} catch (error) {
if (error !== "cancel" && error?.message !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "操作失败");
}
}
}
async function deleteEnv(row) {
try {
await ElMessageBox.confirm(`确定删除环境配置「${row.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/environments/${row.id}`);
ElMessage.success("已删除");
await loadEnvironments();
} catch (error) {
if (error !== "cancel" && error?.message !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
function statusTag(status) {
const map = { active: "success", draft: "info", archived: "warning" };
return map[status] || "info";
}
function statusLabel(status) {
const map = { active: "当前版本", draft: "草稿", archived: "已归档" };
return map[status] || status;
}
function maskToken(token) {
if (!token) return "-";
if (token.length <= 8) return "****";
return token.slice(0, 4) + "****" + token.slice(-4);
}
onMounted(() => {
loadEnvironments();
});
</script>
<template>
<div class="version-page">
<el-card v-if="activeEnv" class="panel-card version-active-card" shadow="never">
<div class="version-active">
<div class="version-active__icon">
<el-icon :size="28" color="var(--el-color-success)"><CircleCheck /></el-icon>
</div>
<div class="version-active__info">
<strong>当前活跃版本{{ activeEnv.name }}</strong>
<p>
<span class="version-active__label">Base URL</span>
<code>{{ activeEnv.base_url }}</code>
</p>
<p v-if="activeEnv.description" class="version-active__desc">{{ activeEnv.description }}</p>
</div>
<el-button text @click="openEditDialog(activeEnv)">编辑</el-button>
</div>
</el-card>
<el-card class="panel-card" shadow="never" v-loading="loading">
<template #header>
<div class="panel-card__header">
<div>
<strong>整体版本</strong>
<p>管理 Base URL Auth Token 的环境配置版本执行工作流时使用活跃版本的设置</p>
</div>
<div class="toolbar-actions">
<el-input
v-model="searchKeyword"
:prefix-icon="Search"
placeholder="搜索版本…"
clearable
style="width: 200px"
/>
<el-tag type="info" effect="plain">{{ environments.length }} 个版本</el-tag>
<el-button :icon="Refresh" @click="loadEnvironments">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新建版本</el-button>
</div>
</div>
</template>
<el-table :data="filteredEnvironments" stripe>
<el-table-column label="版本名称" min-width="150">
<template #default="{ row }">
<strong>{{ row.name }}</strong>
</template>
</el-table-column>
<el-table-column label="Base URL" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<code>{{ row.base_url || "-" }}</code>
</template>
</el-table-column>
<el-table-column label="Auth Token" min-width="150">
<template #default="{ row }">
<code>{{ maskToken(row.auth_token) }}</code>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" effect="plain">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="{ row }">
<div class="row-actions">
<el-button
v-if="row.status !== 'active'"
text
type="success"
:icon="CircleCheck"
@click="activateEnv(row)"
>激活</el-button>
<el-button text :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="deleteEnv(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !environments.length" description="暂无版本,点击「新建版本」创建" />
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogMode === 'edit' ? '编辑版本' : '新建版本'" width="520px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="版本名称" required>
<el-input v-model="form.name" placeholder="例如:开发环境 / 测试环境 / 生产环境" />
</el-form-item>
<el-form-item label="Base URL" required>
<el-input v-model="form.base_url" placeholder="https://api.example.com" />
</el-form-item>
<el-form-item label="Auth Token">
<el-input v-model="form.auth_token" placeholder="Bearer token 或留空" show-password />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="描述此版本的用途…" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">
{{ dialogMode === 'edit' ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.version-page {
padding: 0;
}
.version-active-card {
margin-bottom: 16px;
}
.version-active {
display: flex;
align-items: center;
gap: 16px;
}
.version-active__icon {
flex-shrink: 0;
}
.version-active__info {
flex: 1;
}
.version-active__info strong {
font-size: 15px;
}
.version-active__info p {
margin: 4px 0 0;
font-size: 13px;
}
.version-active__label {
color: var(--el-text-color-secondary);
}
.version-active__desc {
color: var(--el-text-color-secondary);
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,222 @@
<script setup>
import { Delete, Edit, FolderAdd, FolderOpened, Plus, Refresh, Search, Setting, VideoPlay } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "../api/http";
import FolderCreateDialog from "../components/FolderCreateDialog.vue";
import { buildFolderTreeRows, collectFolderPaths, normalizeFolderPath } from "../utils/folderTree";
const router = useRouter();
const loading = ref(false);
const searchKeyword = ref("");
const rawRows = ref([]);
const declaredFolderPaths = ref([]);
const folderDialogVisible = ref(false);
const folderDialogMode = ref("create");
const folderDialogParentPath = ref("");
const folderDialogEditPath = ref("");
const visibleRows = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return rawRows.value;
return rawRows.value.filter((row) =>
[row.name, row.folder_path, row.creator_name].some((value) =>
String(value || "").toLowerCase().includes(keyword)
)
);
});
const visibleFolderPaths = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
const paths = collectFolderPaths(visibleRows.value, declaredFolderPaths.value);
if (!keyword) return paths;
return paths.filter((path) => path.toLowerCase().includes(keyword));
});
const treeRows = computed(() => buildFolderTreeRows(visibleRows.value, visibleFolderPaths.value));
const folderCount = computed(() => visibleFolderPaths.value.length);
const folderOptions = computed(() => collectFolderPaths(rawRows.value, declaredFolderPaths.value));
async function loadWorkflows() {
loading.value = true;
try {
const [workflows, folders] = await Promise.all([
apiGet("/api/workflows"),
apiGet("/api/folders?target=workflows"),
]);
rawRows.value = workflows;
declaredFolderPaths.value = folders.folders || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "加载工作流列表失败");
} finally {
loading.value = false;
}
}
function openCreateDrawer(folderPath = "") {
router.push({ name: "workflow-create", query: folderPath ? { folder: folderPath } : {} });
}
function openEditor(workflowId) {
router.push({ name: "workflow-edit", params: { workflowId } });
}
async function deleteWorkflow(row) {
try {
await ElMessageBox.confirm(`确定删除工作流「${row.name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
ElMessage.info("删除功能开发中");
} catch {
// cancelled
}
}
async function runWorkflow(row) {
try {
await apiPost("/api/workflows/run", { workflow_id: row.id });
ElMessage.success(`工作流「${row.name}」已开始执行`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "执行失败");
}
}
function openFolderDialog(parentPath = "") {
folderDialogMode.value = "create";
folderDialogEditPath.value = "";
folderDialogParentPath.value = parentPath;
folderDialogVisible.value = true;
}
function openEditFolderDialog(folderPath) {
folderDialogMode.value = "edit";
folderDialogEditPath.value = folderPath;
folderDialogParentPath.value = "";
folderDialogVisible.value = true;
}
async function handleFolderCreated() {
await loadWorkflows();
}
function getNodeCount(row) {
const def = row.definition || {};
const nodes = def.nodes || [];
return nodes.filter((n) => n.data?.type === "http").length;
}
onMounted(() => {
loadWorkflows();
});
</script>
<template>
<div class="workflow-list-page">
<el-card class="panel-card" shadow="never" v-loading="loading">
<template #header>
<div class="panel-card__header">
<div>
<strong>工作流管理</strong>
<p>串联多个接口进行顺序执行支持条件分支与循环</p>
</div>
<div class="toolbar-actions">
<el-input
v-model="searchKeyword"
:prefix-icon="Search"
placeholder="搜索工作流名称…"
clearable
style="width: 220px"
/>
<el-tag type="info" effect="plain">{{ visibleRows.length }} 个工作流</el-tag>
<el-tag v-if="folderCount" type="info" effect="plain">{{ folderCount }} 个目录</el-tag>
<el-button :icon="Refresh" @click="loadWorkflows">刷新</el-button>
<el-button type="primary" :icon="FolderAdd" @click="openFolderDialog()">新建目录</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer()">新建工作流</el-button>
</div>
</div>
</template>
<el-table
:data="treeRows"
row-key="rowKey"
default-expand-all
:tree-props="{ children: 'children' }"
stripe
>
<el-table-column label="名称" min-width="200">
<template #default="{ row }">
<template v-if="row.nodeType === 'folder'">
<div class="folder-row">
<el-icon><FolderOpened /></el-icon>
<span class="folder-row__path">{{ row.folder_path || "根目录" }}</span>
<el-button text size="small" :icon="FolderAdd" @click="openFolderDialog(row.folder_path)">子目录</el-button>
<el-button text size="small" :icon="Setting" @click="openEditFolderDialog(row.folder_path)">编辑</el-button>
<el-button text size="small" :icon="Plus" @click="openCreateDrawer(row.folder_path)">新建工作流</el-button>
</div>
</template>
<template v-else>
<el-link type="primary" @click="openEditor(row.id)">{{ row.name }}</el-link>
</template>
</template>
</el-table-column>
<el-table-column label="节点数" width="90" align="center">
<template #default="{ row }">
<span v-if="row.nodeType !== 'folder'">{{ getNodeCount(row) }}</span>
</template>
</el-table-column>
<el-table-column label="创建者" width="120">
<template #default="{ row }">
<span v-if="row.nodeType !== 'folder'">{{ row.creator_name || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="{ row }">
<div v-if="row.nodeType !== 'folder'" class="row-actions">
<el-button text :icon="VideoPlay" @click="runWorkflow(row)">执行</el-button>
<el-button text :icon="Edit" @click="openEditor(row.id)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="deleteWorkflow(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<FolderCreateDialog
v-model="folderDialogVisible"
target="workflows"
:mode="folderDialogMode"
:edit-path="folderDialogEditPath"
:parent-path="folderDialogParentPath"
:folder-options="folderOptions"
@success="handleFolderCreated"
/>
</div>
</template>
<style scoped>
.workflow-list-page {
padding: 0;
}
.folder-row {
display: flex;
align-items: center;
gap: 8px;
}
.folder-row__path {
font-weight: 500;
color: var(--el-text-color-primary);
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
</style>