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 .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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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],
|
||||||
() => {
|
() => {
|
||||||
loadPage();
|
if (routeWorkflowId.value) {
|
||||||
|
loadPageByWorkflow();
|
||||||
|
} else if (apiId.value) {
|
||||||
|
loadPage();
|
||||||
|
} else if (route.name === "workflow-create") {
|
||||||
|
loadNewWorkflowPage();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@ -117,7 +124,11 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: "apis" });
|
if (routeWorkflowId.value) {
|
||||||
|
router.push({ name: "workflows" });
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
|
|||||||
@ -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 {
|
||||||
await apiPost("/api/apis", payload);
|
if (drawerMode.value === "edit" && editingId.value) {
|
||||||
ElMessage.success("接口已创建");
|
await apiPut(`/api/apis/${editingId.value}`, payload);
|
||||||
|
ElMessage.success("接口已更新");
|
||||||
|
} else {
|
||||||
|
await apiPost("/api/apis", payload);
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
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