feat: 新增环境配置管理和接口编辑增强
- 新增环境配置(EnvironmentConfig)的增删改查和激活功能 - API 接口支持 description、request_params、response_params 字段 - 接口管理页面支持抽屉内编辑(替代跳转编辑页) - 新增版本管理页面和工作流列表页面 - catalog_snapshot 返回 environments 数据 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
32cfb20999
commit
3ca520e398
219
app/main.py
219
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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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() {
|
||||
<div class="api-editor-hero">
|
||||
<div class="api-editor-hero__meta">
|
||||
<el-button text :icon="ArrowLeft" class="api-editor-hero__back" @click="goBack">
|
||||
返回接口列表
|
||||
{{ routeWorkflowId ? '返回工作流列表' : '返回接口列表' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<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 type="primary" :loading="saving" @click="submitForm">保存接口和流程</el-button>
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
import { Delete, Edit, FolderAdd, FolderOpened, Link, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiDelete, apiGet, apiPost } from "../api/http";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api/http";
|
||||
import FolderCreateDialog from "../components/FolderCreateDialog.vue";
|
||||
import RequestParamsTabs from "../components/RequestParamsTabs.vue";
|
||||
import { buildFolderTreeRows, collectFolderPaths, normalizeFolderPath } from "../utils/folderTree";
|
||||
import { collectKeyValueObject, normalizeKeyValueRows } from "../utils/keyValue";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
const searchKeyword = ref("");
|
||||
const rawRows = ref([]);
|
||||
const declaredFolderPaths = ref([]);
|
||||
@ -26,6 +26,7 @@ const formState = reactive({
|
||||
folder_path: "",
|
||||
method: "GET",
|
||||
url: "",
|
||||
description: "",
|
||||
timeout_seconds: 10,
|
||||
body_text: "{}",
|
||||
query_text: "{}",
|
||||
@ -33,6 +34,26 @@ const formState = reactive({
|
||||
|
||||
const headerRows = 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"];
|
||||
|
||||
@ -64,24 +85,49 @@ function resetForm() {
|
||||
formState.folder_path = "";
|
||||
formState.method = "GET";
|
||||
formState.url = "";
|
||||
formState.description = "";
|
||||
formState.timeout_seconds = 10;
|
||||
formState.body_text = "{}";
|
||||
formState.query_text = "{}";
|
||||
headerRows.value = [{ key: "", value: "" }];
|
||||
pathParamRows.value = [{ key: "", value: "" }];
|
||||
requestParams.value = [];
|
||||
responseParams.value = [];
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
drawerMode.value = "create";
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
function openCreateDrawerFromFolder(folderPath) {
|
||||
drawerMode.value = "create";
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
formState.folder_path = folderPath || "";
|
||||
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 = "") {
|
||||
folderDialogMode.value = "create";
|
||||
folderDialogEditPath.value = "";
|
||||
@ -100,10 +146,6 @@ async function handleFolderCreated() {
|
||||
await loadApis();
|
||||
}
|
||||
|
||||
function openEditPage(row) {
|
||||
router.push({ name: "api-edit", params: { apiId: row.id } });
|
||||
}
|
||||
|
||||
function parseJsonObject(text, label) {
|
||||
try {
|
||||
const parsed = JSON.parse(text || "{}");
|
||||
@ -122,11 +164,14 @@ function buildPayload() {
|
||||
folder_path: formState.folder_path.trim(),
|
||||
method: formState.method,
|
||||
url: formState.url.trim(),
|
||||
description: formState.description.trim(),
|
||||
timeout_seconds: Number(formState.timeout_seconds || 10),
|
||||
headers: collectKeyValueObject(headerRows.value, "Headers"),
|
||||
body: parseJsonObject(formState.body_text, "Body"),
|
||||
query: parseJsonObject(formState.query_text, "Query"),
|
||||
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;
|
||||
try {
|
||||
await apiPost("/api/apis", payload);
|
||||
ElMessage.success("接口已创建");
|
||||
if (drawerMode.value === "edit" && editingId.value) {
|
||||
await apiPut(`/api/apis/${editingId.value}`, payload);
|
||||
ElMessage.success("接口已更新");
|
||||
} else {
|
||||
await apiPost("/api/apis", payload);
|
||||
ElMessage.success("接口已创建");
|
||||
}
|
||||
drawerVisible.value = false;
|
||||
await loadApis();
|
||||
} catch (error) {
|
||||
@ -311,7 +361,7 @@ onMounted(() => {
|
||||
<el-button text :icon="Plus" @click="openCreateDrawerFromFolder(row.folder_path)">新建接口</el-button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@ -321,7 +371,7 @@ onMounted(() => {
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="新建接口"
|
||||
:title="drawerMode === 'edit' ? '编辑接口' : '新建接口'"
|
||||
size="760px"
|
||||
destroy-on-close
|
||||
>
|
||||
@ -355,6 +405,15 @@ onMounted(() => {
|
||||
</el-col>
|
||||
</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-input-number v-model="formState.timeout_seconds" :min="1" :step="1" class="full-width" />
|
||||
</el-form-item>
|
||||
@ -367,6 +426,76 @@ onMounted(() => {
|
||||
v-model:query-text="formState.query_text"
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
298
frontend-admin/src/views/VersionView.vue
Normal file
298
frontend-admin/src/views/VersionView.vue
Normal 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>
|
||||
222
frontend-admin/src/views/WorkflowListView.vue
Normal file
222
frontend-admin/src/views/WorkflowListView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user