diff --git a/app/main.py b/app/main.py index 0efc171..329cce6 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import Session from .database import Base, SessionLocal, engine, get_db from .models import ( ApiDefinition, + EnvironmentConfig, FolderEntry, McpToolConfig, MockDataset, @@ -77,6 +78,9 @@ from .schemas import ( SshShortcutOut, SshShortcutUpdate, RunWorkflowRequest, + EnvironmentConfigCreate, + EnvironmentConfigOut, + EnvironmentConfigUpdate, UserApiKeyCreate, UserApiKeyCreateResponse, UserApiKeyOut, @@ -287,6 +291,9 @@ MCP_MUTATION_TOOLS = frozenset( "ssh_script_upsert", "workflow_batch_create", "workflow_batch_update", + "env_upsert", + "env_activate", + "env_delete", } ) @@ -614,7 +621,7 @@ def _delete_ssh_script_files(row: SshScript) -> None: MCP_TOOL_SPECS = [ { "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": { "type": "object", "required": ["name", "method", "url"], @@ -624,11 +631,38 @@ MCP_TOOL_SPECS = [ "folder_path": {"type": "string"}, "method": {"type": "string"}, "url": {"type": "string"}, + "description": {"type": "string", "description": "API description / notes"}, "headers": {"type": "object"}, "body": {"type": "object"}, "query": {"type": "object"}, "path_params": {"type": "object"}, "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): batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id) 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 = { "apis": apis, "workflows": workflows, @@ -1975,6 +2048,7 @@ async def _dispatch_mcp_tool( "workflow_batches": [_model_to_batch_dict(row) for row in batch_rows], "mcp_tools": configs, "ssh_tree": ssh_tree, + "environments": [EnvironmentConfigOut.model_validate(row).model_dump() for row in env_rows], "folders": { "apis": list_folders(target="apis", 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) 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}") except HTTPException as exc: 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, "path_params": payload.path_params, "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( name=payload.name, @@ -2426,6 +2564,9 @@ def update_api(api_id: int, payload: ApiUpdate, db: Session = Depends(get_db), u "query": payload.query, "path_params": payload.path_params, "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, ) @@ -3131,6 +3272,82 @@ def upsert_mcp_tool(payload: McpToolCreate, db: Session = Depends(get_db), user: 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]) 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() diff --git a/app/models.py b/app/models.py index 5e94894..997316c 100644 --- a/app/models.py +++ b/app/models.py @@ -217,3 +217,18 @@ class WorkflowRun(Base): summary_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) + + +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) diff --git a/app/schemas.py b/app/schemas.py index 947951e..0ddad68 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -3,16 +3,26 @@ from typing import Any, Literal from pydantic import BaseModel, Field +class ApiParamField(BaseModel): + name: str = "" + type: str = "string" + required: bool = False + description: str = "" + + class ApiBase(BaseModel): name: str folder_path: str = "" method: str = "GET" url: str + description: str = "" headers: dict[str, Any] = Field(default_factory=dict) body: dict[str, Any] = Field(default_factory=dict) query: dict[str, Any] = Field(default_factory=dict) path_params: dict[str, Any] = Field(default_factory=dict) timeout_seconds: float = 10.0 + request_params: list[ApiParamField] = Field(default_factory=list) + response_params: list[ApiParamField] = Field(default_factory=list) class ApiCreate(ApiBase): @@ -387,6 +397,33 @@ class FolderMoveRequest(BaseModel): 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): tool: str arguments: dict[str, Any] = Field(default_factory=dict) diff --git a/frontend-admin/src/layout/AdminLayout.vue b/frontend-admin/src/layout/AdminLayout.vue index ea2b188..fdc6bd6 100644 --- a/frontend-admin/src/layout/AdminLayout.vue +++ b/frontend-admin/src/layout/AdminLayout.vue @@ -5,11 +5,13 @@ import { DataBoard, Key, Link, + List, Lock, Monitor, Operation, Setting, SwitchButton, + Tickets, User, VideoPlay, } from "@element-plus/icons-vue"; @@ -27,8 +29,10 @@ const profileDrawerVisible = ref(false); const menuItems = computed(() => [ { index: "/", label: "工作台", icon: DataBoard }, - { index: "/workflow-batches", label: "跑批工作流执行", icon: VideoPlay }, { 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 }, ...(authState.user?.role === "superadmin" ? [{ index: "/users", label: "用户管理", icon: User }] : []), { index: "/mcp-config", label: "MCP 配置", icon: Setting }, @@ -36,8 +40,10 @@ const menuItems = computed(() => [ const activeMenu = computed(() => { 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("/apis")) return "/apis"; + if (path.startsWith("/versions")) return "/versions"; if (path.startsWith("/ssh")) return "/ssh"; if (path.startsWith("/mcp-config")) return "/mcp-config"; if (path.startsWith("/users")) return "/users"; diff --git a/frontend-admin/src/router/index.js b/frontend-admin/src/router/index.js index b642601..ff7d2e0 100644 --- a/frontend-admin/src/router/index.js +++ b/frontend-admin/src/router/index.js @@ -8,7 +8,9 @@ import LoginView from "../views/LoginView.vue"; import McpManagementView from "../views/McpManagementView.vue"; import SshManagementView from "../views/SshManagementView.vue"; import UserManagementView from "../views/UserManagementView.vue"; +import VersionView from "../views/VersionView.vue"; import WorkflowBatchView from "../views/WorkflowBatchView.vue"; +import WorkflowListView from "../views/WorkflowListView.vue"; const routes = [ { @@ -40,6 +42,55 @@ const routes = [ 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", name: "ssh", @@ -48,18 +99,6 @@ const routes = [ title: "SSH 管理", }, }, - { - path: "workflow-batches", - name: "workflow-batches", - component: WorkflowBatchView, - meta: { - title: "跑批工作流执行", - }, - }, - { - path: "workflow-runtime", - redirect: "/workflow-batches", - }, { path: "mcp-config", name: "mcp", @@ -68,14 +107,6 @@ const routes = [ title: "MCP 配置", }, }, - { - path: "apis/:apiId/edit", - name: "api-edit", - component: ApiEditorView, - meta: { - title: "接口编辑", - }, - }, { path: "users", name: "users", diff --git a/frontend-admin/src/views/ApiEditorView.vue b/frontend-admin/src/views/ApiEditorView.vue index 7fb5144..9ee0e47 100644 --- a/frontend-admin/src/views/ApiEditorView.vue +++ b/frontend-admin/src/views/ApiEditorView.vue @@ -86,6 +86,7 @@ const nodeMethodOptions = [ const conditionOps = ["==", "!=", ">", "<", ">=", "<="]; const apiId = computed(() => Number(route.params.apiId || 0)); +const routeWorkflowId = computed(() => Number(route.params.workflowId || 0)); const workflowStatusText = computed(() => { if (workflowLoading.value) return "正在匹配已有流程..."; return workflowId.value ? `已关联流程:${workflowName.value}` : "当前为未保存流程"; @@ -102,9 +103,15 @@ const upstreamVariables = computed(() => { }); watch( - () => route.params.apiId, + () => [route.params.apiId, route.params.workflowId], () => { - loadPage(); + if (routeWorkflowId.value) { + loadPageByWorkflow(); + } else if (apiId.value) { + loadPage(); + } else if (route.name === "workflow-create") { + loadNewWorkflowPage(); + } }, { immediate: true } ); @@ -117,7 +124,11 @@ watch( ); function goBack() { - router.push({ name: "apis" }); + if (routeWorkflowId.value) { + router.push({ name: "workflows" }); + } else { + router.push({ name: "apis" }); + } } 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() { if (!apiId.value) { pageError.value = "缺少接口编号"; @@ -726,12 +820,12 @@ async function submitForm() {
+ Base URL:
+ {{ activeEnv.base_url }}
+
{{ activeEnv.description }}
+管理 Base URL 与 Auth Token 的环境配置版本,执行工作流时使用活跃版本的设置。
+{{ row.base_url || "-" }}
+
+ {{ maskToken(row.auth_token) }}
+
+ 串联多个接口进行顺序执行,支持条件分支与循环。
+