初始化仓库:AI 接口自动化测试平台

纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
qihongkun 2026-05-22 15:44:50 +08:00
commit d810abdcee
52 changed files with 19109 additions and 0 deletions

73
.gitignore vendored Normal file
View File

@ -0,0 +1,73 @@
# OS
.DS_Store
Thumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
ENV/
*.egg-info/
.eggs/
dist/
build/
*.egg
# Python tooling
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.tox/
.nox/
# SQLite / local data
ai_test.db
data.db
*.sqlite
*.sqlite3
*.db-journal
*.db-wal
*.db-shm
# SSH scripts (keep directory placeholder only)
data/ssh-scripts/*
!data/ssh-scripts/.gitkeep
# Environment & secrets
.env
.env.*
!.env.example
!**/.env.example
# IDE / editor
.idea/
.vscode/
*.swp
*.swo
*~
# Cursor / local agent tooling (not part of app source)
.cursor/
.serena/
# Project runtime logs
.run_logs/
*.log
# Frontend (Vue + Vite)
frontend-admin/node_modules/
frontend-admin/dist/
frontend-admin/.vite/
frontend-admin/.cache/
frontend-admin/coverage/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

22
AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
# AI Agent 工作约定(本仓库)
## MCP 能力说明(必读)
编排接口、工作流、跑批、执行历史时,**先阅读**
**[`docs/mcp_tools_for_ai.md`](docs/mcp_tools_for_ai.md)**
该文档包含:工具决策表、鉴权要求、跑批三步、工作流节点约定、排障 Recipe。不要仅凭 `tools/list` 的短 description 猜测用法。
## 快速规则
- MCP 服务名:`quality-inspection-platform`stdio 桥 `mcp_bridge.py`
- 写操作与执行类工具须 `sto-` API Key
- 任务开始:`catalog_snapshot`
- 跑批:`workflow_batch_create` → `workflow_batch_update``workflow_batch_run`
- 后端:`uvicorn app.main:app --host 127.0.0.1 --port 8000`
## 其它文档
- 安装桥接:`docs/mcp_install.md`
- 人类调用示例:`docs/mcp_quickstart.md`

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# 质量检测平台
一个基于 `FastAPI` 的质量检测平台,提供三类能力:
- 接口定义管理
- Mock 数据管理
- Workflow 流程编排与执行
此外,项目还带了一个 `stdio MCP bridge`,可以把平台里暴露的工具注册成真正的 MCP Server供 Cursor、Claude Desktop、Codex 等客户端调用。
## 技术栈
- Python
- FastAPI
- SQLAlchemy
- Pydantic
- SQLite
- Jinja2
- HTMX
- Drawflow
## 目录结构
```text
app/
main.py FastAPI 入口、页面路由、REST API、MCP HTTP 网关
database.py SQLite engine / session / Base
models.py SQLAlchemy 模型
schemas.py Pydantic 请求与响应模型
services/engine.py Workflow 执行引擎
static/app.js 前端交互逻辑
templates/ Jinja2 页面模板
docs/
mcp_tools_for_ai.md MCP 能力说明AI Agent 首选)
mcp_install.md MCP bridge 安装说明
mcp_quickstart.md MCP 调用说明(人类 / curl
AGENTS.md Codex 等自动加载的项目 Agent 约定
mcp_bridge.py stdio MCP Server 桥接入口
requirements.txt Python 依赖
```
## 安装依赖
建议先进入项目目录,再安装依赖:
```bash
pip install -r requirements.txt
```
如果你使用虚拟环境,也可以先激活 `.venv` 再安装。
## 启动项目
### 方式一:命令行启动后端
```bash
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
启动后访问:
- 首页:`http://127.0.0.1:8000/`
- API 文档:`http://127.0.0.1:8000/docs`
- MCP 工具列表:`http://127.0.0.1:8000/mcp/tools`
### 方式二:在 PyCharm 里启动
推荐创建一个 `Python` Run Configuration并使用 `Module name` 模式:
- `Module name`: `uvicorn`
- `Parameters`: `app.main:app --host 127.0.0.1 --port 8000 --reload`
- `Working directory`: 项目根目录
注意:`app.main:app` 必须放在 `uvicorn` 后面作为位置参数传入,不能写到最后。
## 启动 MCP Bridge
如果你要把本平台作为 MCP Server 暴露给其他客户端,再额外启动:
```bash
python3 mcp_bridge.py
```
桥接默认读取环境变量:
- `AI_TEST_BASE_URL`,默认值是 `http://127.0.0.1:8000`
完整配置见:
- [`docs/mcp_install.md`](./docs/mcp_install.md)
AI 使用 MCP 前请阅读:
- [`docs/mcp_tools_for_ai.md`](./docs/mcp_tools_for_ai.md)
- 项目根 [`AGENTS.md`](./AGENTS.md)Codex 会自动加载)
## 常用开发命令
```bash
rg --files
rg "workflow_run" app docs
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
python3 -m py_compile app/main.py app/services/engine.py mcp_bridge.py
```
## 当前已知情况
- 默认数据库文件是根目录下的 `ai_test.db`
- 项目当前没有现成的 `pytest`、`ruff`、`flake8` 配置
- 前端是模板 + 原生 JS 结构,改动 UI 时通常集中在:
- `app/templates/index.html`
- `app/templates/partials/*.html`
- `app/static/app.js`

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@

16
app/database.py Normal file
View File

@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = "sqlite:///./ai_test.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

3509
app/main.py Normal file

File diff suppressed because it is too large Load Diff

219
app/models.py Normal file
View File

@ -0,0 +1,219 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, func
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), nullable=False, unique=True, index=True)
password_hash = Column(String(128), nullable=False)
display_name = Column(String(64), nullable=False, default="")
role = Column(String(32), nullable=False, default="user", index=True) # superadmin | user
is_active = Column(Boolean, nullable=False, default=True)
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,
)
class UserApiKey(Base):
__tablename__ = "user_api_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, nullable=False, index=True)
key_prefix = Column(String(16), nullable=False, index=True)
key_hash = Column(String(128), nullable=False, unique=True, index=True)
key_hint = Column(String(8), nullable=False, default="")
expires_at = Column(DateTime(timezone=True), nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
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,
)
class ApiDefinition(Base):
__tablename__ = "api_definitions"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
folder_path = Column(String(255), nullable=False, default="", index=True)
method = Column(String(16), nullable=False)
url = Column(String(512), nullable=False)
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
config_json = Column(Text, nullable=False, default="{}")
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,
)
class Workflow(Base):
__tablename__ = "workflows"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
folder_path = Column(String(255), nullable=False, default="", index=True)
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
definition_json = Column(Text, nullable=False, default='{"nodes":[],"edges":[],"variables":{}}')
default_headers_json = Column(Text, nullable=False, default="{}")
last_run_json = Column(Text, nullable=True)
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,
)
class MockDataset(Base):
__tablename__ = "mock_datasets"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, unique=True, index=True)
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
data_json = Column(Text, nullable=False, default="{}")
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,
)
class McpToolConfig(Base):
__tablename__ = "mcp_tool_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, unique=True, index=True)
enabled = Column(Boolean, nullable=False, default=True)
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
config_json = Column(Text, nullable=False, default="{}")
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,
)
class SshProfile(Base):
__tablename__ = "ssh_profiles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False, default=22)
username = Column(String(128), nullable=False)
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
config_json = Column(Text, nullable=False, default="{}")
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,
)
class SshScript(Base):
__tablename__ = "ssh_scripts"
id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, nullable=False, index=True)
name = Column(String(128), nullable=False, index=True)
description = Column(String(255), nullable=False, default="")
source_type = Column(String(16), nullable=False, default="inline") # inline | file
content = Column(Text, nullable=False, default="")
storage_path = Column(String(512), nullable=False, default="")
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,
)
class SshShortcut(Base):
__tablename__ = "ssh_shortcuts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
command = Column(Text, nullable=False)
description = Column(String(255), nullable=False, default="")
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,
)
class FolderEntry(Base):
__tablename__ = "folder_entries"
id = Column(Integer, primary_key=True, index=True)
target = Column(String(32), nullable=False, index=True) # apis | workflows
path = Column(String(255), nullable=False, index=True)
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)
class WorkflowBatch(Base):
__tablename__ = "workflow_batches"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, default="")
status = Column(String(16), nullable=False, default="draft") # draft | running | success | failed | partial
triggered_by = Column(String(32), nullable=False, default="ui")
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
base_url = Column(String(512), nullable=False, default="")
fail_fast = Column(Boolean, nullable=False, default=True)
workflow_ids_json = Column(Text, nullable=False, default="[]")
summary_json = Column(Text, nullable=False, default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
class WorkflowRun(Base):
__tablename__ = "workflow_runs"
id = Column(Integer, primary_key=True, index=True)
batch_id = Column(Integer, nullable=True, index=True)
workflow_id = Column(Integer, nullable=False, index=True)
workflow_name = Column(String(128), nullable=False, default="")
run_type = Column(String(16), nullable=False, default="full") # full | node
node_id = Column(String(64), nullable=True)
triggered_by = Column(String(32), nullable=False, default="ui") # ui | mcp | api
status = Column(String(16), nullable=False, default="success") # success | failed
duration_ms = Column(Integer, nullable=False, default=0)
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)

408
app/schemas.py Normal file
View File

@ -0,0 +1,408 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class ApiBase(BaseModel):
name: str
folder_path: str = ""
method: str = "GET"
url: 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
class ApiCreate(ApiBase):
pass
class ApiUpdate(ApiBase):
pass
class ApiOut(ApiBase):
id: int
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class WorkflowPayload(BaseModel):
nodes: list[dict[str, Any]] = Field(default_factory=list)
edges: list[dict[str, Any]] = Field(default_factory=list)
variables: dict[str, Any] = Field(default_factory=dict)
class WorkflowBase(BaseModel):
name: str
folder_path: str = ""
definition: WorkflowPayload = Field(default_factory=WorkflowPayload)
default_headers: dict[str, Any] = Field(default_factory=dict)
class WorkflowCreate(WorkflowBase):
pass
class WorkflowUpdate(WorkflowBase):
pass
class WorkflowOut(WorkflowBase):
id: int
creator_id: int = 0
creator_name: str = ""
last_run: dict[str, Any] | None = None
class Config:
from_attributes = True
class ReplayRunRequest(BaseModel):
base_url: str = ""
fail_fast: bool = True
class MockDataBase(BaseModel):
name: str
data: dict[str, Any] = Field(default_factory=dict)
class MockDataCreate(MockDataBase):
pass
class MockDataOut(MockDataBase):
id: int
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class McpToolBase(BaseModel):
name: str
enabled: bool = True
config: dict[str, Any] = Field(default_factory=dict)
class McpToolCreate(McpToolBase):
pass
class McpToolOut(McpToolBase):
id: int
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class SshCommandPreset(BaseModel):
name: str
command: str
description: str = ""
class SshProfileBase(BaseModel):
name: str
host: str
port: int = 22
username: str
auth_type: Literal["key", "password"] = "key"
key_path: str = ""
presets: list[SshCommandPreset] = Field(default_factory=list)
class SshProfileCreate(SshProfileBase):
pass
class SshProfileUpdate(SshProfileBase):
pass
class SshProfileOut(SshProfileBase):
id: int
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class SshExecuteRequest(BaseModel):
command: str = ""
preset_name: str = ""
password: str = ""
timeout_seconds: float = 20.0
class SshExecuteResponse(BaseModel):
ok: bool
command: str
stdout: str = ""
stderr: str = ""
exit_status: int = 0
class SshShortcutBase(BaseModel):
name: str
command: str
description: str = ""
class SshShortcutCreate(SshShortcutBase):
pass
class SshShortcutUpdate(SshShortcutBase):
pass
class SshShortcutOut(SshShortcutBase):
id: int
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class SshScriptBase(BaseModel):
profile_id: int
name: str
description: str = ""
source_type: Literal["inline", "file"] = "inline"
content: str = ""
class SshScriptCreate(SshScriptBase):
pass
class SshScriptUpdate(BaseModel):
name: str
description: str = ""
content: str = ""
class SshScriptOut(SshScriptBase):
id: int
storage_path: str = ""
creator_id: int = 0
creator_name: str = ""
class Config:
from_attributes = True
class SshTreeScriptNode(BaseModel):
id: int
name: str
description: str = ""
source_type: Literal["inline", "file"] = "inline"
storage_path: str = ""
class SshTreeProfileNode(BaseModel):
id: int
name: str
host: str
port: int = 22
username: str
auth_type: Literal["key", "password"] = "key"
key_path: str = ""
scripts: list[SshTreeScriptNode] = Field(default_factory=list)
class LoginRequest(BaseModel):
username: str
password: str
class CurrentUserOut(BaseModel):
id: int
username: str
display_name: str
role: Literal["superadmin", "user"]
class LoginResponse(BaseModel):
token: str
user: CurrentUserOut
class UserCreate(BaseModel):
username: str
password: str
display_name: str = ""
role: Literal["superadmin", "user"] = "user"
class UserUpdate(BaseModel):
display_name: str = ""
role: Literal["superadmin", "user"] = "user"
is_active: bool = True
password: str = ""
class UserOut(BaseModel):
id: int
username: str
display_name: str
role: Literal["superadmin", "user"]
is_active: bool
class UserApiKeyCreate(BaseModel):
expires_at: str | None = None
class UserApiKeyOut(BaseModel):
id: int
key_prefix: str
key_hint: str
masked_key: str = ""
expires_at: str | None = None
is_active: bool = True
created_at: str = ""
class Config:
from_attributes = True
class UserApiKeyCreateResponse(UserApiKeyOut):
api_key: str
class RunWorkflowRequest(BaseModel):
workflow_id: int
base_url: str = ""
fail_fast: bool = True
class NodeExecutionResult(BaseModel):
node_id: str
node_type: str
status: Literal["success", "failed", "skipped"]
output: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
class RunWorkflowResponse(BaseModel):
status: Literal["success", "failed"]
variables: dict[str, Any]
results: list[NodeExecutionResult]
run_id: int | None = None
workflow_id: int | None = None
batch_id: int | None = None
class WorkflowBatchCreate(BaseModel):
name: str = ""
base_url: str = ""
fail_fast: bool = True
workflow_ids: list[int] = Field(default_factory=list)
class WorkflowBatchUpdate(BaseModel):
name: str | None = None
base_url: str | None = None
fail_fast: bool | None = None
workflow_ids: list[int] | None = None
class RunWorkflowBatchRequest(BaseModel):
"""快捷:创建批次并立即执行(兼容旧调用)。"""
workflow_ids: list[int] = Field(default_factory=list)
name: str = ""
base_url: str = ""
fail_fast: bool = True
class WorkflowBatchRunItem(BaseModel):
workflow_id: int
workflow_name: str = ""
run_id: int | None = None
status: str = "pending"
error: str | None = None
class RunWorkflowBatchResponse(BaseModel):
batch_id: int
status: Literal["draft", "running", "success", "failed", "partial"]
items: list[WorkflowBatchRunItem]
class WorkflowBatchOut(BaseModel):
id: int
name: str = ""
status: Literal["draft", "running", "success", "failed", "partial"]
triggered_by: str = ""
creator_id: int = 0
creator_name: str = ""
base_url: str = ""
fail_fast: bool = True
workflow_ids: list[int] = Field(default_factory=list)
summary: dict[str, Any] = Field(default_factory=dict)
created_at: str | None = None
finished_at: str | None = None
runs: list[dict[str, Any]] = Field(default_factory=list)
class LokiLogLinkOut(BaseModel):
enabled: bool = False
explore_url: str = ""
logql: str = ""
time_from: str = ""
time_to: str = ""
method: str = ""
url: str = ""
hint: str = ""
class RunNodeRequest(BaseModel):
workflow_id: int
node_id: str
base_url: str = ""
class RunNodeResponse(BaseModel):
status: Literal["success", "failed"]
variables: dict[str, Any]
result: NodeExecutionResult
class FolderMoveRequest(BaseModel):
target: Literal["apis", "workflows"]
from_path: str
to_path: str
class McpInvokeRequest(BaseModel):
tool: str
arguments: dict[str, Any] = Field(default_factory=dict)
api_key: str | None = Field(
default=None,
description="Optional sto- API key when Authorization header is unavailable",
)
class McpInvokeBatchRequest(BaseModel):
calls: list[McpInvokeRequest] = Field(default_factory=list)
stop_on_error: bool = True
class McpInvokeResponse(BaseModel):
ok: bool
tool: str
data: dict[str, Any] = Field(default_factory=dict)
error: str | None = None

953
app/services/engine.py Normal file
View File

@ -0,0 +1,953 @@
import asyncio
import json
import re
import time
from collections import defaultdict, deque
from collections.abc import Callable
from typing import Any
import httpx
def render_template_value(value: Any, variables: dict[str, Any]) -> Any:
if isinstance(value, str):
matches = re.findall(r"\{\{(.*?)\}\}", value)
rendered = value
for raw_key in matches:
key = raw_key.strip()
replacement = variables.get(key, "")
rendered = rendered.replace(f"{{{{{raw_key}}}}}", str(replacement))
return rendered
if isinstance(value, dict):
return {k: render_template_value(v, variables) for k, v in value.items()}
if isinstance(value, list):
return [render_template_value(v, variables) for v in value]
return value
def build_execution_order(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> list[str]:
node_ids = [n["id"] for n in nodes]
indegree = {node_id: 0 for node_id in node_ids}
graph = defaultdict(list)
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if source in indegree and target in indegree:
graph[source].append(target)
indegree[target] += 1
queue = deque([node_id for node_id in node_ids if indegree[node_id] == 0])
order = []
while queue:
cur = queue.popleft()
order.append(cur)
for nxt in graph[cur]:
indegree[nxt] -= 1
if indegree[nxt] == 0:
queue.append(nxt)
if len(order) != len(node_ids):
return node_ids
return order
LOOP_BODY_BRANCHES = {"", "body", "loop", "in", "next", "continue"}
LOOP_EXIT_BRANCHES = {"", "done", "break", "out", "exit", "default"}
def _safe_eval(left: Any, op: str, right: Any) -> bool:
if op == "==":
return left == right
if op == "!=":
return left != right
if op == ">":
return left > right
if op == "<":
return left < right
if op == ">=":
return left >= right
if op == "<=":
return left <= right
return False
async def execute_workflow(
definition: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
fail_fast: bool = True,
default_headers: dict[str, Any] | None = None,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None = None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None = None,
) -> dict[str, Any]:
nodes = definition.get("nodes", [])
edges = definition.get("edges", [])
variables = dict(definition.get("variables", {}))
node_map = {node["id"]: node for node in nodes}
order = build_execution_order(nodes, edges)
order_index = {node_id: idx for idx, node_id in enumerate(order)}
results: list[dict[str, Any]] = []
halted = False
graph = _build_edge_graph(edges)
pending = deque(_sorted_start_nodes(nodes, edges, order_index))
queued = set(pending)
executed = set()
total_nodes = len(order)
default_headers = dict(default_headers or {})
async with httpx.AsyncClient(timeout=20.0) as client:
while pending:
node_id = pending.popleft()
queued.discard(node_id)
if node_id in executed or node_id not in node_map:
continue
node = node_map[node_id]
node_type = str(node.get("data", {}).get("type", "http")).lower()
await _invoke_callback(on_node_start, node, len(executed) + 1, total_nodes)
if node_type == "loop":
loop_results, result = await _execute_loop_node(
node=node,
node_map=node_map,
graph=graph,
edges=edges,
order_index=order_index,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers,
fail_fast=fail_fast,
on_node_start=on_node_start,
on_node_finish=on_node_finish,
executed_count=len(executed),
total_nodes=total_nodes,
)
results.extend(loop_results)
else:
result = await execute_single_node(
node, variables, api_map, mock_map, base_url, client, default_headers=default_headers
)
results.append(result)
executed.add(node_id)
_persist_node_last_run(node, result)
await _invoke_callback(on_node_finish, node, result, len(executed), total_nodes)
if result["status"] == "failed" and fail_fast:
halted = True
break
allowed = _allowed_branches(result)
next_nodes = []
for target, branch in graph.get(node_id, []):
if target in executed or target in queued:
continue
if node_type == "loop":
if branch not in LOOP_EXIT_BRANCHES:
continue
if branch in allowed:
next_nodes.append(target)
next_nodes.sort(key=lambda item: order_index.get(item, 10**9))
for target in next_nodes:
pending.append(target)
queued.add(target)
if halted:
for node_id in order:
if node_id in executed:
continue
node_type = node_map[node_id].get("data", {}).get("type", "http")
skipped_result = {
"node_id": node_id,
"node_type": node_type,
"status": "skipped",
"output": {},
"error": "halted by previous failure",
}
results.append(skipped_result)
_persist_node_last_run(node_map[node_id], skipped_result)
await _invoke_callback(on_node_finish, node_map[node_id], skipped_result, len(executed), total_nodes)
status = "failed" if any(item["status"] == "failed" for item in results) else "success"
return {"status": status, "variables": variables, "results": results, "raw": json.dumps(results)}
async def _invoke_callback(callback: Callable[..., Any] | None, *args: Any) -> None:
if callback is None:
return
result = callback(*args)
if asyncio.iscoroutine(result):
await result
def _persist_node_last_run(node: dict[str, Any], result: dict[str, Any]) -> None:
if not isinstance(node, dict):
return
data = node.setdefault("data", {})
if not isinstance(data, dict):
return
data["last_run"] = {
"status": result.get("status"),
"error": result.get("error"),
"output": result.get("output", {}),
"ts": int(time.time()),
}
def _edge_branch(edge: dict[str, Any]) -> str:
data = edge.get("data")
if isinstance(data, dict):
value = str(data.get("branch", "")).strip().lower()
if value:
return value
label = str(edge.get("label", "")).strip().lower()
return label
def _build_edge_graph(edges: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]:
graph: dict[str, list[tuple[str, str]]] = defaultdict(list)
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if not source or not target:
continue
graph[source].append((target, _edge_branch(edge)))
return graph
def _sorted_start_nodes(
nodes: list[dict[str, Any]], edges: list[dict[str, Any]], order_index: dict[str, int]
) -> list[str]:
indegree = {node["id"]: 0 for node in nodes if "id" in node}
for edge in edges:
target = edge.get("target")
if target in indegree:
indegree[target] += 1
starts = [node_id for node_id, degree in indegree.items() if degree == 0]
starts.sort(key=lambda item: order_index.get(item, 10**9))
return starts
def _allowed_branches(result: dict[str, Any]) -> set[str]:
status = result.get("status")
node_type = result.get("node_type")
output = result.get("output", {})
if status == "failed":
return {"", "always", "default", "fail", "failed", "failure", "on_fail"}
if node_type == "condition":
if bool(output.get("result")):
return {"", "always", "default", "true", "on_true", "success"}
return {"", "always", "default", "false", "on_false"}
if node_type == "loop":
return set(LOOP_EXIT_BRANCHES) | {"always", "success", "on_success"}
return {"", "always", "default", "success", "on_success"}
def _resolve_field_path(source: Any, field: str) -> Any:
extracted = source
for part in str(field or "").split("."):
if not part:
continue
if isinstance(extracted, dict):
extracted = extracted.get(part)
else:
return None
return extracted
def _resolve_condition_operand(node_data: dict[str, Any], side: str, variables: dict[str, Any]) -> Any:
mode_key = f"{side}_mode"
mode = str(node_data.get(mode_key) or node_data.get("mode", "template")).strip().lower()
if mode == "json_path":
source_var = str(node_data.get(f"{side}_source_var") or node_data.get("source_var") or "").strip()
field = str(node_data.get(f"{side}_field") or node_data.get("field") or "").strip()
if not source_var:
return None
return _resolve_field_path(variables.get(source_var), field)
raw = node_data.get(side, "")
return render_template_value(raw, variables)
def _evaluate_condition(node_data: dict[str, Any], variables: dict[str, Any]) -> bool:
left = _resolve_condition_operand(node_data, "left", variables)
right = _resolve_condition_operand(node_data, "right", variables)
op = str(node_data.get("op", "=="))
return _safe_eval(left, op, right)
def _collect_loop_body_node_ids(loop_id: str, graph: dict[str, list[tuple[str, str]]]) -> set[str]:
entries = [target for target, branch in graph.get(loop_id, []) if branch in LOOP_BODY_BRANCHES]
if not entries:
return set()
body: set[str] = set()
queue = deque(entries)
while queue:
current = queue.popleft()
if current == loop_id or current in body:
continue
body.add(current)
for target, _branch in graph.get(current, []):
if target != loop_id and target not in body:
queue.append(target)
return body
async def _execute_node_subgraph(
*,
node_ids: set[str],
entry_node_ids: list[str],
node_map: dict[str, dict[str, Any]],
graph: dict[str, list[tuple[str, str]]],
edges: list[dict[str, Any]],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
fail_fast: bool,
iteration: int,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None,
executed_count: int,
total_nodes: int,
) -> tuple[list[dict[str, Any]], bool]:
if not node_ids:
return [], False
subgraph_nodes = [node_map[node_id] for node_id in node_ids if node_id in node_map]
subgraph_edges = [
edge
for edge in edges
if edge.get("source") in node_ids and edge.get("target") in node_ids
]
order = build_execution_order(subgraph_nodes, subgraph_edges)
order_index = {node_id: idx for idx, node_id in enumerate(order)}
indegree = {node_id: 0 for node_id in node_ids}
for edge in subgraph_edges:
target = edge.get("target")
if target in indegree:
indegree[target] += 1
starts = [node_id for node_id in entry_node_ids if node_id in node_ids]
if not starts:
starts = [node_id for node_id, degree in indegree.items() if degree == 0]
starts.sort(key=lambda item: order_index.get(item, 10**9))
pending = deque(starts)
queued = set(starts)
local_executed: set[str] = set()
results: list[dict[str, Any]] = []
halted = False
while pending:
node_id = pending.popleft()
queued.discard(node_id)
if node_id in local_executed or node_id not in node_map:
continue
node = node_map[node_id]
await _invoke_callback(on_node_start, node, executed_count + len(local_executed) + 1, total_nodes)
result = await execute_single_node(
node, variables, api_map, mock_map, base_url, client, default_headers=default_headers
)
if isinstance(result.get("output"), dict):
result["output"]["loop_iteration"] = iteration
results.append(result)
local_executed.add(node_id)
_persist_node_last_run(node, result)
await _invoke_callback(on_node_finish, node, result, executed_count + len(local_executed), total_nodes)
if result["status"] == "failed" and fail_fast:
halted = True
break
allowed = _allowed_branches(result)
next_nodes = []
for target, branch in graph.get(node_id, []):
if target not in node_ids or target in local_executed or target in queued:
continue
if branch in allowed:
next_nodes.append(target)
next_nodes.sort(key=lambda item: order_index.get(item, 10**9))
for target in next_nodes:
pending.append(target)
queued.add(target)
return results, halted
async def _execute_loop_node(
*,
node: dict[str, Any],
node_map: dict[str, dict[str, Any]],
graph: dict[str, list[tuple[str, str]]],
edges: list[dict[str, Any]],
order_index: dict[str, int],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
fail_fast: bool,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None,
executed_count: int,
total_nodes: int,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
node_id = str(node.get("id", ""))
node_data = node.get("data", {}) if isinstance(node.get("data"), dict) else {}
max_iterations = max(1, min(int(node_data.get("max_iterations", 10) or 10), 1000))
iteration_var = str(node_data.get("iteration_var") or "loop_index").strip() or "loop_index"
body_node_ids = _collect_loop_body_node_ids(node_id, graph)
entry_node_ids = [
target for target, branch in graph.get(node_id, []) if branch in LOOP_BODY_BRANCHES and target in body_node_ids
]
entry_node_ids.sort(key=lambda item: order_index.get(item, 10**9))
if not body_node_ids:
return [], {
"node_id": node_id,
"node_type": "loop",
"status": "failed",
"output": {"iterations": 0, "reason": "missing loop body branch"},
"error": "循环节点需要连接 body 分支到循环体",
}
all_results: list[dict[str, Any]] = []
iterations_run = 0
continue_loop = True
last_error: str | None = None
while continue_loop and iterations_run < max_iterations:
variables[iteration_var] = iterations_run
body_results, halted = await _execute_node_subgraph(
node_ids=body_node_ids,
entry_node_ids=entry_node_ids,
node_map=node_map,
graph=graph,
edges=edges,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers,
fail_fast=fail_fast,
iteration=iterations_run,
on_node_start=on_node_start,
on_node_finish=on_node_finish,
executed_count=executed_count + len(all_results),
total_nodes=total_nodes,
)
all_results.extend(body_results)
iterations_run += 1
if halted or any(item.get("status") == "failed" for item in body_results):
last_error = next(
(str(item.get("error") or "loop body failed") for item in body_results if item.get("status") == "failed"),
"loop body failed",
)
break
while_cfg = {
"left_mode": node_data.get("while_left_mode") or node_data.get("left_mode"),
"left": node_data.get("while_left", ""),
"left_source_var": node_data.get("while_left_source_var") or node_data.get("left_source_var"),
"left_field": node_data.get("while_left_field") or node_data.get("left_field"),
"op": node_data.get("while_op", node_data.get("op", "==")),
"right_mode": node_data.get("while_right_mode") or node_data.get("right_mode"),
"right": node_data.get("while_right", ""),
"right_source_var": node_data.get("while_right_source_var"),
"right_field": node_data.get("while_right_field"),
}
has_while_rule = any(
str(while_cfg.get(key) or "").strip()
for key in ("left", "left_source_var", "left_field", "right", "right_source_var", "right_field")
)
if not has_while_rule:
continue_loop = False
else:
continue_loop = _evaluate_condition(while_cfg, variables)
save_as = str(node_data.get("save_as") or f"{node_id}_loop").strip()
summary = {
"iterations": iterations_run,
"max_iterations": max_iterations,
"continue_loop": continue_loop,
"body_nodes": sorted(body_node_ids),
}
variables[save_as] = summary
status = "failed" if last_error else "success"
return all_results, {
"node_id": node_id,
"node_type": "loop",
"status": status,
"output": summary,
"error": last_error,
}
async def execute_single_node(
node: dict[str, Any],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient | None = None,
default_headers: dict[str, Any] | None = None,
) -> dict[str, Any]:
node_id = node.get("id", "")
node_data = node.get("data", {})
node_type = node_data.get("type", "http")
own_client = client is None
if own_client:
client = httpx.AsyncClient(timeout=20.0)
assert client is not None
try:
# Legacy compatibility: 旧的 mock 节点已废弃 → 视为 http
if node_type == "mock":
node_type = "http"
node_data["type"] = "http"
if node_type == "http":
return await _execute_http_node(
node_id=node_id,
node_data=node_data,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers or {},
)
if node_type == "condition":
ok = _evaluate_condition(node_data, variables)
variables[node_data.get("save_as", f"{node_id}_result")] = ok
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {
"result": ok,
"left": _resolve_condition_operand(node_data, "left", variables),
"right": _resolve_condition_operand(node_data, "right", variables),
"op": node_data.get("op", "=="),
},
"error": None,
}
if node_type == "loop":
return {
"node_id": node_id,
"node_type": node_type,
"status": "failed",
"output": {},
"error": "loop node must be executed by execute_workflow",
}
if node_type in {"start", "end"}:
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"pass": True},
"error": None,
}
if node_type == "extract":
source_key = node_data.get("source_var", "")
field = node_data.get("field", "")
save_as = node_data.get("save_as", "")
source = variables.get(source_key, {})
extracted = source
for part in field.split("."):
if not part:
continue
if isinstance(extracted, dict):
extracted = extracted.get(part)
else:
extracted = None
break
variables[save_as] = extracted
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"value": extracted},
"error": None,
}
if node_type == "transform":
template = node_data.get("template", {})
transformed = render_template_value(template, variables)
save_as = node_data.get("save_as", f"{node_id}_output")
variables[save_as] = transformed
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"value": transformed},
"error": None,
}
if node_type == "delay":
seconds = float(node_data.get("seconds", 1))
await asyncio.sleep(seconds)
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"waited": seconds},
"error": None,
}
if node_type == "ai":
prompt = node_data.get("prompt", "")
response = {"message": "AI node reserved", "prompt": prompt}
save_as = node_data.get("save_as", f"{node_id}_ai")
variables[save_as] = response
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": response,
"error": None,
}
raise ValueError(f"unsupported node type: {node_type}")
except Exception as exc:
return {
"node_id": node_id,
"node_type": node_type,
"status": "failed",
"output": {},
"error": str(exc),
}
finally:
if own_client:
await client.aclose()
def _merge_str_dict(a: dict[str, Any] | None, b: dict[str, Any] | None) -> dict[str, Any]:
out = dict(a or {})
for k, v in (b or {}).items():
out[k] = v
return out
def _mock_payload_for_overlay(
node_data: dict[str, Any], mock_map: dict[str, dict[str, Any]], variables: dict[str, Any]
) -> dict[str, Any]:
raw: Any = node_data.get("mock_data")
if raw is None:
raw = node_data.get("mock")
if isinstance(raw, str) and raw in mock_map:
raw = mock_map.get(raw) or {}
name = node_data.get("mock_name")
if (not raw or raw == {}) and isinstance(name, str) and name.strip() and name in mock_map:
raw = mock_map.get(name) or {}
rendered = render_template_value(raw if isinstance(raw, dict) else {}, variables)
return rendered if isinstance(rendered, dict) else {}
def _looks_like_response_mock(m: dict[str, Any]) -> bool:
if not m:
return False
if "status_code" in m:
return True
if m.get("text") is not None or m.get("json") is not None:
if not any(k in m for k in ("method", "url", "query", "path_params")):
return True
return False
def _has_request_overlay_keys(m: dict[str, Any]) -> bool:
return any(k in m for k in ("method", "url", "headers", "query", "body", "path_params"))
def _apply_request_overlay(
method: str,
url: str,
headers: dict[str, Any],
params: dict[str, Any],
json_body: dict[str, Any],
path_params: dict[str, Any],
overlay: dict[str, Any],
) -> tuple[str, str, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
if not overlay:
return method, url, headers, params, json_body, path_params
if overlay.get("method"):
method = str(overlay["method"]).upper()
if "url" in overlay and overlay["url"] is not None:
url = str(overlay["url"])
if isinstance(overlay.get("headers"), dict):
headers = _merge_str_dict(headers, overlay["headers"])
if isinstance(overlay.get("query"), dict):
params = dict(overlay["query"])
if isinstance(overlay.get("body"), dict):
json_body = dict(overlay["body"])
if isinstance(overlay.get("path_params"), dict):
path_params = dict(overlay["path_params"])
return method, url, headers, params, json_body, path_params
async def _execute_http_node(
node_id: str,
node_data: dict[str, Any],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
) -> dict[str, Any]:
request_cfg = _resolve_http_request(node_data, api_map)
mode = str(node_data.get("mode", "http")).lower()
overlay_cfg = _mock_payload_for_overlay(node_data, mock_map, variables)
use_request_mock = bool(node_data.get("use_mock"))
legacy_mode_mock = mode == "mock"
# 旧 mode=mock若 overlay 中含请求字段则作为请求 mock仅有响应字段时不覆盖请求仍发真实 HTTP
if legacy_mode_mock:
use_request_mock = use_request_mock or _has_request_overlay_keys(overlay_cfg)
method = str(request_cfg.get("method", "GET")).upper()
url = render_template_value(request_cfg.get("url", ""), variables)
dh = render_template_value(dict(default_headers or {}), variables) or {}
base_h = render_template_value(request_cfg.get("headers", {}), variables) or {}
headers = _merge_str_dict(dh, base_h)
params = render_template_value(request_cfg.get("query", {}), variables) or {}
json_body = render_template_value(request_cfg.get("body", {}), variables) or {}
path_params = render_template_value(request_cfg.get("path_params", {}), variables) or {}
mock_request_applied = False
if use_request_mock and overlay_cfg and _has_request_overlay_keys(overlay_cfg):
method, url, headers, params, json_body, path_params = _apply_request_overlay(
method, url, headers, params, json_body, path_params, overlay_cfg
)
mock_request_applied = True
if base_url and url and not url.startswith("http"):
url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
for key, value in path_params.items():
url = url.replace(f"{{{key}}}", str(value))
request_payload = {
"method": method,
"url": url,
"headers": headers,
"query": params,
"body": json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None,
"path_params": path_params,
"mock_request_applied": mock_request_applied,
}
response_payload: dict[str, Any] = {}
error_message: str | None = None
mock_response_used = False
started = time.perf_counter()
try:
response = await client.request(
method=method,
url=url,
headers=headers,
params=params,
json=json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None,
)
response_payload = _build_response_payload(response)
except Exception as exc:
error_message = str(exc)
if mode == "auto" and overlay_cfg and _looks_like_response_mock(overlay_cfg):
response_payload = _normalize_mock_response(overlay_cfg)
response_payload["mock_fallback_reason"] = error_message
mock_response_used = True
error_message = None
duration_ms = int((time.perf_counter() - started) * 1000)
assertions = _evaluate_http_assertions(node_data.get("expect"), response_payload, error_message)
status = "failed" if (error_message or any(item["passed"] is False for item in assertions)) else "success"
output = {
"request": request_payload,
"response": response_payload,
"mock_used": mock_request_applied or mock_response_used,
"duration_ms": duration_ms,
"assertions": assertions,
}
save_as = node_data.get("save_as")
if save_as:
variables[save_as] = response_payload
if status == "failed" and error_message is None:
error_message = "; ".join(item.get("reason", "assertion failed") for item in assertions if item["passed"] is False)
return {
"node_id": node_id,
"node_type": "http",
"status": status,
"output": output,
"error": error_message,
}
def _resolve_http_request(node_data: dict[str, Any], api_map: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""Merge api_map definition (if api_id given) with inline node fields. Inline overrides."""
base: dict[str, Any] = {}
api_id_value = node_data.get("api_id")
if api_id_value is not None:
try:
api_id = int(api_id_value)
except (TypeError, ValueError):
api_id = None
if api_id is not None and api_id in api_map:
api_def = api_map[api_id]
base = {
"method": api_def.get("method", "GET"),
"url": api_def.get("url", ""),
"headers": dict(api_def.get("headers", {}) or {}),
"query": dict(api_def.get("query", {}) or {}),
"body": dict(api_def.get("body", {}) or {}),
"path_params": dict(api_def.get("path_params", {}) or {}),
}
for field in ("method", "url"):
if node_data.get(field):
base[field] = node_data[field]
for field in ("headers", "query", "body", "path_params"):
if isinstance(node_data.get(field), dict):
merged = dict(base.get(field, {}) or {})
merged.update(node_data[field])
base[field] = merged
base.setdefault("method", "GET")
base.setdefault("url", "")
base.setdefault("headers", {})
base.setdefault("query", {})
base.setdefault("body", {})
base.setdefault("path_params", {})
return base
def _build_response_payload(response: httpx.Response) -> dict[str, Any]:
payload: dict[str, Any] = {
"status_code": response.status_code,
"headers": dict(response.headers),
"text": response.text,
}
try:
payload["json"] = response.json()
except Exception:
payload["json"] = None
return payload
def _normalize_mock_response(mock_data: dict[str, Any]) -> dict[str, Any]:
if not isinstance(mock_data, dict):
return {"status_code": 200, "headers": {}, "text": "", "json": None}
body = mock_data.get("json")
if body is None and "data" in mock_data:
body = mock_data.get("data")
text = mock_data.get("text")
if text is None and body is not None:
try:
text = json.dumps(body, ensure_ascii=False)
except Exception:
text = str(body)
return {
"status_code": int(mock_data.get("status_code", 200)),
"headers": dict(mock_data.get("headers", {}) or {}),
"text": text or "",
"json": body,
"is_mock": True,
}
def _evaluate_http_assertions(
expect: Any, response_payload: dict[str, Any], error_message: str | None
) -> list[dict[str, Any]]:
if not isinstance(expect, dict):
return []
if error_message:
return [{"name": "request", "passed": False, "reason": error_message}]
results: list[dict[str, Any]] = []
status_in = expect.get("status_in") or expect.get("status_codes")
if isinstance(status_in, list) and status_in:
actual = response_payload.get("status_code")
passed = actual in status_in
results.append(
{
"name": "status_in",
"passed": passed,
"expected": status_in,
"actual": actual,
"reason": None if passed else f"status_code {actual} not in {status_in}",
}
)
json_contains = expect.get("json_contains")
if isinstance(json_contains, dict):
body = response_payload.get("json") or {}
passed, reason = _dict_contains(body, json_contains)
results.append(
{
"name": "json_contains",
"passed": passed,
"expected": json_contains,
"actual": body,
"reason": None if passed else reason,
}
)
text_includes = expect.get("text_includes")
if isinstance(text_includes, str):
text = response_payload.get("text") or ""
passed = text_includes in text
results.append(
{
"name": "text_includes",
"passed": passed,
"expected": text_includes,
"actual": text[:200],
"reason": None if passed else f"response text does not contain '{text_includes}'",
}
)
return results
def _dict_contains(actual: Any, expected: Any) -> tuple[bool, str | None]:
if isinstance(expected, dict):
if not isinstance(actual, dict):
return False, "expected object"
for key, value in expected.items():
if key not in actual:
return False, f"missing key: {key}"
ok, reason = _dict_contains(actual[key], value)
if not ok:
return False, f"{key}: {reason}"
return True, None
if isinstance(expected, list):
if not isinstance(actual, list):
return False, "expected list"
for item in expected:
if item not in actual:
return False, f"missing list item: {item}"
return True, None
return (actual == expected, None if actual == expected else f"expected {expected}, got {actual}")

126
app/services/loki.py Normal file
View File

@ -0,0 +1,126 @@
"""Build General / Grafana Loki explore links from workflow run context."""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote
def _parse_run_time(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
text = str(value).strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return datetime.now(timezone.utc)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _url_path_hint(url: str) -> str:
text = str(url or "").strip()
if not text:
return ""
if "://" in text:
try:
from urllib.parse import urlparse
parsed = urlparse(text)
return parsed.path or text
except Exception:
return text
return text.split("?")[0]
def _extract_http_request(node_result: dict[str, Any] | None) -> dict[str, str]:
if not isinstance(node_result, dict):
return {}
output = node_result.get("output") or {}
if not isinstance(output, dict):
return {}
request = output.get("request") or {}
if not isinstance(request, dict):
return {}
method = str(request.get("method") or "GET").upper()
url = str(request.get("url") or "")
return {"method": method, "url": url, "path": _url_path_hint(url)}
def _build_logql(method: str, url_path: str) -> str:
service_label = os.getenv("LOKI_LABEL_SELECTOR", "").strip()
if service_label:
base = service_label
else:
service_key = os.getenv("LOKI_SERVICE_LABEL", "app").strip() or "app"
service_value = os.getenv("LOKI_SERVICE_VALUE", "").strip() or "ai-auto-test"
base = f'{{{service_key}="{service_value}"}}'
filters: list[str] = [base]
if url_path:
escaped = url_path.replace('"', '\\"')
filters.append(f'|= "{escaped}"')
if method and method != "GET":
filters.append(f'|= "{method}"')
return " ".join(filters)
def _build_grafana_explore_url(logql: str, time_from: datetime, time_to: datetime) -> str:
base = (os.getenv("GENERAL_LOKI_EXPLORE_URL") or os.getenv("LOKI_EXPLORE_URL") or "").strip().rstrip("/")
if not base:
return ""
datasource = os.getenv("LOKI_DATASOURCE", "Loki").strip() or "Loki"
org_id = os.getenv("LOKI_ORG_ID", "1").strip() or "1"
from_ms = int(time_from.timestamp() * 1000)
to_ms = int(time_to.timestamp() * 1000)
left = json.dumps(
[str(from_ms), str(to_ms), datasource, {"expr": logql, "refId": "A"}],
ensure_ascii=False,
)
return f"{base}?orgId={quote(org_id)}&left={quote(left)}"
def build_run_loki_link(
*,
run_created_at: str | None,
duration_ms: int = 0,
node_result: dict[str, Any] | None = None,
padding_seconds: int | None = None,
) -> dict[str, Any]:
"""Return explore URL + LogQL for a single HTTP node execution."""
padding = int(padding_seconds or os.getenv("LOKI_TIME_PADDING_SECONDS", "120") or 120)
started = _parse_run_time(run_created_at)
ended = started + timedelta(milliseconds=max(duration_ms, 0))
time_from = started - timedelta(seconds=padding)
time_to = ended + timedelta(seconds=padding)
http = _extract_http_request(node_result)
method = http.get("method", "")
url = http.get("url", "")
path = http.get("path", "")
logql = _build_logql(method, path)
explore_url = _build_grafana_explore_url(logql, time_from, time_to)
enabled = bool(explore_url)
hint = "已生成 General Loki 查询链接,将按执行时间窗口与接口路径过滤日志。"
if not enabled:
hint = "未配置 GENERAL_LOKI_EXPLORE_URL / LOKI_EXPLORE_URL请在环境变量中设置 General 探索页地址。"
return {
"enabled": enabled,
"explore_url": explore_url,
"logql": logql,
"time_from": time_from.isoformat(),
"time_to": time_to.isoformat(),
"method": method,
"url": url,
"hint": hint,
}

220
app/services/ssh_runner.py Normal file
View File

@ -0,0 +1,220 @@
import asyncio
import contextlib
import os
import posixpath
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import paramiko
from ..schemas import SshProfileOut, SshScriptOut
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
SSH_SCRIPTS_DIR = PROJECT_ROOT / "data" / "ssh-scripts"
def resolve_script_body(script: SshScriptOut) -> str:
if script.source_type == "file":
if not script.storage_path:
raise ValueError("脚本文件路径为空")
file_path = (SSH_SCRIPTS_DIR / script.storage_path).resolve()
scripts_root = SSH_SCRIPTS_DIR.resolve()
if scripts_root not in file_path.parents and file_path != scripts_root:
raise ValueError("脚本文件路径非法")
if not file_path.is_file():
raise ValueError("脚本文件不存在")
return file_path.read_text(encoding="utf-8")
body = (script.content or "").strip()
if not body:
raise ValueError("脚本内容为空")
return body
def _build_connect_kwargs(profile: SshProfileOut, password: str) -> dict[str, Any]:
if not password.strip():
raise ValueError("执行前必须输入密码")
connect_kwargs: dict[str, Any] = {
"hostname": profile.host,
"port": int(profile.port or 22),
"username": profile.username,
"timeout": 15,
"banner_timeout": 15,
"auth_timeout": 15,
}
if profile.auth_type == "password":
connect_kwargs.update({"password": password, "look_for_keys": False, "allow_agent": False})
elif profile.auth_type == "key" and profile.key_path:
connect_kwargs.update({"key_filename": profile.key_path, "look_for_keys": False, "allow_agent": False})
else:
connect_kwargs.update({"look_for_keys": True, "allow_agent": True})
return connect_kwargs
def _connect_ssh(profile: SshProfileOut, password: str) -> paramiko.SSHClient:
if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path):
raise ValueError("配置的 key_path 不存在")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(**_build_connect_kwargs(profile, password))
return client
def _upload_and_build_command(client: paramiko.SSHClient, script_body: str, script_name: str) -> str:
run_id = uuid.uuid4().hex[:12]
remote_dir = posixpath.join("/tmp", f"qip-script-{run_id}")
safe_name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in script_name) or "script.sh"
if not safe_name.endswith(".sh"):
safe_name = f"{safe_name}.sh"
remote_path = posixpath.join(remote_dir, safe_name)
stdin, stdout, stderr = client.exec_command(f"mkdir -p {remote_dir} && chmod 700 {remote_dir}")
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
err = (stderr.read() or b"").decode("utf-8", errors="replace").strip()
raise RuntimeError(err or "无法在远端创建临时目录")
with client.open_sftp() as sftp:
with sftp.file(remote_path, "w") as remote_file:
remote_file.write(script_body)
sftp.chmod(remote_path, 0o700)
return f"bash {remote_path}"
def _cleanup_remote_session(stdout: Any, stderr: Any, client: paramiko.SSHClient | None) -> None:
for stream in (stdout, stderr):
channel = getattr(stream, "channel", None)
if channel is not None:
with contextlib.suppress(Exception):
channel.close()
if client is not None:
with contextlib.suppress(Exception):
client.close()
async def stream_script_run(
profile: SshProfileOut,
script: SshScriptOut,
password: str,
cancel_event: asyncio.Event | None = None,
) -> AsyncIterator[dict[str, Any]]:
cancel_event = cancel_event or asyncio.Event()
script_body = resolve_script_body(script)
client = await asyncio.to_thread(_connect_ssh, profile, password)
stdout = None
stderr = None
try:
remote_command = await asyncio.to_thread(_upload_and_build_command, client, script_body, script.name)
yield {"type": "meta", "command": remote_command, "profile": profile.name, "script": script.name}
stdin, stdout, stderr = await asyncio.to_thread(client.exec_command, remote_command)
if stdin:
with contextlib.suppress(Exception):
stdin.close()
while True:
if cancel_event.is_set():
yield {"type": "stopped", "detail": "用户手动停止", "ok": False, "exit_status": -1}
return
emitted = False
if stdout.channel.recv_ready():
chunk = await asyncio.to_thread(stdout.channel.recv, 4096)
if chunk:
emitted = True
yield {"type": "stdout", "data": chunk.decode("utf-8", errors="replace")}
if stderr.channel.recv_stderr_ready():
chunk = await asyncio.to_thread(stderr.channel.recv_stderr, 4096)
if chunk:
emitted = True
yield {"type": "stderr", "data": chunk.decode("utf-8", errors="replace")}
if (
stdout.channel.exit_status_ready()
and not stdout.channel.recv_ready()
and not stderr.channel.recv_stderr_ready()
):
break
if not emitted:
await asyncio.sleep(0.05)
if cancel_event.is_set():
yield {"type": "stopped", "detail": "用户手动停止", "ok": False, "exit_status": -1}
return
exit_status = await asyncio.to_thread(stdout.channel.recv_exit_status)
yield {"type": "done", "exit_status": int(exit_status), "ok": exit_status == 0}
finally:
_cleanup_remote_session(stdout, stderr, client)
async def run_script_collect(
profile: SshProfileOut,
script: SshScriptOut,
password: str,
timeout_seconds: float = 120.0,
max_output_chars: int = 200_000,
) -> dict[str, Any]:
timeout_value = max(5.0, min(float(timeout_seconds or 120.0), 600.0))
cancel_event = asyncio.Event()
stdout_parts: list[str] = []
stderr_parts: list[str] = []
meta: dict[str, Any] = {}
result: dict[str, Any] = {
"ok": False,
"exit_status": -1,
"stdout": "",
"stderr": "",
"command": "",
"timed_out": False,
"truncated": False,
}
async def consume() -> None:
nonlocal result
async for event in stream_script_run(profile, script, password, cancel_event):
event_type = str(event.get("type", ""))
if event_type == "meta":
meta.update(event)
result["command"] = str(event.get("command", ""))
continue
if event_type in {"stdout", "stderr"}:
chunk = str(event.get("data", ""))
bucket = stdout_parts if event_type == "stdout" else stderr_parts
bucket.append(chunk)
total_len = sum(len(item) for item in stdout_parts) + sum(len(item) for item in stderr_parts)
if total_len > max_output_chars:
result["truncated"] = True
cancel_event.set()
return
continue
if event_type == "done":
result["ok"] = bool(event.get("ok"))
result["exit_status"] = int(event.get("exit_status", 0))
return
if event_type == "stopped":
result["ok"] = False
result["exit_status"] = int(event.get("exit_status", -1))
result["stopped"] = True
return
if event_type == "error":
raise RuntimeError(str(event.get("detail", "ssh script run failed")))
try:
await asyncio.wait_for(consume(), timeout=timeout_value)
except asyncio.TimeoutError:
cancel_event.set()
result["timed_out"] = True
result["ok"] = False
result["exit_status"] = -1
finally:
result["stdout"] = "".join(stdout_parts)
result["stderr"] = "".join(stderr_parts)
if meta:
result["profile"] = meta.get("profile")
result["script"] = meta.get("script")
return result

View File

@ -0,0 +1 @@

201
docs/mcp_install.md Normal file
View File

@ -0,0 +1,201 @@
# 把本平台注册成真正的 MCP Server
本仓库已附带 `mcp_bridge.py`,它是一个标准 stdio MCP Server
对外暴露 MCP 协议JSON-RPC over stdio内部桥接到本平台的 HTTP 网关。
只要先启动平台后端,再让 Cursor / Claude Desktop / Codex CLI 启动这个桥接,
它就会被识别成一个真正的 MCP Server并自动暴露所有平台工具含跑批工作流工具
---
## 1. 启动平台后端
```bash
cd /Users/qihongkun/work/My_app/ai_auto_test
# 可选:生产/共享环境建议开启,所有 MCP 调用必须带 API Key
# export MCP_REQUIRE_API_KEY=true
uvicorn app.main:app --host 127.0.0.1 --port 8000
```
确认可访问:
- `http://127.0.0.1:8000/mcp/tools`
---
## 2. 安装到 Cursor
编辑 `~/.cursor/mcp.json`(不存在则新建),加入:
```json
{
"mcpServers": {
"quality-inspection-platform": {
"command": "python3",
"args": ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"],
"env": {
"AI_TEST_BASE_URL": "http://127.0.0.1:8000",
"AI_TEST_API_KEY": "sto-你的API密钥"
}
}
}
}
```
- `AI_TEST_BASE_URL`:平台后端地址。
- `AI_TEST_API_KEY`**必须配置**。写操作与执行类工具(`workflow_run`、`workflow_batch_run`、`ssh_script_run` 等)无 Key 将返回 401桥接会把 Key 放到 `Authorization: Bearer` 与 invoke body 的 `api_key` 字段。
- 后端可选 `MCP_REQUIRE_API_KEY=true`:所有 MCP 工具(含只读、`GET /mcp/tools`)均要求 Key。
在平台 Web 端右上角「个人中心」生成 `sto-` 开头的 API Key。
重启 Cursor 后,在 MCP 面板里就能看到 `quality-inspection-platform`,里面会自动列出全部工具。
---
## 3. 安装到 Claude Desktop
编辑 `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"quality-inspection-platform": {
"command": "python3",
"args": ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"],
"env": {
"AI_TEST_BASE_URL": "http://127.0.0.1:8000",
"AI_TEST_API_KEY": "sto-你的API密钥"
}
}
}
}
```
---
## 4. 安装到 Codex CLI
```bash
codex mcp add quality-inspection-platform \
--command python3 \
--args /Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py \
--env AI_TEST_BASE_URL=http://127.0.0.1:8000 \
--env AI_TEST_API_KEY=sto-你的API密钥
```
或在 `~/.codex/config.toml` 中:
```toml
[mcp.servers.quality-inspection-platform]
command = "python3"
args = ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"]
[mcp.servers.quality-inspection-platform.env]
AI_TEST_BASE_URL = "http://127.0.0.1:8000"
AI_TEST_API_KEY = "sto-你的API密钥"
```
---
## 5. 自检(不连客户端,先确认桥接可用)
```bash
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"catalog_snapshot","arguments":{}}}' \
| AI_TEST_API_KEY=sto-你的API密钥 python3 mcp_bridge.py
```
预期:
- `initialize` 返回 `serverInfo`
- `tools/list` 返回工具清单(应包含 `workflow_batch_*` 等)
- `tools/call` 返回 `content[0].text` 内含调用结果
---
## 6. 暴露的工具(自动转发自平台)
桥接启动时从 `GET /mcp/tools` 拉取列表,**无需改 `mcp_bridge.py`** 即可随平台升级获得新工具。
### 资源管理
- `api_upsert` — 创建/更新接口
- `mock_upsert` — 创建/更新 mock 数据
- `mcp_tool_upsert` — 创建/更新 MCP 工具配置
- `catalog_snapshot` — 全量资源快照(含 `workflow_batches`、目录树、SSH 树)
### 工作流(单次)
- `workflow_upsert` — 创建/更新工作流(支持 `loop`、`condition` + `json_path`
- `workflow_get` — 读取工作流及各节点 `last_run`
- `workflow_node_status` — 单节点最近执行状态
- `workflow_patch_json` — 增量修改工作流 JSON
- `workflow_validate` — 校验 definition JSON
- `workflow_run` / `workflow_run_node` — 执行(需 API Key
- `workflow_analyze_last_run` — 分析最近一次执行
- `workflow_run_list` / `workflow_run_get` — 执行历史
- `workflow_run_replay` / `workflow_run_loki_link` — 重放与 Loki 链接
### 跑批工作流
- `workflow_batch_create` — 创建草稿批跑任务(需 API Key
- `workflow_batch_update` — 更新 `workflow_ids` / `base_url` 等(需 API Key
- `workflow_batch_run` — 按任务配置顺序执行多个工作流
- `workflow_batch_get` — 批跑详情与关联 `workflow_runs`
- `workflow_batch_list` — 批跑任务列表
推荐顺序:`create` → `update`(绑定 `workflow_ids`)→ `run``get`。详见 `docs/mcp_quickstart.md` 示例 H。
### 目录
- `folder_ensure` / `folder_list`
- `api_move_folder` / `workflow_move_folder`
### SSH 脚本
- `ssh_tree` / `ssh_script_get`
- `ssh_script_upsert` / `ssh_script_run`
---
## 7. 工作机制
```
Cursor / Claude / Codex
|
| (MCP stdio JSON-RPC)
v
mcp_bridge.py
|
| HTTP (httpx) + Authorization / api_key
v
http://127.0.0.1:8000/mcp/invoke
```
- 桥接会在启动时调用一次 `/mcp/tools`,把工具映射成 MCP `tools/list` 返回值。
- 每次 `tools/call`,桥接会以 `{tool, arguments}` POST 到 `/mcp/invoke`
- 后端返回 `{ok, tool, data, error}`,桥接将其作为 `content[0].text` 文本返回,并按 `ok` 设置 `isError`
---
## 8. Loki 日志(可选)
若需在管理端或 API 中打开 General/Grafana Loki 探索页,在后端进程环境中配置:
- `GENERAL_LOKI_EXPLORE_URL``LOKI_EXPLORE_URL`
- 可选:`LOKI_DATASOURCE`、`LOKI_ORG_ID`、`LOKI_LABEL_SELECTOR`、`LOKI_TIME_PADDING_SECONDS`
配置后,`GET /api/workflow-runs/{run_id}/loki-link` 可为指定 HTTP 节点生成带时间窗与路径过滤的 Explore URL。
---
## 9. 维护建议
- 平台新增工具时,无需改桥接,重启 MCP 客户端即可重新拉取 `tools/list`
- 团队成员:`git pull` + `pip install -r requirements.txt` + 配置上述 MCP 入口与 `AI_TEST_API_KEY`
- **AI 能力说明(首选)****`docs/mcp_tools_for_ai.md`**
- 人类 curl 示例:**`docs/mcp_quickstart.md`**
- Codex 自动加载:**项目根 `AGENTS.md`**

484
docs/mcp_quickstart.md Normal file
View File

@ -0,0 +1,484 @@
# MCP 快速接入与 AI 调用说明书
> **AI Agent 请优先阅读**[mcp_tools_for_ai.md](./mcp_tools_for_ai.md)工具决策表、鉴权、Recipe、节点约定
> 本文档侧重 curl 示例与人工接入;项目根 [AGENTS.md](../AGENTS.md) 供 Codex 自动加载。
本文档给 AI Agent 和开发者使用,目标是让 AI 可以直接通过本平台的 MCP 接口完成:
- 接口创建/更新
- Mock 数据创建/更新
- Workflow JSON 创建/修改(含循环节点、条件分支)
- 单次执行工作流 / 单节点调试
- **跑批工作流**(先建任务、再选流程、再执行)
- 执行结果分析
- 批量编排调用
---
## 1. 基础信息
- 服务地址:`http://127.0.0.1:8000`
- 工具发现:`GET /mcp/tools`
- 单次调用:`POST /mcp/invoke`
- 批量调用:`POST /mcp/invoke-batch`
- MCP 鉴权:在平台右上角「个人中心」生成 `sto-` 开头的 API Key配置到 MCP Bridge 环境变量 `AI_TEST_API_KEY`,或在请求头使用 `Authorization: Bearer <api_key>` / `X-API-Key: <api_key>`
- **写操作**(创建/更新资源)与 **执行类操作**跑工作流、批跑、单节点、SSH 执行、重放)**必须**带有效 Key禁止无 Key 回落为 superadmin。
- 可选 **`MCP_REQUIRE_API_KEY=true`**(后端环境变量):所有 MCP 工具(含只读)均要求 Key`GET /mcp/tools` 同样校验。
**需要 API Key 的写操作**`folder_ensure`、`api_upsert`、`workflow_upsert`、`workflow_patch_json`、`workflow_move_folder`、`api_move_folder`、`mock_upsert`、`mcp_tool_upsert`、`ssh_script_upsert`、`workflow_batch_create`、`workflow_batch_update`
**需要 API Key 的执行操作**`workflow_run`、`workflow_run_node`、`workflow_batch_run`、`workflow_run_replay`、`ssh_script_run`
统一返回结构(`/mcp/invoke`
```json
{
"ok": true,
"tool": "tool_name",
"data": {},
"error": null
}
```
---
## 2. 当前可用 MCP 工具
### 资源与工作流
| 工具 | 用途 | 关键参数 |
|------|------|----------|
| `api_upsert` | 创建或更新接口 | `name`, `method`, `url`;可选 `api_id`, `headers`, `body`, `query`, `path_params`, `timeout_seconds`, `folder_path` |
| `mock_upsert` | 创建或更新 Mock | `name`, `data` |
| `workflow_upsert` | 创建或更新工作流 JSON | `name`, `definition`;可选 `workflow_id`, `folder_path` |
| `workflow_get` | 读取完整工作流(含各节点 `last_run` | `workflow_id` |
| `workflow_node_status` | 读取单节点最近执行状态 | `workflow_id`, `node_id` |
| `workflow_patch_json` | 对工作流 definition 深度合并 patch | `workflow_id`, `patch` |
| `workflow_run` | 执行整个工作流 | `workflow_id`;可选 `base_url`, `fail_fast` |
| `workflow_run_node` | 执行单个节点 | `workflow_id`, `node_id`;可选 `base_url` |
| `workflow_analyze_last_run` | 分析最近一次执行摘要 | `workflow_id` |
| `workflow_run_list` | 执行历史列表 | 可选 `workflow_id`、`batch_id`、`limit`、`after_id` |
| `workflow_run_get` | 单次执行详情(含完整 `payload` | `run_id` |
| `workflow_run_replay` | 按历史快照重放 | `run_id`;可选 `base_url`、`fail_fast` |
| `workflow_run_loki_link` | 生成 Loki Explore 链接 | `run_id`、`node_id` |
| `workflow_validate` | 校验 definition JSON | `definition` |
### 跑批工作流(推荐顺序见 §5 示例 H
| 工具 | 用途 | 关键参数 |
|------|------|----------|
| `workflow_batch_create` | 创建批跑任务(`draft` | 可选 `name`, `base_url`, `fail_fast`, `workflow_ids` |
| `workflow_batch_update` | 更新草稿批跑任务 | `batch_id`;可选 `name`, `base_url`, `fail_fast`, `workflow_ids` |
| `workflow_batch_run` | 执行批跑(按 `workflow_ids` 顺序跑多个工作流) | `batch_id` |
| `workflow_batch_get` | 批跑详情(含关联的 `workflow_runs` | `batch_id` |
| `workflow_batch_list` | 列出当前用户的批跑任务 | 可选 `limit`, `after_id`, `status``draft`/`running`/`success`/`failed`/`partial` |
> **跑批 MCP 约定**:必须先 `workflow_batch_create`(或 create 时带上 `workflow_ids`),再 `workflow_batch_update` 绑定工作流,最后 `workflow_batch_run`。不要跳过草稿阶段直接「匿名批量跑」。
### 目录管理
| 工具 | 用途 |
|------|------|
| `folder_ensure` | 创建/登记目录(`target`: `apis` / `workflows``path` |
| `folder_list` | 列出目录 |
| `api_move_folder` | 移动接口到目录(`api_id`, `folder_path` |
| `workflow_move_folder` | 移动工作流到目录(`workflow_id`, `folder_path` |
### 全局与其它
| 工具 | 用途 |
|------|------|
| `catalog_snapshot` | 一次返回 APIs / Workflows / Mocks / **workflow_batches** / MCP 配置及 SSH 树 |
| `mcp_tool_upsert` | 创建或更新 MCP 工具配置 |
### SSH 脚本管理
| 工具 | 用途 |
|------|------|
| `ssh_tree` | SSH 主机与脚本树 |
| `ssh_script_get` | 读取脚本详情 |
| `ssh_script_upsert` | 创建/更新内联 bash 脚本(`name`, `content`;创建需 `profile_id`,更新需 `script_id` |
| `ssh_script_run` | 远端执行脚本(`profile_id`, `script_id`, `password`;可选 `timeout_seconds` |
---
## 3. 工作流节点与连线约定
`workflow_upsert` / `workflow_patch_json` 中的 `definition` 与前端 Drawflow 导出结构一致:
```json
{
"nodes": [{ "id": "n1", "position": {"x": 0, "y": 0}, "data": { "type": "http", ... } }],
"edges": [{ "id": "e1", "source": "n1", "target": "n2", "label": "success", "data": { "branch": "success" } }],
"variables": {}
}
```
### 节点类型
| `data.type` | 说明 |
|-------------|------|
| `start` / `end` | 透传,无 HTTP |
| `http` | 发请求;可内联 `method/url/headers/body/query/path_params`,或 `api_id`;支持 `mock`、`expect` |
| `extract` | 从变量提取字段写入新变量 |
| `condition` | 条件分支:连线 `data.branch``true` / `false`(或 label `IF`/`ELSE` |
| `loop` | 循环体:从 loop 节点连出的 **BODY** 边进入子图,多轮执行后再走 **DONE** |
### 条件节点(`condition`
- `left_mode` / `right_mode``template`(默认,变量替换后比较)或 `json_path`(从 `left_source_var` 对应响应里按 `left_field` 取 JSON 路径值)。
- `op``==`、`!=`、`>`、`<`、`contains` 等。
- 出边:`edge.data.branch` 为 `true` / `false`(引擎也识别 label 中的 IF/ELSE
### 循环节点(`loop`
- **BODY 边**`edge.data.branch` 取 `body`、`loop`、`in`、`next`、`continue` 之一(或空字符串),目标节点构成循环体子图。
- **DONE 边**`branch` 为 `done`、`out`、`exit`、`end` 等,循环结束后继续主流程。
- 节点字段示例:
- `max_iterations`:最大轮数(默认 10
- `iteration_var`:每轮写入变量的下标名(默认 `loop_index`
- `while_left_mode` / `while_left` / `while_left_source_var` / `while_left_field` / `while_op` / `while_right_*`:每轮 BODY 执行完后判断是否继续下一轮(语义同 condition支持 `json_path`
### HTTP 出边分支
- 成功:`branch`: `success`
- 失败:`branch`: `failed`
---
## 4. AI 调用协议(推荐)
### 4.1 通用调用模板
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "tool_name",
"arguments": {}
}'
```
### 4.2 批量调用模板
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"stop_on_error": true,
"calls": [
{"tool": "tool_a", "arguments": {}},
{"tool": "tool_b", "arguments": {}}
]
}'
```
---
## 5. 常见 AI 任务示例
### 示例 AAI 自动创建接口
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "api_upsert",
"arguments": {
"name": "登录接口",
"method": "POST",
"url": "/api/login",
"headers": {"token": "{{token}}"},
"body": {"username": "admin", "password": "123456"},
"query": {},
"path_params": {},
"timeout_seconds": 10
}
}'
```
### 示例 BAI 自动创建 Mock 数据
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "mock_upsert",
"arguments": {
"name": "demo_user",
"data": {"uid": 1001, "token": "abc"}
}
}'
```
### 示例 CAI 自动创建 Workflow含条件分支
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "workflow_upsert",
"arguments": {
"name": "登录流程",
"definition": {
"nodes": [
{"id": "n1", "position": {"x": 120, "y": 120}, "data": {"type": "http", "api_id": 1, "save_as": "login_resp"}},
{"id": "n2", "position": {"x": 380, "y": 120}, "data": {"type": "extract", "source_var": "login_resp", "field": "json.token", "save_as": "token"}},
{"id": "n3", "position": {"x": 640, "y": 120}, "data": {"type": "condition", "left_mode": "json_path", "left_source_var": "login_resp", "left_field": "json.code", "op": "==", "right": "0"}}
],
"edges": [
{"id": "e1", "source": "n1", "target": "n2", "label": "success", "data": {"branch": "success"}},
{"id": "e2", "source": "n2", "target": "n3"},
{"id": "e3", "source": "n3", "target": "n4", "label": "IF", "data": {"branch": "true"}},
{"id": "e4", "source": "n3", "target": "n5", "label": "ELSE", "data": {"branch": "false"}}
],
"variables": {}
}
}
}'
```
### 示例 DAI 自动 Patch Workflow JSON
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "workflow_patch_json",
"arguments": {
"workflow_id": 1,
"patch": {
"variables": {"env": "test"}
}
}
}'
```
### 示例 EAI 执行工作流并分析结果
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "workflow_run",
"arguments": {
"workflow_id": 1,
"base_url": "http://127.0.0.1:8080",
"fail_fast": true
}
}'
```
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "workflow_analyze_last_run",
"arguments": {"workflow_id": 1}
}'
```
### 示例 FAI 批量编排(创建接口 → Mock → Patch → 执行)
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"stop_on_error": true,
"calls": [
{
"tool": "api_upsert",
"arguments": {
"name": "用户详情接口",
"method": "GET",
"url": "/api/user/{uid}",
"headers": {"token": "{{token}}"},
"query": {},
"path_params": {"uid": "{{uid}}"}
}
},
{
"tool": "mock_upsert",
"arguments": {
"name": "seed_user",
"data": {"uid": 1001, "token": "abc"}
}
},
{
"tool": "workflow_patch_json",
"arguments": {
"workflow_id": 1,
"patch": {"variables": {"uid": 1001, "token": "abc"}}
}
},
{
"tool": "workflow_run",
"arguments": {"workflow_id": 1, "base_url": "http://127.0.0.1:8080", "fail_fast": true}
}
]
}'
```
### 示例 G按目录分功能管理
```bash
curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"stop_on_error": true,
"calls": [
{"tool": "folder_ensure", "arguments": {"target": "apis", "path": "auth/login"}},
{"tool": "folder_ensure", "arguments": {"target": "workflows", "path": "auth/smoke"}},
{
"tool": "api_upsert",
"arguments": {
"name": "登录接口",
"folder_path": "auth/login",
"method": "POST",
"url": "/api/login",
"body": {"username": "admin", "password": "123456"}
}
},
{
"tool": "workflow_upsert",
"arguments": {
"name": "登录冒烟",
"folder_path": "auth/smoke",
"definition": {"nodes": [], "edges": [], "variables": {}}
}
}
]
}'
```
### 示例 H跑批工作流MCP 三步)
```bash
# 1) 创建草稿批跑任务
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "workflow_batch_create",
"arguments": {
"name": "nightly-smoke",
"base_url": "http://127.0.0.1:8080",
"fail_fast": false
}
}'
# 假设返回 data.id = 3
# 2) 绑定要执行的工作流 ID 列表
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sto-api-key>" \
-d '{
"tool": "workflow_batch_update",
"arguments": {
"batch_id": 3,
"workflow_ids": [1, 2, 5]
}
}'
# 3) 执行批跑
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "workflow_batch_run",
"arguments": {"batch_id": 3}
}'
# 4) 查询结果(含每次 workflow_run 的 run_id
curl -s -X POST http://127.0.0.1:8000/mcp/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "workflow_batch_get",
"arguments": {"batch_id": 3}
}'
```
也可用 `invoke-batch` 将 create + update + run 串在一次请求里(`stop_on_error: true` 时任一步失败即中断)。
---
## 6. Loki 环境变量
**Loki 相关环境变量**(后端 `app/services/loki.py`
| 变量 | 说明 |
|------|------|
| `GENERAL_LOKI_EXPLORE_URL``LOKI_EXPLORE_URL` | Grafana Explore 页基础 URL必填其一才生成链接 |
| `LOKI_DATASOURCE` | 数据源名,默认 `Loki` |
| `LOKI_ORG_ID` | 组织 ID默认 `1` |
| `LOKI_LABEL_SELECTOR` | 完整 LogQL 标签选择器;未设则用 `LOKI_SERVICE_LABEL` / `LOKI_SERVICE_VALUE` |
| `LOKI_TIME_PADDING_SECONDS` | 执行时间窗口前后扩展秒数,默认 `120` |
---
## 7. AI 调用策略建议
### 推荐执行顺序(单工作流调试)
1. `catalog_snapshot` 读取现状
2. `api_upsert` / `mock_upsert` 补全资源
3. `workflow_upsert``workflow_patch_json` 组装流程
4. `workflow_run``workflow_run_node` 执行
5. `workflow_analyze_last_run` / `workflow_get` 分析
6. 根据失败节点二次 patch + 重跑
### 推荐执行顺序(跑批)
1. `catalog_snapshot` 确认 `workflow_id` 列表
2. `workflow_batch_create`(草稿)
3. `workflow_batch_update`(写入 `workflow_ids`、`base_url`、`fail_fast`
4. `workflow_batch_run`
5. `workflow_batch_get` 汇总;排错用 `workflow_run_get` / `workflow_run_replay` / `workflow_run_loki_link`
### 失败处理策略
- `ok=false`:优先读取 `error`,只修最小必要字段后重试
- 工具不存在:先调用 `GET /mcp/tools` 刷新工具列表
- JSON 错误:确保 `arguments` 中对象字段是 JSON 对象(不是字符串)
- 运行失败:先看 `failed_nodes`,再做针对性 patch不要全量重建 workflow
- 批跑 `partial`:用 `workflow_batch_get` 里每条 `run``status` 定位失败工作流
---
## 8. 给 AI 的最小 Prompt可直接复用
```text
你是本平台的自动化编排 Agent。
目标:最小改动下让 workflow / 批跑 执行成功。
单工作流:
1) catalog_snapshot
2) 缺什么补什么api_upsert / mock_upsert / workflow_patch_json
3) workflow_run
4) workflow_analyze_last_run
5) 失败则只修失败节点相关配置后重试
跑批:
1) workflow_batch_create
2) workflow_batch_updateworkflow_ids
3) workflow_batch_run
4) workflow_batch_get 核对每条 run
约束:保持 JSON 可读、禁止无关改动、每步输出工具调用和结果摘要。
写操作必须带 sto- API Key。
```
---
## 9. 版本说明
- 文档对应:`app/main.py` 中 `MCP_TOOL_SPECS``/mcp/invoke` 实现
- 跑批、循环节点、`json_path` 条件、Loki 链接为当前仓库已上线能力
- 若后续新增工具,请同步更新 `/mcp/tools` 和本文档工具清单

357
docs/mcp_tools_for_ai.md Normal file
View File

@ -0,0 +1,357 @@
# MCP 工具能力说明AI 专用)
> **读者**Cursor / Codex / Claude 等通过 `quality-inspection-platform` MCP 工作的 Agent。
> **目的**:在调用 `tools/list` 之外,提供「何时用哪个工具、按什么顺序、参数怎么填」的固定上下文。
> **维护**:工具以 `app/main.py``MCP_TOOL_SPECS` 和平台 `GET /mcp/tools` 为准;增删工具时请同步更新本文档。
> **重要**:某些 MCP 客户端可能缓存、裁剪或延迟刷新工具列表;不要仅凭当前客户端可见工具反向删减本文档能力。
---
## 0. Agent 必读30 秒)
1. **先** `catalog_snapshot` 了解现有 apis / workflows / workflow_batches / mocks / folders / ssh_tree避免重复创建。
2. **写操作与执行**必须带 `sto-` API Key`Authorization: Bearer` 或桥接环境变量 `AI_TEST_API_KEY`)。
3. **接口**用 `api_upsert`**流程**用 `workflow_upsert``workflow_patch_json`**跑批**固定三步:`workflow_batch_create` → `workflow_batch_update``workflow_batch_run`
4. **排障**`workflow_run_get` 看 request/response → `workflow_run_loki_link` 查日志。
5. 如果当前客户端没有显示某个工具,先确认平台 `GET /mcp/tools` 是否包含该工具,再刷新或重启 MCP 客户端。
6. 人类可读安装与 curl 示例见 `docs/mcp_quickstart.md`;桥接安装见 `docs/mcp_install.md`
### 0.1 工具来源与客户端刷新
- 权威工具清单来自后端 `app/main.py``MCP_TOOL_SPECS`,平台通过 `GET /mcp/tools` 对外暴露。
- `mcp_bridge.py` 启动后会从 `AI_TEST_BASE_URL/mcp/tools` 拉取工具并转换为 MCP `tools/list`
- Codex / Cursor / Claude 等客户端可能在会话启动时缓存工具列表;平台新增工具后,通常需要重启 MCP 客户端或重启会话。
- 如果客户端只显示部分工具,不代表平台没有该能力。先用 `/mcp/tools` 或本文档核对,再决定是否降级。
- Agent 更新本文档时,应以源码和 `/mcp/tools` 为准,不以单个客户端当前暴露的工具子集为准。
---
## 1. 平台在做什么
| 层级 | 含义 | MCP 主要工具 |
|------|------|----------------|
| 接口库 | 单个 HTTP API 定义method、url、headers、body… | `api_upsert`、`catalog_snapshot` |
| 工作流 | 多节点编排HTTP / 条件 / 循环 / 提取) | `workflow_upsert`、`workflow_run` |
| 跑批 | 一次任务顺序执行多个工作流 | `workflow_batch_*` |
| 执行历史 | 每次运行的 request/response 快照 | `workflow_run_list`、`workflow_run_get` |
| Mock | 命名数据集,供节点 mock 使用 | `mock_upsert` |
**数据约定**`url` 只存**路径**(如 `/api/user/{id}`),环境域名放在执行时的 `base_url`
---
## 2. 鉴权
### 2.1 平台地址
MCP Bridge 通过环境变量访问平台:
| 环境变量 | 含义 | 示例 |
|----------|------|------|
| `AI_TEST_BASE_URL` | 质量检测平台后端地址 | `http://127.0.0.1:8000` |
| `AI_TEST_API_KEY` | `sto-` 开头的 API Key | `sto-...` |
Agent 通过 MCP 工具访问平台,不需要在业务项目中启动平台服务;平台进程由质量检测平台项目本身或共享服务负责。
### 2.2 调用鉴权
| 类型 | 工具示例 | 无 Key 时 |
|------|----------|-----------|
| 只读 | `catalog_snapshot`、`workflow_get`、`workflow_run_list` | 以 superadmin 读全库(不推荐生产暴露) |
| 写入 | `api_upsert`、`workflow_upsert`、`folder_ensure`… | **401** |
| 执行 | `workflow_run`、`workflow_batch_run`、`ssh_script_run`、`workflow_run_replay` | **401** |
环境变量 `MCP_REQUIRE_API_KEY=true` 时,**所有**工具(含 `GET /mcp/tools`)均需 Key。
写操作与执行类工具必须使用有效 Key。不要把 Key 写入文档、日志或对话输出;只允许放在 MCP 配置、环境变量或请求头中。
---
## 3. 按场景选工具(决策表)
| 用户意图 | 推荐工具链 |
|----------|------------|
| 我不知道项目里有什么 | `catalog_snapshot` |
| 客户端看不到某个工具 | 查 `GET /mcp/tools` → 重启 MCP 客户端 / 会话 → 再调用 |
| 从 Apifox/文档批量建接口 | `folder_ensure` → 多次 `api_upsert`(或 `invoke-batch` |
| 新建/改流程图 | `workflow_validate`(可选)→ `workflow_upsert` |
| 只改流程里某几个字段 | `workflow_patch_json` |
| 跑一条流程 | `workflow_run``base_url` + `workflow_id` |
| 只调试一个节点 | `workflow_run_node` |
| 版本/回归一次跑多条流程 | `workflow_batch_create``workflow_batch_update``workflow_batch_run``workflow_batch_get` |
| 看某次执行详情 | `workflow_run_get``run_id` |
| 看历史列表 | `workflow_run_list``workflow_id` / `batch_id` |
| 失败后续跑 | `workflow_run_replay``run_id` |
| 查服务端日志 | `workflow_run_loki_link``run_id` + `node_id` |
| 看节点上次结果 | `workflow_node_status``workflow_get` |
| 快速看上次成败统计 | `workflow_analyze_last_run` |
| 准备 Mock 数据 | `mock_upsert` |
| 运维脚本 | `ssh_tree``ssh_script_upsert``ssh_script_run` |
---
## 4. 工具清单(按分类)
### 4.1 发现与目录
#### `catalog_snapshot`
- **用途**:一次拉取 apis、workflows、mocks、**workflow_batches**、folders、ssh_tree。
- **参数**:无。
- **何时用**:任务开始、批量导入前、避免重复 `api_upsert`
#### `folder_ensure`
- **用途**:登记 apis 或 workflows 目录(空目录也可)。
- **参数**`target``apis` | `workflows``path`:如 `auth/login`(不要前导 `/`)。
- **需 Key**:是。
#### `folder_list`
- **用途**:列出已登记目录。
- **参数**:可选 `target`
#### `api_move_folder` / `workflow_move_folder`
- **用途**移动资源到目录workflow 目标目录须已 `folder_ensure`
---
### 4.2 接口API
#### `api_upsert`
- **用途**:创建或更新接口定义。
- **必填**`name`、`method`、`url`。
- **常用可选**`api_id`(更新)、`folder_path`、`headers`、`body`、`query`、`path_params`、`timeout_seconds`。
- **需 Key**:是。
- **示例**
```json
{
"tool": "api_upsert",
"arguments": {
"name": "用户登录",
"folder_path": "auth",
"method": "POST",
"url": "/api/login",
"headers": { "Content-Type": "application/json" },
"body": { "username": "admin", "password": "{{password}}" },
"query": {},
"path_params": {}
}
}
```
---
### 4.3 工作流Workflow
#### `workflow_upsert`
- **用途**:创建/更新完整 `definition`Drawflow JSON
- **必填**`name`、`definition``nodes`、`edges`、`variables`)。
- **需 Key**:是。
- **注意**:非空 `folder_path` 须先 `folder_ensure`workflows
#### `workflow_get`
- **用途**:读取流程及各节点 `last_run`
#### `workflow_patch_json`
- **用途**:对 `definition` / `variables` 等深度合并 patch小改动优先于全量 upsert。
#### `workflow_validate`
- **用途**:检查节点 id、边是否引用合法**upsert 前**建议调用。
#### `workflow_move_folder`
- **用途**:移动工作流到已登记目录。
#### `workflow_node_status`
- **用途**:单节点最近 `last_run`
#### `workflow_analyze_last_run`
- **用途**:上次执行汇总(成功/失败节点 id细节不足时再 `workflow_get`
---
### 4.4 执行
#### `workflow_run`
- **用途**:执行整条工作流。
- **必填**`workflow_id`。
- **可选**`base_url`(环境根地址)、`fail_fast`(默认 true
- **需 Key**:是。
- **返回**`status`、`variables`、`results[]`(含每节点 request/response、`run_id`。
#### `workflow_run_node`
- **用途**:只跑一个节点(调试参数)。
- **必填**`workflow_id`、`node_id`。
- **需 Key**:是。
#### `workflow_run_replay`
- **用途**:按历史 run 内保存的 definition 快照重放。
- **必填**`run_id`。
- **需 Key**:是。
---
### 4.5 跑批Batch
| 步骤 | 工具 | 说明 |
|------|------|------|
| 1 | `workflow_batch_create` | 建草稿,可带 `name`、`base_url`、`fail_fast` |
| 2 | `workflow_batch_update` | **必填** `batch_id`;设置 `workflow_ids: [1,2,3]` |
| 3 | `workflow_batch_run` | 执行,仅 `draft` 可跑 |
| 4 | `workflow_batch_get` | 查看结果及关联 `run_id` |
辅助:`workflow_batch_list``status` 过滤draft|running|success|failed|partial
**需 Key**create/update/run 需 Keyget/list 只读。
---
### 4.6 执行历史与日志
#### `workflow_run_list`
- **参数**:可选 `workflow_id`、`batch_id`、`limit`、`after_id`。
- **返回**`items[]` 摘要(无完整 body 时用 `workflow_run_get`)。
#### `workflow_run_get`
- **参数**`run_id`。
- **返回**:含 `payload``results`、`variables`、`workflow_snapshot`)。
#### `workflow_run_loki_link`
- **参数**`run_id`、`node_id`HTTP 节点)。
- **返回**`explore_url`、`logql`、时间窗;未配 `GENERAL_LOKI_EXPLORE_URL``enabled=false`
---
### 4.7 Mock 与其它
#### `mock_upsert`
- **用途**:按 `name` 创建/更新 Mock 数据集(`data` 对象)。
- **需 Key**:是。
#### `mcp_tool_upsert`
- **用途**:平台内「自定义 MCP 工具配置」表,**不是**本桥接工具列表本身。
#### SSH 系列
| 工具 | 用途 |
|------|------|
| `ssh_tree` | 主机与脚本树 |
| `ssh_script_get` | 读脚本内容 |
| `ssh_script_upsert` | 创建/更新内联 bash`profile_id`+`name`+`content` |
| `ssh_script_run` | 远端执行(**需 password**,需 Key |
---
## 5. 工作流 definition 约定(简版)
```json
{
"nodes": [
{ "id": "n1", "position": {"x": 0, "y": 0}, "data": { "type": "http", "api_id": 1, "save_as": "resp1" } },
{ "id": "n2", "data": { "type": "extract", "source_var": "resp1", "field": "json.token", "save_as": "token" } }
],
"edges": [
{ "id": "e1", "source": "n1", "target": "n2", "data": { "branch": "success" } }
],
"variables": {}
}
```
| `data.type` | 说明 |
|-------------|------|
| `start` / `end` | 透传 |
| `http` | 引用 `api_id` 或内联 method/url/headers/body/query/path_params支持 mock、expect |
| `extract` | 从 `source_var``field` 写入 `save_as` |
| `condition` | 出边 `branch`: `true` / `false`;支持 `left_mode=json_path` |
| `loop` | BODY 边进入子图;`while_*` 控制下一轮DONE 边退出 |
变量替换:字符串中的 `{{token}}``variables` 注入。工作流级 `default_headers` 与节点 headers 合并,**节点覆盖同名 key**。
---
## 6. 调用协议
- **发现**`GET /mcp/tools` → `{ tools, require_api_key, ai_guide }`
- **单次**`POST /mcp/invoke` → `{ ok, tool, data, error }`
- **批量**`POST /mcp/invoke-batch` → `{ results: [...] }``stop_on_error: true` 时任一步失败即停
`ok=false` 时读 `error`,修正参数后重试;不要臆造未在 `tools/list` 中出现的工具名。
---
## 7. 推荐 Recipe
### R1新建接口并跑通单接口流程
```
catalog_snapshot → folder_ensure(apis) → api_upsert
→ workflow_upsert → workflow_run → workflow_analyze_last_run
```
### R2Apifox 迁移一批接口
```
catalog_snapshot → folder_ensure(apis, 模块路径)
→ invoke-batch: N × api_upsert → 核对 catalog
```
### R3版本回归跑批
```
workflow_batch_create → workflow_batch_update(workflow_ids)
→ workflow_batch_run → workflow_batch_get
→ 对失败项 workflow_run_get + workflow_run_loki_link
```
### R4失败节点最小修复
```
workflow_analyze_last_run → workflow_patch_json(只改变量/单节点 data)
→ workflow_run → 仍失败则 workflow_run_get 看 payload
```
---
## 8. 常见错误
| error / 现象 | 处理 |
|--------------|------|
| 401 MCP requires API Key | 配置 `AI_TEST_API_KEY` 或请求头 Bearer |
| workflow folder not registered | 先 `folder_ensure` workflows |
| 仅 draft 批次可执行 | 已对 running/success 的 batch 再 run |
| unknown mcp tool | `GET /mcp/tools` 刷新名称 |
| Loki enabled=false | 配置 `GENERAL_LOKI_EXPLORE_URL` |
| 重复接口 | 同 method+url 视为同一接口,更新时传 `api_id` |
---
## 9. 文档索引
| 文件 | 受众 |
|------|------|
| **本文** `docs/mcp_tools_for_ai.md` | **AI Agent首选** |
| `docs/mcp_quickstart.md` | 人类 + curl 示例 |
| `docs/mcp_install.md` | MCP 桥接安装 |
| 项目根 `AGENTS.md` | Codex 自动加载的短指引 |
---
*文档版本:与仓库 `MCP_TOOL_SPECS` 同步(含 workflow_run_* / workflow_validate / 执行类鉴权)。*

View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000

12
frontend-admin/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>质量检测平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1974
frontend-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "ai-auto-test-admin",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.6",
"@element-plus/icons-vue": "^2.3.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/node-toolbar": "^1.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"element-plus": "^2.11.5",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.3.5"
}
}

View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@ -0,0 +1,154 @@
import { clearSession, getAccessToken } from "../auth/session";
const explicitBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
export function buildUrl(path) {
if (/^https?:\/\//.test(path)) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (explicitBaseUrl) {
return `${explicitBaseUrl}${normalizedPath}`;
}
return normalizedPath;
}
export function buildWebSocketUrl(path, params = {}) {
const httpUrl = buildUrl(path);
const url = new URL(httpUrl, window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
export async function apiGet(path, init = {}) {
return request(path, {
method: "GET",
headers: {
Accept: "application/json",
...(init.headers || {}),
},
...init,
});
}
export async function apiPost(path, body, init = {}) {
return request(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init.headers || {}),
},
body: JSON.stringify(body),
...init,
});
}
export async function apiPut(path, body, init = {}) {
return request(path, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init.headers || {}),
},
body: JSON.stringify(body),
...init,
});
}
export async function apiDelete(path, init = {}) {
return request(path, {
method: "DELETE",
headers: {
Accept: "application/json",
...(init.headers || {}),
},
...init,
});
}
export async function apiUploadForm(path, formData, init = {}) {
const token = getAccessToken();
const headers = { ...(init.headers || {}) };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(buildUrl(path), {
method: "POST",
headers,
body: formData,
...init,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const rawText = await response.text();
if (rawText) {
try {
const payload = JSON.parse(rawText);
errorMessage = payload.detail || JSON.stringify(payload);
} catch {
errorMessage = rawText;
}
}
} catch {
errorMessage = `Request failed: ${response.status}`;
}
if (response.status === 401) {
clearSession();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new Error(errorMessage);
}
return response.json();
}
async function request(path, init = {}) {
const token = getAccessToken();
const headers = { ...(init.headers || {}) };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(buildUrl(path), {
...init,
headers,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const rawText = await response.text();
if (rawText) {
try {
const payload = JSON.parse(rawText);
errorMessage = payload.detail || JSON.stringify(payload);
} catch {
errorMessage = rawText;
}
}
} catch {
errorMessage = `Request failed: ${response.status}`;
}
if (response.status === 401) {
clearSession();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new Error(errorMessage);
}
return response.json();
}

View File

@ -0,0 +1,42 @@
import { reactive } from "vue";
const TOKEN_KEY = "ai-auto-test-token";
const USER_KEY = "ai-auto-test-user";
function loadUser() {
const raw = window.localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export const authState = reactive({
token: window.localStorage.getItem(TOKEN_KEY) || "",
user: loadUser(),
});
export function getAccessToken() {
return authState.token || "";
}
export function setSession(token, user) {
authState.token = token || "";
authState.user = user || null;
if (token) {
window.localStorage.setItem(TOKEN_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (user) {
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
window.localStorage.removeItem(USER_KEY);
}
}
export function clearSession() {
setSession("", null);
}

View File

@ -0,0 +1,200 @@
<script setup>
import { ElMessage } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import { apiPost, apiPut } from "../api/http";
const props = defineProps({
modelValue: { type: Boolean, default: false },
target: { type: String, required: true },
mode: { type: String, default: "create" },
editPath: { type: String, default: "" },
parentPath: { type: String, default: "" },
folderOptions: { type: Array, default: () => [] },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "success"]);
const saving = ref(false);
const formState = reactive({
parent_path: "",
folder_name: "",
});
const isEditMode = computed(() => props.mode === "edit");
const dialogTitle = computed(() => {
if (props.title) return props.title;
return isEditMode.value ? "编辑目录" : "新建目录";
});
function normalizeFolderPath(value) {
const text = String(value || "")
.trim()
.replace(/\\/g, "/");
if (!text || text === "/") return "";
return text
.split("/")
.map((part) => part.trim())
.filter(Boolean)
.join("/");
}
function splitFolderPath(path) {
const normalized = normalizeFolderPath(path);
if (!normalized) {
return { parent_path: "", folder_name: "" };
}
const parts = normalized.split("/").filter(Boolean);
if (parts.length === 1) {
return { parent_path: "", folder_name: parts[0] };
}
return {
parent_path: parts.slice(0, -1).join("/"),
folder_name: parts[parts.length - 1],
};
}
function isInvalidParentOption(path) {
const current = normalizeFolderPath(props.editPath);
const candidate = normalizeFolderPath(path);
if (!current || !candidate) return false;
if (candidate === current) return true;
return candidate.startsWith(`${current}/`);
}
const resolvedPath = computed(() => {
const parent = normalizeFolderPath(formState.parent_path);
const name = normalizeFolderPath(formState.folder_name);
if (!name) return parent;
return parent ? `${parent}/${name}` : name;
});
const parentOptions = computed(() => {
const paths = new Set(
(props.folderOptions || [])
.map((item) => normalizeFolderPath(item))
.filter(Boolean)
);
if (isEditMode.value) {
const current = normalizeFolderPath(props.editPath);
if (current) paths.add(current);
}
return [
{ label: "根目录", value: "" },
...[...paths]
.filter((path) => !isInvalidParentOption(path))
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((path) => ({ label: path, value: path })),
];
});
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
if (isEditMode.value) {
const parts = splitFolderPath(props.editPath);
formState.parent_path = parts.parent_path;
formState.folder_name = parts.folder_name;
return;
}
formState.parent_path = normalizeFolderPath(props.parentPath);
formState.folder_name = "";
}
);
async function submitForm() {
const path = resolvedPath.value;
if (!path) {
ElMessage.warning("请输入目录名称");
return;
}
if (isEditMode.value) {
const fromPath = normalizeFolderPath(props.editPath);
if (!fromPath) {
ElMessage.warning("缺少原目录路径");
return;
}
if (path === fromPath) {
emit("update:modelValue", false);
return;
}
saving.value = true;
try {
const result = await apiPut("/api/folders/move", {
target: props.target,
from_path: fromPath,
to_path: path,
});
const movedResources = Number(result.moved_resources || 0);
ElMessage.success(`目录已更新:${fromPath}${path}(同步 ${movedResources} 项资源)`);
emit("update:modelValue", false);
emit("success", path);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "目录更新失败");
} finally {
saving.value = false;
}
return;
}
saving.value = true;
try {
await apiPost("/api/folders/ensure", { target: props.target, path });
ElMessage.success(`目录已创建:${path}`);
emit("update:modelValue", false);
emit("success", path);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "目录创建失败");
} finally {
saving.value = false;
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="520px"
destroy-on-close
@close="emit('update:modelValue', false)"
>
<el-form label-position="top">
<el-form-item v-if="isEditMode" label="原路径">
<el-input :model-value="normalizeFolderPath(editPath)" readonly />
</el-form-item>
<el-form-item label="上级目录">
<el-select v-model="formState.parent_path" class="full-width" filterable>
<el-option
v-for="item in parentOptions"
:key="item.value || 'root'"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="目录名称">
<el-input
v-model="formState.folder_name"
placeholder="可填单级名称;移动上级目录时在此修改"
@keyup.enter="submitForm"
/>
</el-form-item>
<el-form-item :label="isEditMode ? '新路径' : '完整路径'">
<el-input :model-value="resolvedPath || '/根目录'" readonly />
</el-form-item>
<p v-if="isEditMode" class="folder-form__hint">
保存后会同步移动该目录下的子目录以及目录内的{{ target === "workflows" ? "工作流" : "接口" }}
</p>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
{{ isEditMode ? "保存" : "创建" }}
</el-button>
</template>
</el-dialog>
</template>

View File

@ -0,0 +1,258 @@
<script setup>
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, lintGutter } from "@codemirror/lint";
import { EditorState } from "@codemirror/state";
import { EditorView, placeholder } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { CircleCheck, WarningFilled } from "@element-plus/icons-vue";
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "{}",
},
minHeight: {
type: Number,
default: 140,
},
placeholder: {
type: String,
default: "{}",
},
readOnly: {
type: Boolean,
default: false,
},
requireObject: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue", "validity"]);
const hostRef = shallowRef(null);
const viewRef = shallowRef(null);
const statusMessage = shallowRef("");
const isValid = shallowRef(true);
const statusType = computed(() => (isValid.value ? "success" : "error"));
function inspectJson(text) {
const trimmed = String(text ?? "").trim();
if (!trimmed) {
return { valid: true, message: "JSON 格式正确(空对象将保存为 {}" };
}
try {
const parsed = JSON.parse(trimmed);
if (props.requireObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
return { valid: false, message: "必须是 JSON 对象,请使用花括号 {}" };
}
return { valid: true, message: "JSON 格式正确" };
} catch (error) {
return {
valid: false,
message: error instanceof Error ? error.message : "JSON 解析失败",
};
}
}
function syncValidity(text) {
const result = inspectJson(text);
isValid.value = result.valid;
statusMessage.value = result.message;
emit("validity", result.valid);
return result;
}
function jsonObjectLinter() {
if (!props.requireObject) {
return () => [];
}
return (view) => {
const text = view.state.doc.toString().trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return [
{
from: 0,
to: view.state.doc.length,
severity: "error",
message: "必须是 JSON 对象(使用 {}",
},
];
}
} catch {
return [];
}
return [];
};
}
function buildTheme() {
return EditorView.theme({
"&": {
fontSize: "13px",
backgroundColor: "#f6f8fc",
border: "1px solid #d5e2f2",
borderRadius: "10px",
overflow: "hidden",
},
"&.cm-focused": {
outline: "none",
borderColor: "#409eff",
boxShadow: "0 0 0 1px rgba(64, 158, 255, 0.18)",
},
".cm-scroller": {
minHeight: `${props.minHeight}px`,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
lineHeight: "1.55",
},
".cm-content": {
padding: "10px 2px",
caretColor: "#409eff",
},
".cm-gutters": {
backgroundColor: "#eef3fa",
color: "#8a9ab1",
borderRight: "1px solid #d5e2f2",
},
".cm-activeLineGutter": {
backgroundColor: "#e4edf8",
},
".cm-activeLine": {
backgroundColor: "rgba(64, 158, 255, 0.06)",
},
".cm-lintRange-error": {
backgroundImage: "none",
backgroundColor: "rgba(245, 108, 108, 0.18)",
},
".cm-tooltip-lint": {
backgroundColor: "#fff5f5",
border: "1px solid #fbc4c4",
color: "#c45656",
},
});
}
function createEditorState(doc) {
return EditorState.create({
doc,
extensions: [
basicSetup,
json(),
buildTheme(),
EditorView.lineWrapping,
placeholder(props.placeholder),
lintGutter(),
linter(jsonParseLinter()),
linter(jsonObjectLinter()),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const text = update.state.doc.toString();
emit("update:modelValue", text);
syncValidity(text);
}),
EditorState.readOnly.of(props.readOnly),
],
});
}
function mountEditor() {
if (!hostRef.value) return;
const initialDoc = props.modelValue || "";
syncValidity(initialDoc);
viewRef.value = new EditorView({
state: createEditorState(initialDoc),
parent: hostRef.value,
});
}
function replaceDocument(text) {
const view = viewRef.value;
if (!view) return;
const current = view.state.doc.toString();
if (text === current) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: text ?? "" },
});
syncValidity(text);
}
onMounted(() => {
mountEditor();
});
onBeforeUnmount(() => {
viewRef.value?.destroy();
viewRef.value = null;
});
watch(
() => props.modelValue,
(value) => {
replaceDocument(value ?? "");
}
);
watch(
() => props.minHeight,
() => {
viewRef.value?.requestMeasure();
}
);
</script>
<template>
<div class="json-code-editor" :class="{ 'is-invalid': !isValid }">
<div ref="hostRef" class="json-code-editor__host" />
<div class="json-code-editor__status" :class="`is-${statusType}`">
<el-icon v-if="isValid"><CircleCheck /></el-icon>
<el-icon v-else><WarningFilled /></el-icon>
<span>{{ statusMessage }}</span>
</div>
</div>
</template>
<style scoped>
.json-code-editor {
width: 100%;
}
.json-code-editor__host :deep(.cm-editor) {
width: 100%;
}
.json-code-editor__status {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
.json-code-editor__status.is-success {
color: #3f9f6d;
}
.json-code-editor__status.is-error {
color: #d03050;
}
.json-code-editor.is-invalid :deep(.cm-editor) {
border-color: #f5a8b8;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup>
import { computed } from "vue";
import JsonCodeEditor from "./JsonCodeEditor.vue";
const props = defineProps({
modelValue: {
type: String,
default: "{}",
},
label: {
type: String,
default: "JSON",
},
rows: {
type: Number,
default: 5,
},
placeholder: {
type: String,
default: "{}",
},
hint: {
type: String,
default: "",
},
});
defineEmits(["update:modelValue"]);
const editorMinHeight = computed(() => Math.max(120, props.rows * 22 + 24));
</script>
<template>
<div class="json-field">
<div class="json-field__label">
<span>{{ label }}</span>
<el-tag size="small" type="info" effect="plain" class="json-field__badge">JSON</el-tag>
</div>
<JsonCodeEditor
:model-value="modelValue"
:min-height="editorMinHeight"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<p v-if="hint" class="json-field__hint">{{ hint }}</p>
</div>
</template>

View File

@ -0,0 +1,74 @@
<script setup>
import { Delete, Plus } from "@element-plus/icons-vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [{ key: "", value: "" }],
},
keyPlaceholder: {
type: String,
default: "Key",
},
valuePlaceholder: {
type: String,
default: "Value",
},
addLabel: {
type: String,
default: "新增一行",
},
});
const emit = defineEmits(["update:modelValue"]);
function updateRows(nextRows) {
emit("update:modelValue", nextRows);
}
function updateRow(index, field, value) {
const nextRows = props.modelValue.map((row, rowIndex) =>
rowIndex === index ? { ...row, [field]: value } : row
);
updateRows(nextRows);
}
function addRow() {
updateRows([...props.modelValue, { key: "", value: "" }]);
}
function removeRow(index) {
const nextRows = props.modelValue.slice();
nextRows.splice(index, 1);
if (!nextRows.length) {
nextRows.push({ key: "", value: "" });
}
updateRows(nextRows);
}
</script>
<template>
<div class="kv-editor">
<div class="kv-editor__head">
<span class="kv-editor__col">Key</span>
<span class="kv-editor__col">Value</span>
<span class="kv-editor__action" aria-hidden="true" />
</div>
<div class="kv-editor__list">
<div v-for="(row, index) in modelValue" :key="index" class="kv-editor__row">
<el-input :model-value="row.key" :placeholder="keyPlaceholder" @update:model-value="updateRow(index, 'key', $event)" />
<el-input
:model-value="row.value"
:placeholder="valuePlaceholder"
@update:model-value="updateRow(index, 'value', $event)"
/>
<el-button circle :icon="Delete" text type="danger" @click="removeRow(index)" />
</div>
</div>
<el-button :icon="Plus" text type="primary" class="kv-editor__add" @click="addRow">
{{ addLabel }}
</el-button>
</div>
</template>

View File

@ -0,0 +1,179 @@
<script setup>
import { CopyDocument, Delete, Plus, Refresh, View, Hide } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue";
import { apiDelete, apiGet, apiPost } from "../api/http";
const loading = ref(false);
const creating = ref(false);
const apiKeys = ref([]);
const expiresAt = ref("");
const createdKeyVisible = ref(false);
const createdKeyValue = ref("");
function maskApiKey(row) {
return row.masked_key || `${row.key_prefix}••••••••${row.key_hint}`;
}
function formatExpires(value) {
if (!value) return "永久有效";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN");
}
async function loadApiKeys() {
loading.value = true;
try {
apiKeys.value = await apiGet("/api/me/api-keys");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "API Key 加载失败");
} finally {
loading.value = false;
}
}
async function createApiKey() {
creating.value = true;
try {
const payload = expiresAt.value ? { expires_at: expiresAt.value } : { expires_at: null };
const row = await apiPost("/api/me/api-keys", payload);
createdKeyValue.value = row.api_key || "";
createdKeyVisible.value = false;
expiresAt.value = "";
await loadApiKeys();
await ElMessageBox.alert(
"请立即复制保存,关闭后将无法再次查看完整 Key。",
"API Key 已创建",
{
confirmButtonText: "我已复制",
type: "success",
}
);
if (createdKeyValue.value) {
await copyText(createdKeyValue.value, "已复制完整 API Key");
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "创建失败");
} finally {
creating.value = false;
}
}
async function revokeApiKey(row) {
try {
await ElMessageBox.confirm(`确认吊销 API Key「${maskApiKey(row)}」吗?`, "吊销确认", {
type: "warning",
confirmButtonText: "吊销",
cancelButtonText: "取消",
});
await apiDelete(`/api/me/api-keys/${row.id}`);
ElMessage.success("API Key 已吊销");
await loadApiKeys();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "吊销失败");
}
}
}
async function copyText(text, successMessage = "已复制") {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
ElMessage.success(successMessage);
} catch {
ElMessage.error("复制失败,请手动复制");
}
}
function toggleCreatedKeyVisible() {
createdKeyVisible.value = !createdKeyVisible.value;
}
function displayCreatedKey() {
if (!createdKeyValue.value) return "—";
if (createdKeyVisible.value) return createdKeyValue.value;
const key = createdKeyValue.value;
if (key.length <= 12) return `${key.slice(0, 7)}••••`;
return `${key.slice(0, 8)}••••••••${key.slice(-4)}`;
}
onMounted(() => {
loadApiKeys();
});
</script>
<template>
<div class="profile-api-key-panel">
<el-alert
type="info"
:closable="false"
show-icon
title="MCP 连接说明"
description="在 Cursor / MCP 配置中设置环境变量 AI_TEST_API_KEY或在请求头携带 Authorization: Bearer <api_key>。Key 以 sto- 开头。"
/>
<section class="profile-api-key-panel__create">
<div class="profile-api-key-panel__create-head">
<div>
<strong>新建 API Key</strong>
<p>不填写有效期表示永久有效</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadApiKeys">刷新</el-button>
</div>
<div class="profile-api-key-panel__create-form">
<el-date-picker
v-model="expiresAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="有效期(留空=永久)"
clearable
class="profile-api-key-panel__expires"
/>
<el-button type="primary" :icon="Plus" :loading="creating" @click="createApiKey">生成 API Key</el-button>
</div>
</section>
<section v-if="createdKeyValue" class="profile-api-key-panel__latest">
<div class="profile-api-key-panel__latest-head">
<strong>最近生成的 Key</strong>
<div class="toolbar-actions">
<el-button text :icon="createdKeyVisible ? Hide : View" @click="toggleCreatedKeyVisible">
{{ createdKeyVisible ? "隐藏" : "显示" }}
</el-button>
<el-button text type="primary" :icon="CopyDocument" @click="copyText(createdKeyValue, '已复制完整 API Key')">
复制
</el-button>
</div>
</div>
<code class="profile-api-key-panel__key-text">{{ displayCreatedKey() }}</code>
</section>
<el-table :data="apiKeys" v-loading="loading" empty-text="还没有 API Key">
<el-table-column label="API Key" min-width="220">
<template #default="{ row }">
<code class="profile-api-key-panel__masked">{{ maskApiKey(row) }}</code>
</template>
</el-table-column>
<el-table-column label="有效期" min-width="180">
<template #default="{ row }">
{{ formatExpires(row.expires_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" effect="plain">
{{ row.is_active ? "有效" : "无效" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180" prop="created_at" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button text type="danger" :icon="Delete" @click="revokeApiKey(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>

View File

@ -0,0 +1,127 @@
<script setup>
import { computed, ref } from "vue";
import JsonField from "./JsonField.vue";
import KeyValueEditor from "./KeyValueEditor.vue";
const props = defineProps({
overlay: {
type: Boolean,
default: false,
},
});
const headerRows = defineModel("headerRows", {
type: Array,
default: () => [{ key: "", value: "" }],
});
const bodyText = defineModel("bodyText", {
type: String,
default: "{}",
});
const queryText = defineModel("queryText", {
type: String,
default: "{}",
});
const pathParamRows = defineModel("pathParamRows", {
type: Array,
default: () => [{ key: "", value: "" }],
});
const activeTab = ref("path");
const suffix = computed(() => (props.overlay ? "(覆盖)" : ""));
</script>
<template>
<div class="request-params-tabs">
<el-tabs v-model="activeTab" class="request-params-tabs__inner">
<el-tab-pane name="path">
<template #label>路径参数</template>
<p class="request-params-tabs__hint">
替换 URL 路径里的占位符例如 URL <code>/api/users/{id}</code> 可配置
<code>id=1001</code>最终请求 <code>/api/users/1001</code>
</p>
<KeyValueEditor
v-model="pathParamRows"
key-placeholder="占位符名(如 id"
value-placeholder="替换值(如 1001 或 {{user_id}}"
:add-label="`新增路径参数${suffix}`"
/>
</el-tab-pane>
<el-tab-pane name="query">
<template #label>查询参数</template>
<p class="request-params-tabs__hint">
拼接到 URL 问号后面形成 <code>?key=value</code>常用于分页筛选例如
<code>page=1</code><code>status=active</code>
</p>
<JsonField
v-model="queryText"
:label="`Query${suffix}`"
:rows="overlay ? 6 : 7"
placeholder="{}"
/>
</el-tab-pane>
<el-tab-pane name="headers">
<template #label>请求头</template>
<p class="request-params-tabs__hint">
HTTP 请求头用于鉴权内容类型等例如 <code>Authorization</code><code>Content-Type</code>
</p>
<KeyValueEditor
v-model="headerRows"
key-placeholder="Header Key"
value-placeholder="Header Value"
:add-label="`新增 Header${suffix}`"
/>
</el-tab-pane>
<el-tab-pane name="body">
<template #label>请求体</template>
<p class="request-params-tabs__hint">
请求体 JSON <code>POST</code> / <code>PUT</code> / <code>PATCH</code> / <code>DELETE</code>
时作为 <code>application/json</code> 发送<code>GET</code> 一般不使用
</p>
<JsonField
v-model="bodyText"
:label="`Body${suffix}`"
:rows="overlay ? 6 : 7"
placeholder="{}"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.request-params-tabs {
width: 100%;
}
.request-params-tabs__inner :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.request-params-tabs__hint {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f7faff;
border: 1px solid #e8eef6;
color: #6e7f96;
font-size: 12px;
line-height: 1.7;
}
.request-params-tabs__hint code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 11px;
color: #31517e;
background: #fff;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid #e4ebf5;
}
</style>

View File

@ -0,0 +1,14 @@
<script setup>
import { floatingWindows } from "../utils/sshScriptRunner";
import SshFloatingTerminalWindow from "./SshFloatingTerminalWindow.vue";
</script>
<template>
<teleport to="body">
<SshFloatingTerminalWindow
v-for="item in floatingWindows"
:key="item.sessionId"
:window-state="item"
/>
</teleport>
</template>

View File

@ -0,0 +1,319 @@
<script setup>
import { Close, Minus, Rank, SwitchButton } from "@element-plus/icons-vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
bringFloatingToFront,
closeFloatingWindow,
fitSessionTerminal,
FLOAT_WINDOW_DEFAULT_SIZE,
FLOAT_WINDOW_MIN_SIZE,
getSession,
registerTerminalContainer,
stopSession,
toggleFloatingMinimize,
updateFloatingPosition,
updateFloatingSize,
} from "../utils/sshScriptRunner";
const props = defineProps({
windowState: {
type: Object,
required: true,
},
});
const dragging = ref(false);
const resizing = ref(false);
const resizeAxis = ref("");
const dragOffset = ref({ x: 0, y: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0, posX: 0, posY: 0 });
const windowRef = ref(null);
let resizeObserver = null;
let fitFrame = 0;
const session = computed(() => getSession(props.windowState.sessionId));
const windowSize = computed(() => {
const size = props.windowState.size;
if (size?.width && size?.height) return size;
return { ...FLOAT_WINDOW_DEFAULT_SIZE };
});
const statusTagType = computed(() => {
const status = session.value?.status;
if (status === "running" || status === "connecting") return "success";
if (status === "failed") return "danger";
if (status === "success") return "info";
return "warning";
});
const statusLabel = computed(() => {
const status = session.value?.status;
if (status === "running" || status === "connecting") return "运行中";
if (status === "success") return "完成";
if (status === "failed") return "失败";
return "已停止";
});
const canStop = computed(
() => session.value && (session.value.status === "running" || session.value.status === "connecting")
);
const windowStyle = computed(() => {
const style = {
left: `${props.windowState.position.x}px`,
top: `${props.windowState.position.y}px`,
zIndex: props.windowState.zIndex,
};
if (!props.windowState.minimized) {
style.width = `${windowSize.value.width}px`;
style.height = `${windowSize.value.height}px`;
}
return style;
});
function scheduleFitTerminal() {
if (fitFrame) cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
fitFrame = 0;
fitSessionTerminal(props.windowState.sessionId);
});
}
function handleFocus() {
bringFloatingToFront(props.windowState.sessionId);
}
function handleClose() {
closeFloatingWindow(props.windowState.sessionId);
}
function handleMinimize() {
toggleFloatingMinimize(props.windowState.sessionId);
}
function handleStop() {
if (session.value) {
stopSession(session.value);
}
}
function clampPosition(x, y, width, height) {
const maxX = Math.max(8, window.innerWidth - width - 8);
const maxY = Math.max(8, window.innerHeight - height - 8);
return {
x: Math.min(Math.max(8, x), maxX),
y: Math.min(Math.max(8, y), maxY),
};
}
function onHeaderMouseDown(event) {
if (event.button !== 0 || resizing.value) return;
handleFocus();
const rect = windowRef.value?.getBoundingClientRect();
if (!rect) return;
dragging.value = true;
dragOffset.value = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
}
function onDragMove(event) {
if (!dragging.value) return;
const width = windowRef.value?.offsetWidth || windowSize.value.width;
const height = windowRef.value?.offsetHeight || windowSize.value.height;
const next = clampPosition(event.clientX - dragOffset.value.x, event.clientY - dragOffset.value.y, width, height);
updateFloatingPosition(props.windowState.sessionId, next);
}
function onDragEnd() {
dragging.value = false;
document.removeEventListener("mousemove", onDragMove);
document.removeEventListener("mouseup", onDragEnd);
}
function onResizeMouseDown(event, axis) {
if (event.button !== 0 || props.windowState.minimized) return;
event.preventDefault();
event.stopPropagation();
handleFocus();
resizing.value = true;
resizeAxis.value = axis;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: windowSize.value.width,
height: windowSize.value.height,
posX: props.windowState.position.x,
posY: props.windowState.position.y,
};
document.addEventListener("mousemove", onResizeMove);
document.addEventListener("mouseup", onResizeEnd);
}
function onResizeMove(event) {
if (!resizing.value) return;
const dx = event.clientX - resizeStart.value.x;
const dy = event.clientY - resizeStart.value.y;
const axis = resizeAxis.value;
let width = resizeStart.value.width;
let height = resizeStart.value.height;
let posX = resizeStart.value.posX;
let posY = resizeStart.value.posY;
if (axis.includes("e")) {
width = resizeStart.value.width + dx;
}
if (axis.includes("w")) {
width = resizeStart.value.width - dx;
posX = resizeStart.value.posX + dx;
}
if (axis.includes("s")) {
height = resizeStart.value.height + dy;
}
if (axis.includes("n")) {
height = resizeStart.value.height - dy;
posY = resizeStart.value.posY + dy;
}
const maxWidth = Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 16);
const maxHeight = Math.max(FLOAT_WINDOW_MIN_SIZE.height, window.innerHeight - 16);
width = Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.width, width), maxWidth);
height = Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.height, height), maxHeight);
if (axis.includes("w")) {
posX = resizeStart.value.posX + (resizeStart.value.width - width);
}
if (axis.includes("n")) {
posY = resizeStart.value.posY + (resizeStart.value.height - height);
}
updateFloatingSize(props.windowState.sessionId, { width, height });
const nextPos = clampPosition(posX, posY, width, height);
updateFloatingPosition(props.windowState.sessionId, nextPos);
scheduleFitTerminal();
}
function onResizeEnd() {
resizing.value = false;
resizeAxis.value = "";
document.removeEventListener("mousemove", onResizeMove);
document.removeEventListener("mouseup", onResizeEnd);
scheduleFitTerminal();
}
function cleanupListeners() {
onDragEnd();
onResizeEnd();
if (fitFrame) {
cancelAnimationFrame(fitFrame);
fitFrame = 0;
}
}
watch(
() => props.windowState.minimized,
(minimized) => {
if (!minimized) {
requestAnimationFrame(() => fitSessionTerminal(props.windowState.sessionId));
}
}
);
onMounted(() => {
if (!props.windowState.size) {
updateFloatingSize(props.windowState.sessionId, { ...FLOAT_WINDOW_DEFAULT_SIZE });
}
if (!props.windowState.minimized && windowRef.value) {
resizeObserver = new ResizeObserver(() => scheduleFitTerminal());
const body = windowRef.value.querySelector(".ssh-float-window__body");
if (body) resizeObserver.observe(body);
}
});
onBeforeUnmount(() => {
cleanupListeners();
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<template>
<div
ref="windowRef"
class="ssh-float-window"
:class="{
'ssh-float-window--minimized': windowState.minimized,
'ssh-float-window--dragging': dragging,
'ssh-float-window--resizing': resizing,
}"
:style="windowStyle"
@mousedown="handleFocus"
>
<div class="ssh-float-window__header" @mousedown="onHeaderMouseDown">
<div class="ssh-float-window__title">
<el-icon class="ssh-float-window__drag-icon"><Rank /></el-icon>
<div>
<strong>{{ session?.scriptName || "脚本执行" }}</strong>
<p>{{ session?.profileHost || "" }}</p>
</div>
<el-tag size="small" :type="statusTagType" effect="plain">{{ statusLabel }}</el-tag>
</div>
<div class="ssh-float-window__actions" @mousedown.stop>
<el-button text :icon="Minus" @click="handleMinimize" />
<el-button text type="danger" :icon="SwitchButton" :disabled="!canStop" @click="handleStop" />
<el-button text type="danger" :icon="Close" @click="handleClose" />
</div>
</div>
<div v-show="!windowState.minimized" class="ssh-float-window__body">
<div class="ssh-terminal-shell ssh-float-window__terminal">
<div class="ssh-terminal-shell__meta">
<span>{{ session?.statusText || "等待输出" }}</span>
</div>
<div
:ref="(el) => registerTerminalContainer(windowState.sessionId, el)"
class="ssh-terminal-canvas ssh-float-window__terminal-canvas"
/>
</div>
</div>
<template v-if="!windowState.minimized">
<div
class="ssh-float-window__resize ssh-float-window__resize--n"
@mousedown="onResizeMouseDown($event, 'n')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--s"
@mousedown="onResizeMouseDown($event, 's')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--e"
@mousedown="onResizeMouseDown($event, 'e')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--w"
@mousedown="onResizeMouseDown($event, 'w')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--ne"
@mousedown="onResizeMouseDown($event, 'ne')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--nw"
@mousedown="onResizeMouseDown($event, 'nw')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--se"
@mousedown="onResizeMouseDown($event, 'se')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--sw"
@mousedown="onResizeMouseDown($event, 'sw')"
/>
</template>
</div>
</template>

View File

@ -0,0 +1,523 @@
<script setup>
import { computed, inject } from "vue";
import { Handle, Position } from "@vue-flow/core";
import { NodeToolbar } from "@vue-flow/node-toolbar";
const props = defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
default: () => ({}),
},
selected: {
type: Boolean,
default: false,
},
connectable: {
type: [Boolean, Function, Object, String],
default: true,
},
readonly: {
type: Boolean,
default: false,
},
});
const canvasActions = inject("workflowCanvasActions", null);
const nodeType = computed(() => String(props.data?.type || "http").toLowerCase());
const isStart = computed(() => nodeType.value === "start");
const isEnd = computed(() => nodeType.value === "end");
const isCondition = computed(() => nodeType.value === "condition");
const isLoop = computed(() => nodeType.value === "loop");
const isLocked = computed(() => Boolean(props.data?._isLocked));
const badges = computed(() => (Array.isArray(props.data?._badges) ? props.data._badges : []));
const runtimeState = computed(() => props.data?._runtimeState || null);
const runtimeStatus = computed(() => String(runtimeState.value?.status || ""));
const runtimeLabel = computed(() => {
const map = {
running: "正在执行",
success: "已完成",
failed: "失败",
skipped: "跳过",
};
return map[runtimeStatus.value] || "";
});
function runAction(action, ...args) {
action?.(...args);
}
</script>
<template>
<NodeToolbar
v-if="selected && !readonly"
class="workflow-node-toolbar"
:is-visible="selected"
:position="Position.Top"
:offset="20"
>
<div class="workflow-node-toolbar__inner" @mousedown.stop>
<button
v-if="!isStart"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.insertPrevNode, 'http', id)"
>
前置 HTTP
</button>
<button
v-if="!isEnd"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', '', id)"
>
后接 HTTP
</button>
<button
v-if="!isEnd"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'condition', '', id)"
>
后接 条件
</button>
<button
v-if="isCondition"
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--if"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'if', id)"
>
IF
</button>
<button
v-if="isCondition"
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--else"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'else', id)"
>
ELSE
</button>
<button
v-if="isLoop"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'body', id)"
>
循环体
</button>
<button
v-if="isLoop"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'done', id)"
>
循环后
</button>
<button
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--danger"
:disabled="isLocked"
@mousedown.stop
@click.stop="runAction(canvasActions?.deleteNodeById, id)"
>
删除
</button>
</div>
</NodeToolbar>
<div
class="workflow-node-card"
:class="[
`workflow-node-card--${nodeType}`,
{
'is-selected': selected,
'is-current-api': data?._isCurrentApi,
[`is-runtime-${runtimeStatus}`]: runtimeStatus,
},
]"
>
<Handle
v-if="!isStart && !readonly"
id="in"
type="target"
:position="Position.Top"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--target"
/>
<div class="workflow-node-card__surface">
<div class="workflow-node-card__eyebrow">
<span
v-for="badge in badges"
:key="`${badge.kind}-${badge.text}`"
class="workflow-node-card__badge"
:class="`workflow-node-card__badge--${badge.kind}`"
>
{{ badge.text }}
</span>
</div>
<strong class="workflow-node-card__title">{{ data?.label || "节点" }}</strong>
<p class="workflow-node-card__summary">{{ data?._summary }}</p>
<span v-if="runtimeLabel" class="workflow-node-card__runtime">{{ runtimeLabel }}</span>
<div v-if="isCondition" class="workflow-node-card__branches">
<span class="workflow-node-card__branch workflow-node-card__branch--true">IF</span>
<span class="workflow-node-card__branch workflow-node-card__branch--false">ELSE</span>
</div>
<div v-if="isLoop" class="workflow-node-card__branches">
<span class="workflow-node-card__branch workflow-node-card__branch--body">BODY</span>
<span class="workflow-node-card__branch workflow-node-card__branch--done">DONE</span>
</div>
</div>
<template v-if="isCondition && !readonly">
<Handle
id="true"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--true"
/>
<Handle
id="false"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--false"
/>
</template>
<template v-else-if="isLoop && !readonly">
<Handle
id="body"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--body"
style="left: 34%"
/>
<Handle
id="done"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--done"
style="left: 66%"
/>
</template>
<Handle
v-else-if="!isEnd && !readonly"
id="out"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source"
/>
</div>
</template>
<style scoped>
.workflow-node-card {
position: relative;
width: 248px;
min-height: 138px;
border-radius: 22px;
border: 1px solid #d9e5f3;
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.workflow-node-card.is-selected {
border-color: #73a6ff;
box-shadow:
0 0 0 5px rgba(107, 160, 255, 0.16),
0 20px 38px rgba(15, 23, 42, 0.12);
}
.workflow-node-card.is-current-api {
border-color: #16a34a;
box-shadow:
0 0 0 4px rgba(34, 197, 94, 0.12),
0 18px 36px rgba(15, 23, 42, 0.1);
}
.workflow-node-card.is-runtime-running {
border-color: #409eff;
box-shadow:
0 0 0 4px rgba(64, 158, 255, 0.14),
0 18px 36px rgba(15, 23, 42, 0.1);
}
.workflow-node-card.is-runtime-success {
border-color: #67c23a;
}
.workflow-node-card.is-runtime-failed {
border-color: #f56c6c;
}
.workflow-node-card.is-runtime-skipped {
border-style: dashed;
opacity: 0.78;
}
.workflow-node-card--start,
.workflow-node-card--end {
border-radius: 999px;
}
.workflow-node-card--start {
background: linear-gradient(180deg, #f4fff8 0%, #ffffff 100%);
border-color: #9ddab2;
}
.workflow-node-card--end {
background: linear-gradient(180deg, #fff5f5 0%, #ffffff 100%);
border-color: #f0b3b3;
}
.workflow-node-card--condition {
background: linear-gradient(180deg, #fffaf0 0%, #ffffff 100%);
border-color: #efcf82;
}
.workflow-node-card--extract {
background: linear-gradient(180deg, #f8f6ff 0%, #ffffff 100%);
border-color: #cfc0ff;
}
.workflow-node-card__surface {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 18px 20px;
min-height: 138px;
}
.workflow-node-card__eyebrow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.workflow-node-card__badge {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
color: #315b96;
background: #ebf3ff;
}
.workflow-node-card__badge--start {
color: #0f766e;
background: #defcf5;
}
.workflow-node-card__badge--end {
color: #b42318;
background: #fee4e2;
}
.workflow-node-card__badge--condition {
color: #8a6116;
background: #fff2cf;
}
.workflow-node-card__badge--extract {
color: #6d28d9;
background: #efe7ff;
}
.workflow-node-card__badge--current {
color: #166534;
background: #dcfce7;
}
.workflow-node-card__badge--link {
color: #0f6fa0;
background: #e0f2fe;
}
.workflow-node-card__badge--stage {
color: #0f766e;
background: #dffaf6;
}
.workflow-node-card__badge--var {
color: #7c3aed;
background: #f1e8ff;
}
.workflow-node-card__title {
color: #1f2d3d;
font-size: 15px;
line-height: 1.45;
word-break: break-word;
}
.workflow-node-card__summary {
margin: 0;
color: #6f8199;
font-size: 12px;
line-height: 1.7;
word-break: break-word;
}
.workflow-node-card__runtime {
display: inline-flex;
align-self: flex-start;
margin-top: -2px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1;
color: #1d4ed8;
background: #eaf2ff;
}
.workflow-node-card__branches {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.workflow-node-card__branch {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.workflow-node-card__branch--true {
color: #166534;
background: #dcfce7;
}
.workflow-node-card__branch--false {
color: #b42318;
background: #fee4e2;
}
.workflow-node-card__branch--body {
color: #1d4ed8;
background: #dbeafe;
}
.workflow-node-card__branch--done {
color: #6d28d9;
background: #ede9fe;
}
.workflow-node-card--loop {
border-color: #c4d7ff;
}
:deep(.workflow-node-card__handle) {
width: 14px;
height: 14px;
border: 2px solid #fff;
background: #6888b5;
box-shadow: 0 0 0 2px rgba(104, 136, 181, 0.18);
}
:deep(.workflow-node-card__handle--target) {
top: -8px;
}
:deep(.workflow-node-card__handle--source) {
bottom: -8px;
}
:deep(.workflow-node-card__handle--true) {
left: 30% !important;
background: #16a34a;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18);
}
:deep(.workflow-node-card__handle--false) {
left: 70% !important;
background: #dc2626;
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.18);
}
:deep(.workflow-node-toolbar) {
pointer-events: all;
}
.workflow-node-toolbar__inner {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
max-width: 360px;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(31, 41, 55, 0.08);
background: rgba(15, 23, 42, 0.94);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22);
}
.workflow-node-toolbar__btn {
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
padding: 7px 10px;
border-radius: 10px;
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.workflow-node-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.workflow-node-toolbar__btn--if {
background: rgba(34, 197, 94, 0.22);
}
.workflow-node-toolbar__btn--else {
background: rgba(239, 68, 68, 0.22);
}
.workflow-node-toolbar__btn--danger {
background: rgba(248, 113, 113, 0.24);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,201 @@
<script setup>
import {
ArrowDown,
Connection,
DataBoard,
Key,
Link,
Lock,
Monitor,
Operation,
Setting,
SwitchButton,
User,
VideoPlay,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import ProfileApiKeyPanel from "../components/ProfileApiKeyPanel.vue";
import SshFloatingTerminalLayer from "../components/SshFloatingTerminalLayer.vue";
import { authState, clearSession } from "../auth/session";
const route = useRoute();
const router = useRouter();
const collapsed = ref(false);
const profileDrawerVisible = ref(false);
const menuItems = computed(() => [
{ index: "/", label: "工作台", icon: DataBoard },
{ index: "/workflow-batches", label: "跑批工作流执行", icon: VideoPlay },
{ index: "/apis", label: "接口管理", icon: Connection },
{ index: "/ssh", label: "SSH 管理", icon: Key },
...(authState.user?.role === "superadmin" ? [{ index: "/users", label: "用户管理", icon: User }] : []),
{ index: "/mcp-config", label: "MCP 配置", icon: Setting },
]);
const activeMenu = computed(() => {
const path = route.path || "/";
if (path.startsWith("/workflow-batches") || path.startsWith("/workflow-runtime")) return "/workflow-batches";
if (path.startsWith("/apis")) return "/apis";
if (path.startsWith("/ssh")) return "/ssh";
if (path.startsWith("/mcp-config")) return "/mcp-config";
if (path.startsWith("/users")) return "/users";
return "/";
});
const displayName = computed(() => authState.user?.display_name || authState.user?.username || "未登录");
const roleLabel = computed(() => (authState.user?.role === "superadmin" ? "超管" : "普通用户"));
const accountLabel = computed(() => authState.user?.username || "--");
const avatarText = computed(() => {
const source = displayName.value || accountLabel.value || "U";
return String(source).trim().slice(0, 1).toUpperCase();
});
function handleUserCommand(command) {
if (command === "logout") {
logout();
return;
}
if (command === "profile") {
profileDrawerVisible.value = true;
return;
}
const commandText = {
password: "修改密码",
bind: "账号绑定",
}[command];
ElMessage.info(`${commandText} 暂未开放`);
}
function logout() {
clearSession();
router.replace("/login");
}
</script>
<template>
<div class="admin-shell">
<aside class="admin-sidebar" :class="{ collapsed }">
<div class="admin-brand">
<div class="admin-brand__logo">质检</div>
<div v-if="!collapsed" class="admin-brand__meta">
<strong>质量检测平台</strong>
</div>
</div>
<el-scrollbar class="admin-sidebar__scroll">
<el-menu
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
class="admin-menu"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.index"
:index="item.index"
:disabled="item.disabled"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</el-scrollbar>
</aside>
<div class="admin-main">
<header class="admin-header">
<div class="admin-header__left">
<el-button text @click="collapsed = !collapsed">
<el-icon><Operation /></el-icon>
</el-button>
<div>
<h1>质量检测平台</h1>
</div>
</div>
<div class="admin-header__right">
<el-dropdown
trigger="click"
placement="bottom-end"
popper-class="user-dropdown-popper"
@command="handleUserCommand"
>
<button class="admin-user-trigger" type="button">
<div class="admin-user-trigger__meta">
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }}</p>
</div>
<el-avatar :size="40" class="admin-user-trigger__avatar">
{{ avatarText }}
</el-avatar>
<el-icon class="admin-user-trigger__arrow"><ArrowDown /></el-icon>
</button>
<template #dropdown>
<div class="user-dropdown-card">
<div class="user-dropdown-card__summary">
<div class="user-dropdown-card__avatar">
<el-icon :size="18"><Monitor /></el-icon>
</div>
<div>
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }}</p>
<span>账号 {{ accountLabel }}</span>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item command="password">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item command="bind">
<el-icon><Link /></el-icon>
<span>账号绑定</span>
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
</div>
</header>
<main class="admin-content">
<router-view />
</main>
</div>
<el-drawer v-model="profileDrawerVisible" title="个人中心" size="760px" destroy-on-close>
<div class="profile-drawer">
<section class="profile-drawer__summary panel-card">
<div class="profile-drawer__avatar">{{ avatarText }}</div>
<div>
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }} · 账号 {{ accountLabel }}</p>
</div>
</section>
<section class="profile-drawer__section">
<div class="panel-card__header">
<div>
<strong>API Key</strong>
<p>用于 MCP Bridge 与自动化调用平台接口</p>
</div>
</div>
<ProfileApiKeyPanel />
</section>
</div>
</el-drawer>
<SshFloatingTerminalLayer />
</div>
</template>

View File

@ -0,0 +1,8 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
import "./styles/index.css";
createApp(App).use(router).use(ElementPlus).mount("#app");

View File

@ -0,0 +1,114 @@
import { createRouter, createWebHistory } from "vue-router";
import { authState, getAccessToken } from "../auth/session";
import AdminLayout from "../layout/AdminLayout.vue";
import ApiEditorView from "../views/ApiEditorView.vue";
import ApiManagementView from "../views/ApiManagementView.vue";
import DashboardView from "../views/DashboardView.vue";
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 WorkflowBatchView from "../views/WorkflowBatchView.vue";
const routes = [
{
path: "/login",
name: "login",
component: LoginView,
meta: {
public: true,
title: "登录",
},
},
{
path: "/",
component: AdminLayout,
children: [
{
path: "",
name: "dashboard",
component: DashboardView,
meta: {
title: "工作台",
},
},
{
path: "apis",
name: "apis",
component: ApiManagementView,
meta: {
title: "接口管理",
},
},
{
path: "ssh",
name: "ssh",
component: SshManagementView,
meta: {
title: "SSH 管理",
},
},
{
path: "workflow-batches",
name: "workflow-batches",
component: WorkflowBatchView,
meta: {
title: "跑批工作流执行",
},
},
{
path: "workflow-runtime",
redirect: "/workflow-batches",
},
{
path: "mcp-config",
name: "mcp",
component: McpManagementView,
meta: {
title: "MCP 配置",
},
},
{
path: "apis/:apiId/edit",
name: "api-edit",
component: ApiEditorView,
meta: {
title: "接口编辑",
},
},
{
path: "users",
name: "users",
component: UserManagementView,
meta: {
title: "用户管理",
requiresSuperadmin: true,
},
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const token = getAccessToken();
if (to.meta.public) {
if (token && to.path === "/login") {
return "/";
}
return true;
}
if (!token) {
return "/login";
}
if (to.meta.requiresSuperadmin && authState.user?.role !== "superadmin") {
return "/";
}
return true;
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
export function normalizeFolderPath(value) {
const text = String(value || "")
.trim()
.replace(/\\/g, "/");
if (!text || text === "/") return "";
return text
.split("/")
.map((part) => part.trim())
.filter(Boolean)
.join("/");
}
export function collectFolderPaths(rows, extraFolderPaths = []) {
const paths = new Set();
rows.forEach((row) => {
const path = normalizeFolderPath(row.folder_path);
if (path) paths.add(path);
});
extraFolderPaths.forEach((path) => {
const normalized = normalizeFolderPath(path);
if (normalized) paths.add(normalized);
});
return [...paths];
}
export function buildFolderTreeRows(rows, extraFolderPaths = []) {
const folderMap = new Map();
const rootNodes = [];
function ensureFolderNode(path) {
if (!path) return null;
if (folderMap.has(path)) return folderMap.get(path);
const parts = path.split("/").filter(Boolean);
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join("/");
const node = {
rowKey: `folder:${path}`,
nodeType: "folder",
name,
folder_path: path,
creator_name: "",
method: "",
url: "",
timeout_seconds: "",
children: [],
isEmptyFolder: true,
};
folderMap.set(path, node);
const parent = parentPath ? ensureFolderNode(parentPath) : null;
if (parent) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
return node;
}
collectFolderPaths(rows, extraFolderPaths).forEach((path) => ensureFolderNode(path));
rows.forEach((row) => {
const folderNode = ensureFolderNode(normalizeFolderPath(row.folder_path || ""));
const apiNode = {
...row,
rowKey: `api:${row.id}`,
nodeType: "api",
children: [],
isEmptyFolder: false,
};
if (folderNode) {
folderNode.isEmptyFolder = false;
folderNode.children.push(apiNode);
} else {
rootNodes.push(apiNode);
}
});
function sortNodes(nodes) {
nodes.sort((left, right) => {
if (left.nodeType !== right.nodeType) {
return left.nodeType === "folder" ? -1 : 1;
}
return String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
});
nodes.forEach((node) => {
if (node.children?.length) {
sortNodes(node.children);
}
});
}
sortNodes(rootNodes);
return rootNodes;
}

View File

@ -0,0 +1,29 @@
export function normalizeKeyValueRows(source) {
if (!source || typeof source !== "object" || Array.isArray(source)) {
return [{ key: "", value: "" }];
}
const rows = Object.entries(source).map(([key, value]) => ({
key: String(key),
value: typeof value === "string" ? value : JSON.stringify(value ?? ""),
}));
return rows.length ? rows : [{ key: "", value: "" }];
}
export function collectKeyValueObject(rows, duplicateLabel = "Key") {
const output = {};
const seen = new Set();
rows.forEach((row) => {
const key = String(row.key || "").trim();
if (!key) return;
if (seen.has(key)) {
throw new Error(`${duplicateLabel} 存在重复 Key${key}`);
}
seen.add(key);
output[key] = String(row.value ?? "");
});
return output;
}

View File

@ -0,0 +1,47 @@
const STORAGE_KEY = "qip_ssh_pinned_scripts";
export function loadPinnedScripts() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function savePinnedScripts(items) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
export function isScriptPinned(scriptId) {
return loadPinnedScripts().some((item) => Number(item.scriptId) === Number(scriptId));
}
export function togglePinScript(entry) {
const scriptId = Number(entry.scriptId);
const profileId = Number(entry.profileId);
const items = loadPinnedScripts();
const index = items.findIndex((item) => Number(item.scriptId) === scriptId);
if (index >= 0) {
items.splice(index, 1);
savePinnedScripts(items);
return false;
}
items.unshift({
scriptId,
profileId,
scriptName: entry.scriptName || "",
profileName: entry.profileName || "",
profileHost: entry.profileHost || "",
description: entry.description || "",
pinnedAt: Date.now(),
});
savePinnedScripts(items);
return true;
}
export function removePinnedScript(scriptId) {
const items = loadPinnedScripts().filter((item) => Number(item.scriptId) !== Number(scriptId));
savePinnedScripts(items);
}

View File

@ -0,0 +1,428 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { nextTick, ref } from "vue";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { buildWebSocketUrl } from "../api/http";
import { getAccessToken } from "../auth/session";
import "@xterm/xterm/css/xterm.css";
let nextRunSeq = 1;
let nextZIndex = 3200;
export const sessions = ref([]);
export const tabSessionIds = ref([]);
export const activeTabSessionId = ref("");
export const floatingWindows = ref([]);
export const FLOAT_WINDOW_DEFAULT_SIZE = { width: 760, height: 420 };
export const FLOAT_WINDOW_MIN_SIZE = { width: 360, height: 200 };
const terminalContainers = new Map();
function createTerminalTheme() {
return {
background: "#0f172a",
foreground: "#e2e8f0",
cursor: "#64748b",
black: "#0f172a",
red: "#f87171",
green: "#4ade80",
yellow: "#facc15",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#e2e8f0",
brightBlack: "#334155",
brightRed: "#fca5a5",
brightGreen: "#86efac",
brightYellow: "#fde047",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#f8fafc",
};
}
export function getSession(sessionId) {
return sessions.value.find((item) => item.id === sessionId) || null;
}
export function registerTerminalContainer(sessionId, element) {
if (element) {
terminalContainers.set(sessionId, element);
const session = getSession(sessionId);
if (session && !session.terminal) {
nextTick(() => initSessionTerminal(session));
}
return;
}
terminalContainers.delete(sessionId);
}
export function initSessionTerminal(session) {
const container = terminalContainers.get(session.id);
if (!container || session.terminal) return;
const terminal = new Terminal({
disableStdin: true,
cursorBlink: false,
convertEol: true,
scrollback: 8000,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
fontSize: 13,
lineHeight: 1.3,
theme: createTerminalTheme(),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
fitAddon.fit();
terminal.writeln("\x1b[36mSSH 脚本执行日志\x1b[0m\r\n");
session.terminal = terminal;
session.fitAddon = fitAddon;
fitSessionTerminal(session.id);
}
export function disposeSessionTerminal(session) {
if (!session) return;
session.terminal?.dispose();
session.terminal = null;
session.fitAddon = null;
terminalContainers.delete(session.id);
}
export function fitSessionTerminal(sessionId) {
const session = getSession(sessionId);
if (!session?.terminal || !session.fitAddon) return;
session.fitAddon.fit();
session.terminal.scrollToBottom();
}
function scrollSessionTerminal(session) {
session.terminal?.scrollToBottom();
}
function writeSessionLine(session, text, color = "") {
if (!session.terminal) return;
if (color) {
session.terminal.writeln(`${color}${text}\x1b[0m`);
} else {
session.terminal.writeln(text);
}
scrollSessionTerminal(session);
}
function appendSessionOutput(session, text, stream = "stdout") {
if (!session.terminal || !text) return;
if (stream === "stderr") {
session.terminal.write(`\x1b[31m${text}\x1b[0m`);
} else {
session.terminal.write(text);
}
scrollSessionTerminal(session);
}
function finishSession(session, status, statusText, tailLine = "") {
session.status = status;
session.statusText = statusText;
if (tailLine) {
writeSessionLine(session, tailLine, status === "success" ? "\x1b[32m" : "\x1b[33m");
}
}
function closeSessionSocket(session) {
if (session.socket && session.socket.readyState === WebSocket.OPEN) {
session.socket.close();
}
session.socket = null;
}
export function stopSession(session, options = {}) {
const { silent = false } = options;
if (!session) return;
if (session.status === "running" || session.status === "connecting") {
if (session.socket?.readyState === WebSocket.OPEN) {
session.socket.send(JSON.stringify({ type: "stop" }));
}
closeSessionSocket(session);
finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行");
if (!silent) {
ElMessage.success(`已停止:${session.scriptName}`);
}
}
}
function buildProfileHost(profile) {
return `${profile.username}@${profile.host}:${profile.port || 22}`;
}
function createSession(script, profile) {
return {
id: `run-${Date.now()}-${nextRunSeq++}`,
scriptId: script.id,
scriptName: script.name,
profileId: profile.id,
profileName: profile.name,
profileHost: buildProfileHost(profile),
status: "connecting",
statusText: "正在连接并执行脚本...",
socket: null,
terminal: null,
fitAddon: null,
};
}
export async function executeScript(script, profile, password, target = "tab") {
const session = createSession(script, profile);
sessions.value.push(session);
if (target === "float") {
const offset = floatingWindows.value.length * 28;
const maxWidth =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 32) : 760;
floatingWindows.value.push({
sessionId: session.id,
minimized: false,
position: { x: 72 + offset, y: 72 + offset },
size: {
width: Math.min(FLOAT_WINDOW_DEFAULT_SIZE.width, maxWidth),
height: FLOAT_WINDOW_DEFAULT_SIZE.height,
},
zIndex: ++nextZIndex,
});
} else {
tabSessionIds.value.push(session.id);
activeTabSessionId.value = session.id;
}
await nextTick();
initSessionTerminal(session);
if (!session.terminal) {
await nextTick();
initSessionTerminal(session);
}
writeSessionLine(
session,
`>>> 执行脚本: ${script.name} @ ${buildProfileHost(profile)}`,
"\x1b[36m"
);
const token = getAccessToken();
const socket = new WebSocket(buildWebSocketUrl("/ws/ssh-script-run", { token }));
session.socket = socket;
socket.onopen = () => {
socket.send(
JSON.stringify({
type: "start",
profile_id: profile.id,
script_id: script.id,
password,
})
);
};
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "started") {
session.status = "running";
session.statusText = "脚本执行中,日志实时输出...";
return;
}
if (payload.type === "meta") {
writeSessionLine(session, `$ ${payload.command || ""}`, "\x1b[90m");
return;
}
if (payload.type === "stdout" || payload.type === "stderr") {
appendSessionOutput(session, payload.data || "", payload.type);
return;
}
if (payload.type === "done") {
const exitCode = payload.exit_status ?? 0;
const ok = Boolean(payload.ok);
finishSession(
session,
ok ? "success" : "failed",
ok ? `执行成功exit code ${exitCode}` : `执行失败exit code ${exitCode}`,
`>>> 执行结束exit code ${exitCode}`
);
closeSessionSocket(session);
return;
}
if (payload.type === "stopped") {
finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行");
closeSessionSocket(session);
return;
}
if (payload.type === "error") {
finishSession(session, "failed", payload.detail || "执行失败", `\x1b[31m>>> ${payload.detail || "执行失败"}`);
closeSessionSocket(session);
}
} catch {
appendSessionOutput(session, String(event.data || ""));
}
};
socket.onerror = () => {
finishSession(session, "failed", "脚本执行连接异常", "\x1b[31m>>> WebSocket connection error");
closeSessionSocket(session);
};
socket.onclose = () => {
if (session.status === "running" || session.status === "connecting") {
finishSession(session, "stopped", "脚本执行连接已关闭");
}
session.socket = null;
};
return session;
}
export async function promptExecutePassword(scriptName) {
try {
const result = await ElMessageBox.prompt("请输入执行密码,仅用于本次执行,不会保存。", `执行脚本:${scriptName}`, {
confirmButtonText: "执行",
cancelButtonText: "取消",
inputType: "password",
inputPlaceholder: "执行密码(必填)",
inputValidator: (value) => {
if (!value || !String(value).trim()) {
return "执行前必须输入密码";
}
return true;
},
});
return String(result.value || "").trim();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "已取消执行");
}
return null;
}
}
export async function promptAndExecuteScript({ script, profile, target = "tab" }) {
const password = await promptExecutePassword(script.name);
if (!password) return null;
return executeScript(script, profile, password, target);
}
export function parseProfileHost(profileHost) {
const raw = String(profileHost || "").trim();
const withPort = raw.match(/^([^@]+)@([^:]+):(\d+)$/);
if (withPort) {
return {
username: withPort[1],
host: withPort[2],
port: Number(withPort[3]),
};
}
const withoutPort = raw.match(/^([^@]+)@(.+)$/);
if (withoutPort) {
return {
username: withoutPort[1],
host: withoutPort[2],
port: 22,
};
}
return { username: "", host: "", port: 22 };
}
export async function promptAndExecutePinnedScript(item) {
const parsed = parseProfileHost(item.profileHost);
const script = { id: Number(item.scriptId), name: item.scriptName || "脚本" };
const profile = {
id: Number(item.profileId),
name: item.profileName || "主机",
username: parsed.username,
host: parsed.host,
port: parsed.port,
};
return promptAndExecuteScript({ script, profile, target: "float" });
}
export function removeTabSession(tabId) {
const session = getSession(tabId);
if (!session) return;
stopSession(session, { silent: true });
disposeSessionTerminal(session);
sessions.value = sessions.value.filter((item) => item.id !== tabId);
tabSessionIds.value = tabSessionIds.value.filter((id) => id !== tabId);
if (activeTabSessionId.value === tabId) {
activeTabSessionId.value = tabSessionIds.value[tabSessionIds.value.length - 1] || "";
}
nextTick(() => fitSessionTerminal(activeTabSessionId.value));
}
export function closeFloatingWindow(sessionId) {
const session = getSession(sessionId);
if (session) {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
sessions.value = sessions.value.filter((item) => item.id !== sessionId);
}
floatingWindows.value = floatingWindows.value.filter((item) => item.sessionId !== sessionId);
tabSessionIds.value = tabSessionIds.value.filter((id) => id !== sessionId);
}
export function toggleFloatingMinimize(sessionId) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.minimized = !target.minimized;
if (!target.minimized) {
nextTick(() => fitSessionTerminal(sessionId));
}
}
export function bringFloatingToFront(sessionId) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.zIndex = ++nextZIndex;
}
export function updateFloatingPosition(sessionId, position) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.position = position;
}
export function updateFloatingSize(sessionId, size) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
const maxWidth =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 16) : 2000;
const maxHeight =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.height, window.innerHeight - 16) : 1200;
target.size = {
width: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.width, size.width), maxWidth),
height: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.height, size.height), maxHeight),
};
}
export function cleanupTabSessions() {
const ids = [...tabSessionIds.value];
ids.forEach((id) => {
const session = getSession(id);
if (session) {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
}
});
sessions.value = sessions.value.filter((item) => !ids.includes(item.id));
tabSessionIds.value = [];
activeTabSessionId.value = "";
}
export function cleanupAllSessions() {
sessions.value.forEach((session) => {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
});
sessions.value = [];
tabSessionIds.value = [];
activeTabSessionId.value = "";
floatingWindows.value = [];
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,391 @@
<script setup>
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 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 searchKeyword = ref("");
const rawRows = ref([]);
const declaredFolderPaths = ref([]);
const folderDialogVisible = ref(false);
const folderDialogMode = ref("create");
const folderDialogParentPath = ref("");
const folderDialogEditPath = ref("");
const formState = reactive({
name: "",
folder_path: "",
method: "GET",
url: "",
timeout_seconds: 10,
body_text: "{}",
query_text: "{}",
});
const headerRows = ref([{ key: "", value: "" }]);
const pathParamRows = ref([{ key: "", value: "" }]);
const methodOptions = ["GET", "POST", "PUT", "DELETE", "PATCH"];
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.method, row.url, 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));
function resetForm() {
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: "" }];
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openCreateDrawerFromFolder(folderPath) {
resetForm();
formState.folder_path = folderPath || "";
drawerVisible.value = true;
}
function openCreateFolderDialog(parentPath = "") {
folderDialogMode.value = "create";
folderDialogEditPath.value = "";
folderDialogParentPath.value = normalizeFolderPath(parentPath);
folderDialogVisible.value = true;
}
function openEditFolderDialog(folderPath) {
folderDialogMode.value = "edit";
folderDialogEditPath.value = normalizeFolderPath(folderPath);
folderDialogParentPath.value = "";
folderDialogVisible.value = true;
}
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 || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed;
} catch (error) {
throw new Error(error instanceof Error ? error.message : `${label} 解析失败`);
}
}
function buildPayload() {
return {
name: formState.name.trim(),
folder_path: formState.folder_path.trim(),
method: formState.method,
url: formState.url.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"),
};
}
async function loadApis() {
loading.value = true;
try {
const [apis, folders] = await Promise.all([
apiGet("/api/apis"),
apiGet("/api/folders?target=apis"),
]);
rawRows.value = apis;
declaredFolderPaths.value = folders.folders || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "接口列表加载失败");
} finally {
loading.value = false;
}
}
async function syncFromWorkflows() {
try {
await ElMessageBox.confirm(
"将从当前可见工作流的 HTTP 节点提取 method+URL补全到接口库已存在的不会重复创建。是否继续",
"从工作流同步接口",
{
type: "info",
confirmButtonText: "同步",
cancelButtonText: "取消",
}
);
} catch {
return;
}
loading.value = true;
try {
const result = await apiPost("/api/apis/sync-from-workflows", {});
ElMessage.success(`同步完成:新增 ${result.created || 0} 个,跳过 ${result.skipped || 0} 个,当前共 ${result.total || 0}`);
await loadApis();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "同步失败");
} finally {
loading.value = false;
}
}
async function submitForm() {
let payload;
try {
payload = buildPayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
if (!payload.name || !payload.url) {
ElMessage.warning("名称和 URL 不能为空");
return;
}
saving.value = true;
try {
await apiPost("/api/apis", payload);
ElMessage.success("接口已创建");
drawerVisible.value = false;
await loadApis();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除接口「${row.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/apis/${row.id}`);
ElMessage.success("接口已删除");
await loadApis();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
onMounted(() => {
loadApis();
});
</script>
<template>
<div class="page-stack">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>接口管理</strong>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadApis">刷新</el-button>
<el-button :icon="FolderAdd" @click="openCreateFolderDialog()">新建目录</el-button>
<el-button @click="syncFromWorkflows">从工作流同步</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建接口</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称、目录、方法、URL、创建人"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="toolbar-actions">
<el-tag type="info" effect="plain">目录 {{ folderCount }}</el-tag>
<el-tag type="success" effect="plain">接口 {{ visibleRows.length }}</el-tag>
</div>
</div>
<el-table
:data="treeRows"
row-key="rowKey"
default-expand-all
v-loading="loading"
class="api-table"
:tree-props="{ children: 'children' }"
:row-class-name="({ row }) => (row.nodeType === 'folder' ? 'table-row-folder' : 'table-row-api')"
>
<el-table-column label="目录 / 接口" min-width="360">
<template #default="{ row }">
<div v-if="row.nodeType === 'folder'" class="tree-title tree-title--folder">
<el-icon><FolderOpened /></el-icon>
<strong>{{ row.name || "/" }}</strong>
<el-tag v-if="row.isEmptyFolder" size="small" type="info" effect="plain">空目录</el-tag>
</div>
<div v-else class="tree-title">
<span class="tree-title__api-icon">
<el-icon><Link /></el-icon>
</span>
<strong>{{ row.name }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="Method" width="110">
<template #default="{ row }">
<template v-if="row.nodeType === 'api'">
<el-tag :type="row.method === 'GET' ? 'success' : 'primary'" effect="light">{{ row.method }}</el-tag>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="URL" min-width="300">
<template #default="{ row }">
<span class="table-url">{{ row.nodeType === "api" ? row.url : row.folder_path || "/" }}</span>
</template>
</el-table-column>
<el-table-column label="超时(s)" width="110">
<template #default="{ row }">
<span>{{ row.nodeType === "api" ? row.timeout_seconds : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="创建人" min-width="140">
<template #default="{ row }">
<span>{{ row.nodeType === "folder" ? "-" : row.creator_name }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="360" fixed="right">
<template #default="{ row }">
<div v-if="row.nodeType === 'folder'" class="row-actions">
<el-button text :icon="Setting" @click="openEditFolderDialog(row.folder_path)">编辑</el-button>
<el-button text :icon="FolderAdd" @click="openCreateFolderDialog(row.folder_path)">子目录</el-button>
<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 type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
title="新建接口"
size="760px"
destroy-on-close
>
<div class="api-form">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="接口名称">
<el-input v-model="formState.name" placeholder="登录接口" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目录">
<el-input v-model="formState.folder_path" placeholder="auth/login" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="Method">
<el-select v-model="formState.method" class="full-width">
<el-option v-for="item in methodOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="URL">
<el-input v-model="formState.url" placeholder="/api/example" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="超时(秒)">
<el-input-number v-model="formState.timeout_seconds" :min="1" :step="1" class="full-width" />
</el-form-item>
<el-divider content-position="left">请求参数</el-divider>
<RequestParamsTabs
v-model:header-rows="headerRows"
v-model:body-text="formState.body_text"
v-model:query-text="formState.query_text"
v-model:path-param-rows="pathParamRows"
/>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</div>
</template>
</el-drawer>
<FolderCreateDialog
v-model="folderDialogVisible"
target="apis"
:mode="folderDialogMode"
:edit-path="folderDialogEditPath"
:parent-path="folderDialogParentPath"
:folder-options="folderOptions"
@success="handleFolderCreated"
/>
</div>
</template>

View File

@ -0,0 +1,248 @@
<script setup>
import { Connection, FolderAdd, Key, MagicStick, Refresh, Setting, StarFilled, VideoPlay } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { apiGet } from "../api/http";
import FolderCreateDialog from "../components/FolderCreateDialog.vue";
import { loadPinnedScripts, removePinnedScript } from "../utils/sshPinnedScripts";
import { promptAndExecutePinnedScript } from "../utils/sshScriptRunner";
const stats = reactive({
apis: "-",
workflows: "-",
mocks: "-",
sshProfiles: "-",
});
const loading = ref(false);
const loadError = ref("");
const pinnedScripts = ref([]);
const workflowFolderLoading = ref(false);
const workflowFolderPaths = ref([]);
const workflowFolderDialogVisible = ref(false);
const workflowFolderDialogMode = ref("create");
const workflowFolderDialogParentPath = ref("");
const workflowFolderDialogEditPath = ref("");
const overviewCards = [
{
key: "apis",
title: "接口定义",
description: "当前接口定义总数。",
icon: Connection,
},
{
key: "workflows",
title: "工作流编排",
description: "当前工作流总数。",
icon: VideoPlay,
},
{
key: "mocks",
title: "Mock 数据",
description: "当前 Mock 数据集总数。",
icon: MagicStick,
},
{
key: "sshProfiles",
title: "SSH 主机",
description: "已登记的 SSH 主机配置总数。",
icon: Key,
},
];
function loadPinnedList() {
pinnedScripts.value = loadPinnedScripts();
}
async function loadDashboardStats() {
loading.value = true;
loadError.value = "";
try {
const [apis, workflows, mocks, sshProfiles] = await Promise.all([
apiGet("/api/apis"),
apiGet("/api/workflows"),
apiGet("/api/mocks"),
apiGet("/api/ssh-profiles"),
]);
stats.apis = String(apis.length);
stats.workflows = String(workflows.length);
stats.mocks = String(mocks.length);
stats.sshProfiles = String(sshProfiles.length);
} catch (error) {
loadError.value = error instanceof Error ? error.message : "加载统计失败";
} finally {
loading.value = false;
}
}
async function runPinnedScript(item) {
await promptAndExecutePinnedScript(item);
}
function unpinScript(item) {
removePinnedScript(item.scriptId);
loadPinnedList();
ElMessage.success("已取消首页置顶");
}
async function loadWorkflowFolders() {
workflowFolderLoading.value = true;
try {
const data = await apiGet("/api/folders?target=workflows");
workflowFolderPaths.value = data.folders || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "工作流目录加载失败");
} finally {
workflowFolderLoading.value = false;
}
}
function openWorkflowFolderDialog(parentPath = "") {
workflowFolderDialogMode.value = "create";
workflowFolderDialogEditPath.value = "";
workflowFolderDialogParentPath.value = parentPath;
workflowFolderDialogVisible.value = true;
}
function openEditWorkflowFolderDialog(folderPath) {
workflowFolderDialogMode.value = "edit";
workflowFolderDialogEditPath.value = folderPath;
workflowFolderDialogParentPath.value = "";
workflowFolderDialogVisible.value = true;
}
async function handleWorkflowFolderCreated() {
await loadWorkflowFolders();
}
onMounted(() => {
loadPinnedList();
loadDashboardStats();
loadWorkflowFolders();
});
</script>
<template>
<div class="dashboard-page">
<el-row :gutter="18">
<el-col v-for="card in overviewCards" :key="card.title" :xs="24" :sm="12" :xl="6">
<el-card shadow="hover" class="overview-card">
<div class="overview-card__icon">
<el-icon :size="20"><component :is="card.icon" /></el-icon>
</div>
<div class="overview-card__body">
<span>{{ card.title }}</span>
<strong>{{ stats[card.key] }}</strong>
<p>{{ card.description }}</p>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="panel-card dashboard-folder-card" shadow="never" v-loading="workflowFolderLoading">
<template #header>
<div class="panel-card__header">
<div>
<strong>工作流目录</strong>
<p>保存或移动工作流到非根目录前需先在此登记目录路径</p>
</div>
<div class="toolbar-actions">
<el-tag type="info" effect="plain">{{ workflowFolderPaths.length }} 个目录</el-tag>
<el-button :icon="Refresh" @click="loadWorkflowFolders">刷新</el-button>
<el-button type="primary" :icon="FolderAdd" @click="openWorkflowFolderDialog()">新建目录</el-button>
</div>
</div>
</template>
<div v-if="workflowFolderPaths.length" class="dashboard-folder-list">
<div v-for="path in workflowFolderPaths" :key="path" class="dashboard-folder-item">
<code>{{ path }}</code>
<div class="dashboard-folder-item__actions">
<el-button text :icon="Setting" @click="openEditWorkflowFolderDialog(path)">编辑</el-button>
<el-button text :icon="FolderAdd" @click="openWorkflowFolderDialog(path)">子目录</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无工作流目录,点击「新建目录」创建" />
</el-card>
<FolderCreateDialog
v-model="workflowFolderDialogVisible"
target="workflows"
:mode="workflowFolderDialogMode"
:edit-path="workflowFolderDialogEditPath"
:parent-path="workflowFolderDialogParentPath"
:folder-options="workflowFolderPaths"
@success="handleWorkflowFolderCreated"
/>
<section class="dashboard-pinned-section">
<div class="dashboard-pinned-section__header">
<div>
<strong>置顶 SSH 脚本</strong>
<p> SSH 管理页脚本旁的星标置顶在此执行将弹出独立终端窗口可拖动可最小化</p>
</div>
<div class="toolbar-actions">
<el-tag class="dashboard-pinned-count" type="info" effect="plain">{{ pinnedScripts.length }} 个置顶</el-tag>
<el-button :icon="Refresh" @click="loadPinnedList">刷新</el-button>
</div>
</div>
<div v-if="pinnedScripts.length" class="dashboard-pinned-grid">
<div v-for="item in pinnedScripts" :key="item.scriptId" class="dashboard-pinned-item">
<div class="dashboard-pinned-item__head">
<div class="dashboard-pinned-item__title">
<span class="dashboard-pinned-item__icon">
<el-icon><Key /></el-icon>
</span>
<div>
<strong>{{ item.scriptName }}</strong>
<p>{{ item.profileName }}</p>
</div>
</div>
<el-icon class="dashboard-pinned-item__star"><StarFilled /></el-icon>
</div>
<div class="dashboard-pinned-item__host">
<span>目标主机</span>
<code>{{ item.profileHost }}</code>
</div>
<p class="dashboard-pinned-item__desc">{{ item.description || "无说明" }}</p>
<div class="dashboard-pinned-item__actions">
<el-button type="primary" :icon="VideoPlay" @click="runPinnedScript(item)">执行</el-button>
<el-button @click="unpinScript(item)">取消置顶</el-button>
</div>
</div>
</div>
<div v-else class="dashboard-pinned-empty dashboard-pinned-empty-card">
<el-empty>
<template #description>
<strong>暂无置顶脚本</strong>
<p> SSH 管理页为常用脚本点击星标后这里会显示快捷执行入口</p>
</template>
</el-empty>
</div>
</section>
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>系统概览</strong>
<p>工作台保留总览统计具体资源在左侧菜单进入独立管理页面</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboardStats">刷新统计</el-button>
</div>
</template>
<el-alert
v-if="loadError"
:closable="false"
class="dashboard-alert"
title="后端连接失败"
type="error"
:description="loadError"
/>
</el-card>
</div>
</template>

View File

@ -0,0 +1,74 @@
<script setup>
import { Lock, User } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiPost } from "../api/http";
import { setSession } from "../auth/session";
const router = useRouter();
const loading = ref(false);
const formState = reactive({
username: "admin",
password: "admin123456",
});
async function submitLogin() {
if (!formState.username.trim() || !formState.password.trim()) {
ElMessage.warning("请输入用户名和密码");
return;
}
loading.value = true;
try {
const data = await apiPost("/api/auth/login", {
username: formState.username.trim(),
password: formState.password,
});
setSession(data.token, data.user);
ElMessage.success("登录成功");
router.replace("/");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<div class="login-page__backdrop"></div>
<el-card class="login-card" shadow="never">
<div class="login-card__meta">
<span class="login-card__eyebrow">QUALITY INSPECTION</span>
<h1>登录质量检测平台</h1>
<p>资源按创建人隔离超管可查看全量数据</p>
</div>
<el-form label-position="top" @submit.prevent="submitLogin">
<el-form-item label="用户名">
<el-input v-model="formState.username" :prefix-icon="User" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="formState.password"
:prefix-icon="Lock"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="submitLogin"
/>
</el-form-item>
<el-button type="primary" class="login-card__submit" :loading="loading" @click="submitLogin">
登录
</el-button>
</el-form>
<div class="login-card__hint">
<span>默认超管账号admin</span>
<span>默认密码admin123456</span>
</div>
</el-card>
</div>
</template>

View File

@ -0,0 +1,369 @@
<script setup>
import { CircleCheck, Edit, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { apiGet, apiPost } from "../api/http";
const loading = ref(false);
const saving = ref(false);
const searchKeyword = ref("");
const drawerVisible = ref(false);
const toolConfigs = ref([]);
const toolSpecs = ref([]);
const formState = reactive({
id: null,
name: "",
enabled: true,
config_text: "{}",
});
const isEditing = computed(() => Number.isInteger(formState.id));
const filteredConfigs = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return toolConfigs.value;
return toolConfigs.value.filter((item) =>
[item.name, item.creator_name, JSON.stringify(item.config || {})].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
});
function prettyJson(value) {
return JSON.stringify(value ?? {}, null, 2);
}
function resetForm() {
formState.id = null;
formState.name = "";
formState.enabled = true;
formState.config_text = "{}";
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openFromSpec(spec) {
const existing = toolConfigs.value.find((item) => item.name === spec.name);
if (existing) {
openEditDrawer(existing);
return;
}
resetForm();
formState.name = spec.name || "";
drawerVisible.value = true;
}
function openEditDrawer(row) {
formState.id = row.id;
formState.name = row.name || "";
formState.enabled = Boolean(row.enabled);
formState.config_text = prettyJson(row.config || {});
drawerVisible.value = true;
}
function parseConfigText() {
try {
const parsed = JSON.parse(formState.config_text || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("配置必须是 JSON 对象");
}
return parsed;
} catch (error) {
throw new Error(error instanceof Error ? error.message : "配置解析失败");
}
}
function specFieldSummary(spec) {
const schema = spec.input_schema || {};
const properties = Object.keys(schema.properties || {});
if (!properties.length) return "无需额外参数";
return `参数:${properties.join(" / ")}`;
}
async function loadPage() {
loading.value = true;
try {
const [configResult, specResult] = await Promise.allSettled([apiGet("/api/mcp-tools"), apiGet("/mcp/tools")]);
if (configResult.status === "fulfilled") {
toolConfigs.value = configResult.value || [];
} else {
toolConfigs.value = [];
ElMessage.error(configResult.reason instanceof Error ? configResult.reason.message : "MCP 配置加载失败");
}
if (specResult.status === "fulfilled") {
toolSpecs.value = specResult.value?.tools || [];
} else {
toolSpecs.value = [];
ElMessage.warning(specResult.reason instanceof Error ? specResult.reason.message : "MCP 工具目录加载失败");
}
} finally {
loading.value = false;
}
}
async function submitForm() {
if (!formState.name.trim()) {
ElMessage.warning("工具名不能为空");
return;
}
let config;
try {
config = parseConfigText();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置校验失败");
return;
}
saving.value = true;
try {
await apiPost("/api/mcp-tools", {
name: formState.name.trim(),
enabled: Boolean(formState.enabled),
config,
});
ElMessage.success(isEditing.value ? "MCP 配置已更新" : "MCP 配置已创建");
drawerVisible.value = false;
await loadPage();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
onMounted(() => {
loadPage();
});
</script>
<template>
<div class="page-stack">
<el-row :gutter="18">
<el-col :xs="24" :xl="16">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>MCP 配置</strong>
<p>维护工具启用状态和工具级附加配置</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadPage">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建配置</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索工具名、创建人或配置 JSON"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-tag type="info" effect="plain">已配置 {{ filteredConfigs.length }}</el-tag>
</div>
<el-table :data="filteredConfigs" stripe v-loading="loading">
<el-table-column label="工具名" min-width="220">
<template #default="{ row }">
<div class="mcp-tool-cell">
<div class="mcp-tool-cell__icon">
<el-icon><Setting /></el-icon>
</div>
<div>
<strong>{{ row.name }}</strong>
<p>{{ row.creator_name || "--" }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" effect="light">
{{ row.enabled ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="配置预览" min-width="360">
<template #default="{ row }">
<pre class="mcp-config-preview">{{ prettyJson(row.config || {}) }}</pre>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text :icon="Edit" @click="openEditDrawer(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :xl="8">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>内置 MCP 工具目录</strong>
<p>点击后可快速创建或编辑对应配置</p>
</div>
</div>
</template>
<div class="mcp-spec-list" v-loading="loading">
<button
v-for="spec in toolSpecs"
:key="spec.name"
type="button"
class="mcp-spec-card"
@click="openFromSpec(spec)"
>
<div class="mcp-spec-card__head">
<strong>{{ spec.name }}</strong>
<el-icon><CircleCheck /></el-icon>
</div>
<p>{{ spec.description || "暂无说明" }}</p>
<span>{{ specFieldSummary(spec) }}</span>
</button>
<el-empty v-if="!toolSpecs.length && !loading" description="暂无可用 MCP 工具目录" />
</div>
</el-card>
</el-col>
</el-row>
<el-drawer
v-model="drawerVisible"
:title="isEditing ? '编辑 MCP 配置' : '新建 MCP 配置'"
size="560px"
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="工具名">
<el-input v-model="formState.name" placeholder="workflow_run / catalog_snapshot" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="formState.enabled" inline-prompt active-text="启用" inactive-text="禁用" />
</el-form-item>
<el-form-item label="配置 JSON">
<el-input v-model="formState.config_text" type="textarea" :rows="18" class="code-textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存配置</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.mcp-tool-cell {
display: flex;
align-items: center;
gap: 12px;
}
.mcp-tool-cell__icon {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #eef5ff 0%, #e4fff7 100%);
color: #4f79b3;
}
.mcp-tool-cell strong,
.mcp-tool-cell p {
margin: 0;
}
.mcp-tool-cell p {
margin-top: 4px;
color: #8a9ab1;
font-size: 12px;
}
.mcp-config-preview {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.7;
color: #4a5b73;
}
.mcp-spec-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-spec-card {
border: 1px solid #e4ebf5;
border-radius: 18px;
padding: 14px 16px;
text-align: left;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.mcp-spec-card:hover {
transform: translateY(-1px);
border-color: #bed2fb;
box-shadow: 0 14px 28px rgba(54, 98, 172, 0.1);
}
.mcp-spec-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mcp-spec-card strong,
.mcp-spec-card p,
.mcp-spec-card span {
display: block;
}
.mcp-spec-card p {
margin: 10px 0 8px;
color: #516173;
font-size: 13px;
line-height: 1.7;
}
.mcp-spec-card span {
color: #8a9ab1;
font-size: 12px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -0,0 +1,764 @@
<script setup>
import {
Delete,
Document,
Edit,
Folder,
Monitor,
Plus,
Refresh,
Star,
StarFilled,
SwitchButton,
Upload,
VideoPlay,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiDelete, apiGet, apiPost, apiPut, apiUploadForm } from "../api/http";
import { loadPinnedScripts, togglePinScript } from "../utils/sshPinnedScripts";
import {
activeTabSessionId as activeRunTabId,
cleanupTabSessions,
fitSessionTerminal,
promptAndExecuteScript as runScriptInTab,
registerTerminalContainer,
removeTabSession,
sessions,
stopSession,
tabSessionIds,
} from "../utils/sshScriptRunner";
const route = useRoute();
const router = useRouter();
const treeLoading = ref(false);
const profileSaving = ref(false);
const scriptSaving = ref(false);
const drawerVisible = ref(false);
const scriptDrawerVisible = ref(false);
const uploadDrawerVisible = ref(false);
const editingProfileId = ref(null);
const editingScriptId = ref(null);
const treeKeyword = ref("");
const treeData = ref([]);
const selectedNodeKey = ref("");
const pinnedScriptIds = ref(new Set());
const uploadProfileId = ref(null);
const uploadForm = reactive({
name: "",
description: "",
file: null,
});
const profileForm = reactive({
name: "",
host: "",
port: 22,
username: "",
auth_type: "key",
key_path: "",
});
const scriptForm = reactive({
profile_id: null,
name: "",
description: "",
content: "",
});
const runSessions = computed(() =>
tabSessionIds.value.map((id) => sessions.value.find((item) => item.id === id)).filter(Boolean)
);
const activeSession = computed(
() => runSessions.value.find((session) => session.id === activeRunTabId.value) || null
);
const hasRunningSession = computed(() =>
runSessions.value.some((session) => session.status === "running" || session.status === "connecting")
);
let resizeObserver = null;
const filteredTreeData = computed(() => {
const keyword = treeKeyword.value.trim().toLowerCase();
if (!keyword) {
return buildTreeNodes(treeData.value);
}
const filtered = [];
for (const profile of treeData.value) {
const profileMatched = [profile.name, profile.host, profile.username].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
);
const scripts = (profile.scripts || []).filter((script) =>
[script.name, script.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
if (profileMatched || scripts.length) {
filtered.push({
...profile,
scripts: profileMatched ? profile.scripts || [] : scripts,
});
}
}
return buildTreeNodes(filtered);
});
function buildTreeNodes(profiles) {
return profiles.map((profile) => ({
id: `profile-${profile.id}`,
label: profile.name,
nodeType: "profile",
profileId: profile.id,
profile,
children: (profile.scripts || []).map((script) => ({
id: `script-${script.id}`,
label: script.name,
nodeType: "script",
profileId: profile.id,
scriptId: script.id,
script,
profile,
isLeaf: true,
})),
}));
}
function refreshPinnedState() {
pinnedScriptIds.value = new Set(loadPinnedScripts().map((item) => Number(item.scriptId)));
}
function scriptIsPinned(scriptId) {
return pinnedScriptIds.value.has(Number(scriptId));
}
function handleTogglePin(data) {
const { script, profile } = data;
const pinned = togglePinScript({
scriptId: script.id,
profileId: profile.id,
scriptName: script.name,
profileName: profile.name,
profileHost: `${profile.username}@${profile.host}:${profile.port}`,
description: script.description || "",
});
refreshPinnedState();
ElMessage.success(pinned ? "已置顶到首页" : "已取消首页置顶");
}
function resetProfileForm() {
editingProfileId.value = null;
profileForm.name = "";
profileForm.host = "";
profileForm.port = 22;
profileForm.username = "";
profileForm.auth_type = "key";
profileForm.key_path = "";
}
function fillProfileForm(profile) {
editingProfileId.value = profile.id;
profileForm.name = profile.name;
profileForm.host = profile.host;
profileForm.port = Number(profile.port || 22);
profileForm.username = profile.username;
profileForm.auth_type = profile.auth_type || "key";
profileForm.key_path = profile.key_path || "";
}
function resetScriptForm(profileId = null) {
editingScriptId.value = null;
scriptForm.profile_id = profileId;
scriptForm.name = "";
scriptForm.description = "";
scriptForm.content = "";
}
function fillScriptForm(script, profileId) {
editingScriptId.value = script.id;
scriptForm.profile_id = profileId;
scriptForm.name = script.name || "";
scriptForm.description = script.description || "";
scriptForm.content = "";
}
async function loadScriptContent(scriptId) {
const row = await apiGet(`/api/ssh-scripts/${scriptId}`);
scriptForm.content = row.content || "";
}
function openCreateDrawer() {
resetProfileForm();
drawerVisible.value = true;
}
function openEditDrawer(profile) {
fillProfileForm(profile);
drawerVisible.value = true;
}
function openCreateScriptDrawer(profileId) {
resetScriptForm(profileId);
scriptDrawerVisible.value = true;
}
async function openEditScriptDrawer(script, profileId) {
fillScriptForm(script, profileId);
scriptDrawerVisible.value = true;
await loadScriptContent(script.id);
}
function openUploadDrawer(profileId) {
uploadProfileId.value = profileId;
uploadForm.name = "";
uploadForm.description = "";
uploadForm.file = null;
uploadDrawerVisible.value = true;
}
function buildProfilePayload() {
const payload = {
name: profileForm.name.trim(),
host: profileForm.host.trim(),
port: Number(profileForm.port || 22),
username: profileForm.username.trim(),
auth_type: profileForm.auth_type,
key_path: profileForm.key_path.trim(),
presets: [],
};
if (!payload.name || !payload.host || !payload.username) {
throw new Error("名称、主机和用户名不能为空");
}
if (!Number.isFinite(payload.port) || payload.port <= 0) {
throw new Error("端口必须是有效数字");
}
if (payload.auth_type === "key" && !payload.key_path) {
throw new Error("密钥认证需要填写 key_path");
}
return payload;
}
function buildScriptPayload() {
const payload = {
profile_id: Number(scriptForm.profile_id || 0),
name: scriptForm.name.trim(),
description: scriptForm.description.trim(),
source_type: "inline",
content: scriptForm.content.trim(),
};
if (!payload.profile_id) {
throw new Error("请选择所属 SSH 主机");
}
if (!payload.name || !payload.content) {
throw new Error("脚本名称和内容不能为空");
}
return payload;
}
async function loadTree() {
treeLoading.value = true;
try {
const rows = await apiGet("/api/ssh-tree");
treeData.value = rows;
refreshPinnedState();
if (selectedNodeKey.value) {
const stillExists = rows.some((profile) => {
if (selectedNodeKey.value === `profile-${profile.id}`) return true;
return (profile.scripts || []).some((script) => selectedNodeKey.value === `script-${script.id}`);
});
if (!stillExists) {
selectedNodeKey.value = "";
}
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "SSH 树加载失败");
} finally {
treeLoading.value = false;
}
}
async function submitProfile() {
let payload;
try {
payload = buildProfilePayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
profileSaving.value = true;
try {
if (editingProfileId.value) {
await apiPut(`/api/ssh-profiles/${editingProfileId.value}`, payload);
ElMessage.success("SSH 主机已更新");
} else {
await apiPost("/api/ssh-profiles", payload);
ElMessage.success("SSH 主机已创建");
}
drawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "SSH 主机保存失败");
} finally {
profileSaving.value = false;
}
}
async function submitScript() {
let payload;
try {
payload = buildScriptPayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
scriptSaving.value = true;
try {
if (editingScriptId.value) {
await apiPut(`/api/ssh-scripts/${editingScriptId.value}`, {
name: payload.name,
description: payload.description,
content: payload.content,
});
ElMessage.success("脚本已更新");
} else {
await apiPost("/api/ssh-scripts", payload);
ElMessage.success("脚本已创建");
}
scriptDrawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "脚本保存失败");
} finally {
scriptSaving.value = false;
}
}
async function submitUpload() {
if (!uploadProfileId.value) {
ElMessage.warning("请选择 SSH 主机");
return;
}
if (!uploadForm.file) {
ElMessage.warning("请选择脚本文件");
return;
}
const formData = new FormData();
formData.append("profile_id", String(uploadProfileId.value));
formData.append("name", uploadForm.name.trim());
formData.append("description", uploadForm.description.trim());
formData.append("file", uploadForm.file);
scriptSaving.value = true;
try {
await apiUploadForm("/api/ssh-scripts/upload", formData);
ElMessage.success("脚本文件已上传");
uploadDrawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "脚本上传失败");
} finally {
scriptSaving.value = false;
}
}
async function handleDeleteProfile(profile) {
try {
await ElMessageBox.confirm(`确认删除 SSH 主机「${profile.name}」及其脚本吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/ssh-profiles/${profile.id}`);
if (selectedNodeKey.value === `profile-${profile.id}`) {
selectedNodeKey.value = "";
}
ElMessage.success("SSH 主机已删除");
await loadTree();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
async function handleDeleteScript(script) {
try {
await ElMessageBox.confirm(`确认删除脚本「${script.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/ssh-scripts/${script.id}`);
if (selectedNodeKey.value === `script-${script.id}`) {
selectedNodeKey.value = "";
}
ElMessage.success("脚本已删除");
await loadTree();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
function handleTreeSelect(node) {
if (!node) return;
selectedNodeKey.value = node.id;
}
function handleTreeNodeClick(data) {
selectedNodeKey.value = data.id;
}
async function handleExecuteScript(data) {
const { script, profile } = data;
selectedNodeKey.value = `script-${script.id}`;
await runScriptInTab({ script, profile, target: "tab" });
}
function stopActiveSession() {
if (activeSession.value) {
stopSession(activeSession.value);
}
}
function handleTabRemove(tabId) {
removeTabSession(tabId);
}
function findTreeScript(profileId, scriptId) {
const profile = treeData.value.find((item) => Number(item.id) === Number(profileId));
if (!profile) return null;
const script = (profile.scripts || []).find((item) => Number(item.id) === Number(scriptId));
if (!script) return null;
return { script, profile };
}
async function maybeAutoRunFromQuery() {
if (route.query.run !== "1") return;
const profileId = Number(route.query.profile_id || 0);
const scriptId = Number(route.query.script_id || 0);
if (!profileId || !scriptId) return;
const target = findTreeScript(profileId, scriptId);
if (!target) {
ElMessage.warning("脚本不存在或已删除");
router.replace({ path: "/ssh" });
return;
}
selectedNodeKey.value = `script-${scriptId}`;
await runScriptInTab({
script: target.script,
profile: target.profile,
target: "tab",
});
router.replace({ path: "/ssh" });
}
function onUploadFileChange(file) {
uploadForm.file = file?.raw || null;
if (!uploadForm.name.trim() && file?.name) {
uploadForm.name = file.name;
}
}
watch(activeRunTabId, () => {
nextTick(() => fitSessionTerminal(activeRunTabId.value));
});
watch(
() => route.query.run,
() => {
if (treeData.value.length) {
maybeAutoRunFromQuery();
}
}
);
onMounted(async () => {
resetProfileForm();
resetScriptForm();
refreshPinnedState();
await loadTree();
await maybeAutoRunFromQuery();
const mainPanel = document.querySelector(".ssh-page__main");
if (mainPanel) {
resizeObserver = new ResizeObserver(() => fitSessionTerminal(activeRunTabId.value));
resizeObserver.observe(mainPanel);
}
});
onBeforeUnmount(() => {
cleanupTabSessions();
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<template>
<div class="ssh-page">
<aside class="ssh-page__sidebar panel-card" v-loading="treeLoading">
<div class="panel-card__header ssh-page__sidebar-header">
<div>
<strong>SSH 脚本</strong>
<p>按主机维护脚本选择脚本后可执行置顶或编辑</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadTree">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新增主机</el-button>
</div>
</div>
<el-input v-model="treeKeyword" placeholder="搜索主机或脚本" clearable class="ssh-page__search">
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
<el-scrollbar class="ssh-page__tree-scroll">
<el-tree
v-if="filteredTreeData.length"
:data="filteredTreeData"
node-key="id"
highlight-current
default-expand-all
:expand-on-click-node="false"
:current-node-key="selectedNodeKey"
@node-click="handleTreeNodeClick"
@current-change="handleTreeSelect"
>
<template #default="{ data }">
<div class="ssh-tree-node">
<span class="ssh-tree-node__label">
<span class="ssh-tree-node__icon">
<el-icon v-if="data.nodeType === 'profile'"><Monitor /></el-icon>
<el-icon v-else><Document /></el-icon>
</span>
<span class="ssh-tree-node__text">
<span class="ssh-tree-node__title">{{ data.label }}</span>
<span v-if="data.nodeType === 'profile'" class="ssh-tree-node__meta">
{{ data.profile.username }}@{{ data.profile.host }}:{{ data.profile.port }}
</span>
<span v-else-if="data.script.description" class="ssh-tree-node__meta">
{{ data.script.description }}
</span>
</span>
</span>
<span v-if="data.nodeType === 'profile'" class="ssh-tree-node__actions" @click.stop>
<el-button text :icon="Plus" @click="openCreateScriptDrawer(data.profileId)">脚本</el-button>
<el-button text :icon="Upload" @click="openUploadDrawer(data.profileId)">上传</el-button>
<el-button text :icon="Edit" @click="openEditDrawer(data.profile)" />
<el-button text type="danger" :icon="Delete" @click="handleDeleteProfile(data.profile)" />
</span>
<span v-else class="ssh-tree-node__actions" @click.stop>
<el-button text type="primary" :icon="VideoPlay" @click="handleExecuteScript(data)">执行</el-button>
<el-button
text
:type="scriptIsPinned(data.scriptId) ? 'warning' : 'default'"
@click="handleTogglePin(data)"
>
<el-icon><StarFilled v-if="scriptIsPinned(data.scriptId)" /><Star v-else /></el-icon>
</el-button>
<el-button text :icon="Edit" @click="openEditScriptDrawer(data.script, data.profileId)" />
<el-button text type="danger" :icon="Delete" @click="handleDeleteScript(data.script)" />
</span>
</div>
</template>
</el-tree>
<el-empty v-else description="还没有 SSH 主机,先新增主机并添加脚本" />
</el-scrollbar>
</aside>
<section class="ssh-page__main panel-card">
<div class="ssh-run-panel">
<div class="ssh-run-panel__toolbar">
<div class="ssh-run-panel__title">
<strong>执行日志</strong>
<p>脚本运行输出按 Tab 保留切换 Tab 查看不同会话</p>
</div>
<div class="toolbar-actions">
<el-button
type="danger"
:icon="SwitchButton"
:disabled="!activeSession || (activeSession.status !== 'running' && activeSession.status !== 'connecting')"
@click="stopActiveSession"
>
停止当前脚本
</el-button>
<el-tag v-if="hasRunningSession" type="success" effect="plain">有脚本正在运行</el-tag>
</div>
</div>
<el-tabs
v-if="runSessions.length"
v-model="activeRunTabId"
type="card"
class="ssh-run-tabs"
closable
@tab-remove="handleTabRemove"
>
<el-tab-pane v-for="session in runSessions" :key="session.id" :name="session.id" :closable="true">
<template #label>
<span class="ssh-run-tab__label">
<span>{{ session.scriptName }}</span>
<el-tag
size="small"
:type="
session.status === 'running' || session.status === 'connecting'
? 'success'
: session.status === 'failed'
? 'danger'
: session.status === 'success'
? 'info'
: 'warning'
"
effect="plain"
>
{{
session.status === "running" || session.status === "connecting"
? "运行中"
: session.status === "success"
? "完成"
: session.status === "failed"
? "失败"
: "已停止"
}}
</el-tag>
</span>
</template>
<div class="ssh-terminal-shell ssh-page__terminal">
<div class="ssh-terminal-shell__meta">
<span>{{ session.statusText }}</span>
<span>{{ session.profileHost }}</span>
</div>
<div
:ref="(el) => registerTerminalContainer(session.id, el)"
class="ssh-terminal-canvas ssh-page__terminal-canvas"
/>
</div>
</el-tab-pane>
</el-tabs>
<div v-else class="ssh-run-empty">
<el-empty description="在左侧树中执行脚本,将自动打开日志 Tab" />
</div>
</div>
</section>
<el-drawer
v-model="drawerVisible"
:title="editingProfileId ? '编辑 SSH 主机' : '新增 SSH 主机'"
size="640px"
destroy-on-close
>
<el-form label-position="top">
<div class="ssh-drawer-grid">
<el-form-item label="名称">
<el-input v-model="profileForm.name" placeholder="例如:测试机-01" />
</el-form-item>
<el-form-item label="主机">
<el-input v-model="profileForm.host" placeholder="例如10.10.10.8" />
</el-form-item>
<el-form-item label="端口">
<el-input-number v-model="profileForm.port" :min="1" :max="65535" controls-position="right" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="profileForm.username" placeholder="例如root" />
</el-form-item>
<el-form-item label="认证方式">
<el-segmented
v-model="profileForm.auth_type"
:options="[
{ label: '密钥', value: 'key' },
{ label: '密码', value: 'password' },
]"
/>
</el-form-item>
<el-form-item label="Key Path">
<el-input
v-model="profileForm.key_path"
:disabled="profileForm.auth_type !== 'key'"
placeholder="例如:/Users/name/.ssh/id_rsa"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="profileSaving" @click="submitProfile">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="scriptDrawerVisible" :title="editingScriptId ? '编辑脚本' : '新增脚本'" size="720px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="所属主机">
<el-select v-model="scriptForm.profile_id" placeholder="选择 SSH 主机" :disabled="Boolean(editingScriptId)">
<el-option
v-for="profile in treeData"
:key="profile.id"
:label="`${profile.name} (${profile.username}@${profile.host})`"
:value="profile.id"
/>
</el-select>
</el-form-item>
<el-form-item label="脚本名称">
<el-input v-model="scriptForm.name" placeholder="例如:部署应用" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="scriptForm.description" placeholder="可选" />
</el-form-item>
<el-form-item label="脚本内容">
<el-input
v-model="scriptForm.content"
type="textarea"
:rows="16"
resize="vertical"
placeholder="#!/bin/bash&#10;echo hello"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="scriptDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="scriptSaving" @click="submitScript">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="uploadDrawerVisible" title="上传脚本文件" size="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="脚本名称">
<el-input v-model="uploadForm.name" placeholder="默认识别文件名" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="uploadForm.description" placeholder="可选" />
</el-form-item>
<el-form-item label="脚本文件">
<el-upload :auto-upload="false" :limit="1" accept=".sh,.bash,.txt" :on-change="onUploadFileChange">
<el-button :icon="Upload">选择文件</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="uploadDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="scriptSaving" @click="submitUpload">上传</el-button>
</div>
</template>
</el-drawer>
</div>
</template>

View File

@ -0,0 +1,241 @@
<script setup>
import { CircleCheck, Lock, Plus, Refresh, Search, User } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { apiGet, apiPost, apiPut } from "../api/http";
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const searchKeyword = ref("");
const users = ref([]);
const formState = reactive({
id: null,
username: "",
display_name: "",
role: "user",
is_active: true,
password: "",
});
const isEditing = computed(() => Number.isInteger(formState.id));
const filteredUsers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return users.value;
return users.value.filter((item) =>
[item.username, item.display_name, item.role].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
});
function resetForm() {
formState.id = null;
formState.username = "";
formState.display_name = "";
formState.role = "user";
formState.is_active = true;
formState.password = "";
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openEditDrawer(row) {
formState.id = row.id;
formState.username = row.username;
formState.display_name = row.display_name || "";
formState.role = row.role || "user";
formState.is_active = Boolean(row.is_active);
formState.password = "";
drawerVisible.value = true;
}
async function loadUsers() {
loading.value = true;
try {
users.value = await apiGet("/api/users");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "用户列表加载失败");
} finally {
loading.value = false;
}
}
async function submitForm() {
const payload = {
display_name: formState.display_name.trim() || formState.username.trim(),
role: formState.role,
is_active: Boolean(formState.is_active),
password: formState.password,
};
if (!isEditing.value) {
if (!formState.username.trim() || !formState.password.trim()) {
ElMessage.warning("新建用户时,用户名和密码不能为空");
return;
}
}
saving.value = true;
try {
if (isEditing.value) {
await apiPut(`/api/users/${formState.id}`, payload);
ElMessage.success("用户已更新");
} else {
await apiPost("/api/users", {
username: formState.username.trim(),
password: formState.password,
display_name: payload.display_name,
role: payload.role,
});
ElMessage.success("用户已创建");
}
drawerVisible.value = false;
await loadUsers();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
async function toggleActive(row, nextValue) {
try {
await apiPut(`/api/users/${row.id}`, {
display_name: row.display_name,
role: row.role,
is_active: nextValue,
password: "",
});
ElMessage.success(nextValue ? "用户已启用" : "用户已停用");
await loadUsers();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "状态更新失败");
}
}
onMounted(() => {
loadUsers();
});
</script>
<template>
<div class="page-stack">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>用户管理</strong>
<p>仅超管可访问支持创建修改角色启停用和重置密码</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadUsers">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建用户</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名、显示名或角色"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-tag type="info" effect="plain"> {{ filteredUsers.length }} 位用户</el-tag>
</div>
<el-table :data="filteredUsers" stripe v-loading="loading">
<el-table-column label="用户" min-width="220">
<template #default="{ row }">
<div class="user-cell">
<div class="user-cell__avatar">
<el-icon><User /></el-icon>
</div>
<div>
<strong>{{ row.display_name }}</strong>
<p>{{ row.username }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="130">
<template #default="{ row }">
<el-tag :type="row.role === 'superadmin' ? 'danger' : 'primary'" effect="light">
{{ row.role === "superadmin" ? "超管" : "普通用户" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="140">
<template #default="{ row }">
<el-switch
:model-value="row.is_active"
inline-prompt
active-text="启用"
inactive-text="停用"
@change="toggleActive(row, $event)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button text :icon="CircleCheck" @click="openEditDrawer(row)">编辑</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
:title="isEditing ? '编辑用户' : '新建用户'"
size="520px"
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="formState.username" :disabled="isEditing" placeholder="alice" />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="formState.display_name" placeholder="Alice" />
</el-form-item>
<el-form-item label="角色">
<el-radio-group v-model="formState.role">
<el-radio-button label="user">普通用户</el-radio-button>
<el-radio-button label="superadmin">超管</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="isEditing" label="账号状态">
<el-switch v-model="formState.is_active" inline-prompt active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item :label="isEditing ? '重置密码(留空则不修改)' : '初始密码'">
<el-input
v-model="formState.password"
:prefix-icon="Lock"
type="password"
show-password
placeholder="请输入密码"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>

View File

@ -0,0 +1,718 @@
<script setup>
import { Link, Plus, Refresh, VideoPlay } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { apiGet, apiPost, apiPut } from "../api/http";
const pageTab = ref("batch");
const loading = ref(false);
const batchSaving = ref(false);
const batchRunning = ref(false);
const historyLoading = ref(false);
const replayLoading = ref(false);
const lokiLoading = ref(false);
const workflows = ref([]);
const batches = ref([]);
const activeBatchId = ref(null);
const activeBatch = ref(null);
const workflowTableRef = ref(null);
const batchForm = ref({
name: "",
base_url: "",
fail_fast: true,
});
const selectedWorkflowIds = ref([]);
const runs = ref([]);
const historyWorkflowFilter = ref("");
const selectedRun = ref(null);
const runDetailVisible = ref(false);
const activeNodeId = ref("");
const lokiLink = ref(null);
const activeBatchIsDraft = computed(() => activeBatch.value?.status === "draft");
const draftBatches = computed(() => batches.value.filter((item) => item.status === "draft"));
const executedBatches = computed(() => batches.value.filter((item) => item.status !== "draft"));
const historyWorkflowOptions = computed(() => {
const map = new Map();
runs.value.forEach((item) => {
if (item.workflow_id) {
map.set(item.workflow_id, item.workflow_name || `工作流 #${item.workflow_id}`);
}
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
});
const runNodeResults = computed(() => {
const payload = selectedRun.value?.payload || {};
if (selectedRun.value?.run_type === "node") {
const result = payload.result;
return result ? [result] : [];
}
return Array.isArray(payload.results) ? payload.results : [];
});
const activeNodeResult = computed(() =>
runNodeResults.value.find((item) => String(item.node_id) === String(activeNodeId.value))
);
async function loadWorkflows() {
loading.value = true;
try {
workflows.value = await apiGet("/api/workflows");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "工作流列表加载失败");
} finally {
loading.value = false;
}
}
async function loadBatches() {
try {
const data = await apiGet("/api/workflow-batches?limit=50");
batches.value = data.items || [];
if (activeBatchId.value && !batches.value.some((item) => item.id === activeBatchId.value)) {
activeBatchId.value = null;
activeBatch.value = null;
}
} catch {
batches.value = [];
}
}
async function loadActiveBatchDetail() {
if (!activeBatchId.value) {
activeBatch.value = null;
selectedWorkflowIds.value = [];
return;
}
try {
activeBatch.value = await apiGet(`/api/workflow-batches/${activeBatchId.value}`);
batchForm.value = {
name: activeBatch.value.name || "",
base_url: activeBatch.value.base_url || "",
fail_fast: activeBatch.value.fail_fast !== false,
};
selectedWorkflowIds.value = [...(activeBatch.value.workflow_ids || [])];
await nextTick();
syncWorkflowTableSelection();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "批次详情加载失败");
activeBatch.value = null;
}
}
function syncWorkflowTableSelection() {
const table = workflowTableRef.value;
if (!table) return;
table.clearSelection();
const idSet = new Set(selectedWorkflowIds.value.map((id) => Number(id)));
workflows.value.forEach((row) => {
if (idSet.has(Number(row.id))) {
table.toggleRowSelection(row, true);
}
});
}
async function loadRunHistory() {
historyLoading.value = true;
try {
const params = new URLSearchParams({ limit: "80" });
if (historyWorkflowFilter.value) {
params.set("workflow_id", String(historyWorkflowFilter.value));
}
const data = await apiGet(`/api/workflow-runs?${params.toString()}`);
runs.value = data.items || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "执行历史加载失败");
} finally {
historyLoading.value = false;
}
}
async function createBatch() {
batchSaving.value = true;
try {
const created = await apiPost("/api/workflow-batches", {
name: "",
base_url: "",
fail_fast: true,
workflow_ids: [],
});
await loadBatches();
activeBatchId.value = created.id;
await loadActiveBatchDetail();
ElMessage.success(`已创建批跑任务 #${created.id},请选择工作流后保存并执行`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "创建批跑失败");
} finally {
batchSaving.value = false;
}
}
async function saveActiveBatch() {
if (!activeBatchId.value || !activeBatchIsDraft.value) return;
batchSaving.value = true;
try {
const updated = await apiPut(`/api/workflow-batches/${activeBatchId.value}`, {
name: batchForm.value.name.trim(),
base_url: batchForm.value.base_url.trim(),
fail_fast: batchForm.value.fail_fast,
workflow_ids: selectedWorkflowIds.value,
});
activeBatch.value = updated;
await loadBatches();
ElMessage.success("批跑任务已保存");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
batchSaving.value = false;
}
}
async function runActiveBatch() {
if (!activeBatchId.value || !activeBatchIsDraft.value) return;
if (!selectedWorkflowIds.value.length) {
ElMessage.warning("请先选择至少一个工作流并保存");
return;
}
try {
await ElMessageBox.confirm(
`将执行批跑 #${activeBatchId.value},共 ${selectedWorkflowIds.value.length} 个工作流。是否继续?`,
"执行批跑",
{ type: "info", confirmButtonText: "执行", cancelButtonText: "取消" }
);
} catch {
return;
}
batchRunning.value = true;
try {
await saveActiveBatch();
const result = await apiPost(`/api/workflow-batches/${activeBatchId.value}/run`, {});
ElMessage.success(`批跑完成:${result.status}`);
await Promise.all([loadBatches(), loadRunHistory()]);
pageTab.value = "history";
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "批跑执行失败");
} finally {
batchRunning.value = false;
}
}
function selectBatch(row) {
activeBatchId.value = row.id;
}
function handleWorkflowSelection(rows) {
if (!activeBatchIsDraft.value) return;
selectedWorkflowIds.value = rows.map((item) => item.id);
}
async function openRunDetail(row) {
historyLoading.value = true;
try {
selectedRun.value = await apiGet(`/api/workflow-runs/${row.id}`);
const firstHttp = runNodeResults.value.find((item) => item.node_type === "http");
activeNodeId.value = firstHttp ? String(firstHttp.node_id) : String(runNodeResults.value[0]?.node_id || "");
lokiLink.value = null;
runDetailVisible.value = true;
if (activeNodeId.value) {
await loadLokiLink();
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "执行详情加载失败");
} finally {
historyLoading.value = false;
}
}
async function loadLokiLink() {
if (!selectedRun.value?.id || !activeNodeId.value) {
lokiLink.value = null;
return;
}
lokiLoading.value = true;
try {
const params = new URLSearchParams({ node_id: activeNodeId.value });
lokiLink.value = await apiGet(`/api/workflow-runs/${selectedRun.value.id}/loki-link?${params.toString()}`);
} catch (error) {
lokiLink.value = { enabled: false, hint: error instanceof Error ? error.message : "Loki 链接生成失败" };
} finally {
lokiLoading.value = false;
}
}
async function handleNodeChange(nodeId) {
activeNodeId.value = nodeId;
await loadLokiLink();
}
async function replaySelectedRun() {
if (!selectedRun.value?.id) return;
try {
await ElMessageBox.confirm("将按该次执行的快照重放工作流,是否继续?", "重放确认", {
type: "info",
confirmButtonText: "重放",
cancelButtonText: "取消",
});
} catch {
return;
}
replayLoading.value = true;
try {
const request = selectedRun.value.payload?.request || {};
const result = await apiPost(`/api/workflow-runs/${selectedRun.value.id}/replay`, {
base_url: request.base_url || "",
fail_fast: request.fail_fast !== false,
});
ElMessage.success(`重放完成:${result.status}(新记录 #${result.run_id || "-"}`);
await loadRunHistory();
if (result.run_id) {
await openRunDetail({ id: result.run_id });
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "重放失败");
} finally {
replayLoading.value = false;
}
}
function openLokiExplore() {
if (!lokiLink.value?.explore_url) {
ElMessage.warning(lokiLink.value?.hint || "未配置 Loki 探索地址");
return;
}
window.open(lokiLink.value.explore_url, "_blank", "noopener,noreferrer");
}
function formatJson(value) {
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return String(value ?? "");
}
}
function statusTagType(status) {
if (status === "success") return "success";
if (status === "failed") return "danger";
if (status === "partial") return "warning";
if (status === "running") return "warning";
if (status === "draft") return "info";
return "info";
}
watch(activeBatchId, () => {
loadActiveBatchDetail();
});
onMounted(async () => {
await Promise.all([loadWorkflows(), loadBatches(), loadRunHistory()]);
});
</script>
<template>
<div class="page-stack workflow-batch-page">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>跑批工作流执行</strong>
<p>先创建批跑任务再勾选要执行的工作流保存后执行执行历史可查看响应重放与 Loki 日志</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading || historyLoading" @click="loadWorkflows(); loadBatches(); loadRunHistory();">
刷新
</el-button>
</div>
</div>
</template>
<el-tabs v-model="pageTab">
<el-tab-pane label="跑批任务" name="batch">
<div class="batch-layout">
<aside class="batch-layout__side">
<div class="toolbar-actions batch-layout__side-head">
<el-button type="primary" :icon="Plus" :loading="batchSaving" @click="createBatch">新建批跑</el-button>
</div>
<p class="batch-layout__section-title">待执行</p>
<div class="batch-list">
<button
v-for="item in draftBatches"
:key="item.id"
type="button"
class="batch-list__item"
:class="{ 'is-active': activeBatchId === item.id }"
@click="selectBatch(item)"
>
<div class="batch-list__item-head">
<strong>#{{ item.id }} {{ item.name }}</strong>
<el-tag size="small" type="info" effect="plain">draft</el-tag>
</div>
<span>已选 {{ item.workflow_ids?.length || 0 }} 个工作流</span>
</button>
<div v-if="!draftBatches.length" class="text-muted">暂无待执行批跑点击新建批跑创建</div>
</div>
<p class="batch-layout__section-title">已执行</p>
<div class="batch-list">
<button
v-for="item in executedBatches"
:key="item.id"
type="button"
class="batch-list__item"
:class="{ 'is-active': activeBatchId === item.id }"
@click="selectBatch(item)"
>
<div class="batch-list__item-head">
<strong>#{{ item.id }} {{ item.name }}</strong>
<el-tag size="small" :type="statusTagType(item.status)" effect="light">{{ item.status }}</el-tag>
</div>
<span>
成功 {{ item.summary?.success || 0 }} / 失败 {{ item.summary?.failed || 0 }}
</span>
</button>
</div>
</aside>
<section class="batch-layout__main">
<el-empty v-if="!activeBatchId" description="请选择或新建一个批跑任务" />
<template v-else>
<div class="batch-editor__head">
<div>
<strong>批跑 #{{ activeBatch.id }}{{ activeBatch.name }}</strong>
<p>
状态<el-tag size="small" :type="statusTagType(activeBatch.status)" effect="light">{{ activeBatch.status }}</el-tag>
· 已选 {{ selectedWorkflowIds.length }} 个工作流
</p>
</div>
<div v-if="activeBatchIsDraft" class="toolbar-actions">
<el-button :loading="batchSaving" @click="saveActiveBatch">保存</el-button>
<el-button type="primary" :icon="VideoPlay" :loading="batchRunning" @click="runActiveBatch">
执行批跑
</el-button>
</div>
</div>
<el-form label-position="top" class="batch-form" :disabled="!activeBatchIsDraft">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="批次名称">
<el-input v-model="batchForm.name" placeholder="例如:每日回归批跑" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Base URL">
<el-input v-model="batchForm.base_url" placeholder="https://api.example.com" />
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-checkbox v-model="batchForm.fail_fast">遇失败立即停止后续工作流</el-checkbox>
</el-form-item>
</el-form>
<el-divider content-position="left">选择工作流</el-divider>
<el-table
ref="workflowTableRef"
v-loading="loading"
:data="workflows"
row-key="id"
class="batch-workflow-table"
@selection-change="handleWorkflowSelection"
>
<el-table-column type="selection" width="48" :selectable="() => activeBatchIsDraft" />
<el-table-column prop="name" label="工作流" min-width="180" />
<el-table-column prop="folder_path" label="目录" min-width="140" />
<el-table-column label="最近执行" width="120">
<template #default="{ row }">
<el-tag v-if="row.last_run?.status" :type="statusTagType(row.last_run.status)" effect="light" size="small">
{{ row.last_run.status }}
</el-tag>
<span v-else class="text-muted">未执行</span>
</template>
</el-table-column>
</el-table>
<template v-if="!activeBatchIsDraft && activeBatch.runs?.length">
<el-divider content-position="left">本批次执行记录</el-divider>
<el-table :data="activeBatch.runs" size="small">
<el-table-column prop="id" label="记录" width="80" />
<el-table-column prop="workflow_name" label="工作流" min-width="160" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button text type="primary" @click="openRunDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</template>
</template>
</section>
</div>
</el-tab-pane>
<el-tab-pane label="执行历史" name="history">
<div class="history-toolbar">
<el-select v-model="historyWorkflowFilter" clearable placeholder="按工作流筛选" class="history-toolbar__filter" @change="loadRunHistory">
<el-option v-for="item in historyWorkflowOptions" :key="item.id" :label="item.name" :value="String(item.id)" />
</el-select>
</div>
<el-table v-loading="historyLoading" :data="runs" row-key="id" class="run-history-table">
<el-table-column prop="id" label="记录" width="80" />
<el-table-column prop="workflow_name" label="工作流" min-width="160" />
<el-table-column prop="batch_id" label="批次" width="80" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.run_type === "node" ? "单节点" : "全量" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="汇总" min-width="180">
<template #default="{ row }">
成功 {{ row.summary?.success || 0 }} / 失败 {{ row.summary?.failed || 0 }} / {{ row.summary?.total || 0 }}
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时(ms)" width="110" />
<el-table-column prop="created_at" label="时间" min-width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="openRunDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-drawer v-model="runDetailVisible" size="72%" title="执行详情" destroy-on-close>
<div v-if="selectedRun" class="run-detail">
<div class="run-detail__head">
<div>
<strong>{{ selectedRun.workflow_name }}</strong>
<p>
记录 #{{ selectedRun.id }}
<span v-if="selectedRun.batch_id"> · 批次 #{{ selectedRun.batch_id }}</span>
· {{ selectedRun.created_at }}
</p>
</div>
<div class="toolbar-actions">
<el-tag :type="statusTagType(selectedRun.status)" effect="light">{{ selectedRun.status }}</el-tag>
<el-button type="primary" :loading="replayLoading" @click="replaySelectedRun">重放</el-button>
</div>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="never" class="panel-card">
<template #header><strong>节点结果</strong></template>
<el-menu :default-active="activeNodeId" @select="handleNodeChange">
<el-menu-item v-for="item in runNodeResults" :key="item.node_id" :index="String(item.node_id)">
<span>{{ item.node_id }}</span>
<el-tag size="small" :type="statusTagType(item.status)" effect="plain">{{ item.status }}</el-tag>
</el-menu-item>
</el-menu>
</el-card>
</el-col>
<el-col :span="16">
<el-card v-if="activeNodeResult" shadow="never" class="panel-card run-detail__node-card">
<template #header>
<div class="panel-card__header">
<strong>{{ activeNodeResult.node_id }}{{ activeNodeResult.node_type }}</strong>
<el-button
v-if="activeNodeResult.node_type === 'http'"
:icon="Link"
:loading="lokiLoading"
@click="openLokiExplore"
>
General Loki 日志
</el-button>
</div>
</template>
<el-alert
v-if="activeNodeResult.node_type === 'http' && lokiLink"
:closable="false"
:type="lokiLink.enabled ? 'success' : 'warning'"
:title="lokiLink.hint"
class="run-detail__loki-alert"
>
<p v-if="lokiLink.logql" class="run-detail__logql">{{ lokiLink.logql }}</p>
<p v-if="lokiLink.method || lokiLink.url" class="run-detail__logql">{{ lokiLink.method }} {{ lokiLink.url }}</p>
</el-alert>
<el-collapse>
<el-collapse-item v-if="activeNodeResult.output?.request" title="请求" name="request">
<pre class="run-detail__json">{{ formatJson(activeNodeResult.output.request) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="activeNodeResult.output?.response" title="服务器响应" name="response">
<pre class="run-detail__json">{{ formatJson(activeNodeResult.output.response) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="activeNodeResult.error" title="错误" name="error">
<pre class="run-detail__json run-detail__json--error">{{ activeNodeResult.error }}</pre>
</el-collapse-item>
</el-collapse>
</el-card>
<el-empty v-else description="请选择节点查看结果" />
</el-col>
</el-row>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.batch-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.batch-layout__side-head {
margin-bottom: 12px;
}
.batch-layout__section-title {
margin: 14px 0 8px;
font-size: 12px;
font-weight: 600;
color: #6e7f96;
}
.batch-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.batch-list__item {
width: 100%;
text-align: left;
border: 1px solid #e5ebf4;
border-radius: 12px;
background: #fff;
padding: 10px 12px;
cursor: pointer;
}
.batch-list__item.is-active {
border-color: #9fc0f4;
box-shadow: 0 10px 24px rgba(64, 104, 180, 0.1);
}
.batch-list__item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.batch-list__item span {
color: #7c8ca4;
font-size: 12px;
}
.batch-editor__head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.batch-editor__head p {
margin: 6px 0 0;
color: #7c8ca4;
font-size: 13px;
}
.batch-form {
margin-bottom: 8px;
}
.history-toolbar {
margin-bottom: 12px;
}
.history-toolbar__filter {
width: 280px;
}
.run-detail__head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.run-detail__head p {
margin: 6px 0 0;
color: #7c8ca4;
font-size: 13px;
}
.run-detail__node-card :deep(.el-menu-item) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.run-detail__json {
margin: 0;
padding: 12px;
border-radius: 10px;
background: #f6f8fc;
border: 1px solid #e4ebf5;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow: auto;
}
.run-detail__json--error {
color: #c45656;
background: #fff5f5;
border-color: #fbc4c4;
}
.run-detail__logql {
margin: 6px 0 0;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
}
.text-muted {
color: #9aa8bd;
font-size: 12px;
}
@media (max-width: 1100px) {
.batch-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"^/api(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/docs(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/openapi\\.json$": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/mcp(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/ws(?:/|$)": {
target: "ws://127.0.0.1:8000",
ws: true,
changeOrigin: true,
},
},
},
});

181
mcp_bridge.py Normal file
View File

@ -0,0 +1,181 @@
"""Stdio MCP server that bridges to the local HTTP MCP gateway.
It speaks the Model Context Protocol over stdin/stdout (newline-delimited
JSON-RPC 2.0) and forwards every tool call to:
GET http://127.0.0.1:8000/mcp/tools
POST http://127.0.0.1:8000/mcp/invoke
Run it with Cursor / Codex / Claude Desktop by configuring stdio MCP entry:
{
"command": "python3",
"args": ["/abs/path/to/mcp_bridge.py"],
"env": {"AI_TEST_BASE_URL": "http://127.0.0.1:8000"}
}
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
import httpx
BASE_URL = os.environ.get("AI_TEST_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
API_KEY = os.environ.get("AI_TEST_API_KEY", "").strip()
SERVER_NAME = "quality-inspection-platform"
SERVER_VERSION = "0.1.0"
PROTOCOL_VERSION = "2024-11-05"
def log(msg: str) -> None:
sys.stderr.write(f"[mcp_bridge] {msg}\n")
sys.stderr.flush()
def write_message(message: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n")
sys.stdout.flush()
def fetch_tools() -> list[dict[str, Any]]:
try:
response = httpx.get(f"{BASE_URL}/mcp/tools", headers=_request_headers(), timeout=10.0)
response.raise_for_status()
payload = response.json() or {}
tools = payload.get("tools", [])
except Exception as exc:
log(f"fetch tools failed: {exc}")
return []
converted = []
for tool in tools:
converted.append(
{
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"inputSchema": tool.get("input_schema") or {"type": "object"},
}
)
return converted
def _request_headers() -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
return headers
def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {"tool": name, "arguments": arguments or {}}
if API_KEY:
payload["api_key"] = API_KEY
try:
response = httpx.post(
f"{BASE_URL}/mcp/invoke",
json=payload,
headers=_request_headers(),
timeout=60.0,
)
response.raise_for_status()
return response.json() or {}
except Exception as exc:
return {"ok": False, "tool": name, "data": {}, "error": str(exc)}
def make_response(req_id: Any, result: Any) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def make_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def handle_initialize(req_id: Any, _: dict[str, Any]) -> dict[str, Any]:
return make_response(
req_id,
{
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
"instructions": (
"AI Auto API Test platform. Before complex tasks, read project file "
"docs/mcp_tools_for_ai.md (tool picker, auth, recipes). "
"Typical start: catalog_snapshot. "
"Batch runs: workflow_batch_create → workflow_batch_update → workflow_batch_run. "
"Writes and execution require sto- API Key (AI_TEST_API_KEY)."
),
},
)
def handle_tools_list(req_id: Any, _: dict[str, Any]) -> dict[str, Any]:
return make_response(req_id, {"tools": fetch_tools()})
def handle_tools_call(req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
name = str(params.get("name", ""))
arguments = params.get("arguments") or {}
if not name:
return make_error(req_id, -32602, "missing tool name")
payload = call_tool(name, arguments)
text = json.dumps(payload, ensure_ascii=False, indent=2)
is_error = not bool(payload.get("ok", True))
return make_response(
req_id,
{
"content": [{"type": "text", "text": text}],
"isError": is_error,
},
)
HANDLERS = {
"initialize": handle_initialize,
"tools/list": handle_tools_list,
"tools/call": handle_tools_call,
}
def main() -> None:
log(f"started, base_url={BASE_URL}")
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError as exc:
log(f"invalid json: {exc}")
continue
method = message.get("method")
req_id = message.get("id")
params = message.get("params") or {}
if method is None:
continue
if "id" not in message:
# JSON-RPC notification, no response required.
continue
handler = HANDLERS.get(method)
if handler is None:
write_message(make_error(req_id, -32601, f"method not found: {method}"))
continue
try:
response = handler(req_id, params)
except Exception as exc:
log(f"handler error: {exc}")
response = make_error(req_id, -32000, str(exc))
write_message(response)
if __name__ == "__main__":
main()

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
httpx
jinja2
python-multipart
paramiko

115
start_project.command Executable file
View File

@ -0,0 +1,115 @@
#!/bin/zsh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_PORT=8000
FRONTEND_PORT=5173
require_file() {
local path="$1"
local message="$2"
if [[ ! -e "$path" ]]; then
echo "$message"
read -r -k 1 "?按任意键关闭..."
exit 1
fi
}
require_command() {
local name="$1"
if ! command -v "$name" >/dev/null 2>&1; then
echo "缺少命令: $name"
read -r -k 1 "?按任意键关闭..."
exit 1
fi
}
is_port_listening() {
local port="$1"
lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1
}
wait_for_http() {
local url="$1"
local max_attempts="$2"
local attempt=1
while (( attempt <= max_attempts )); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 1
(( attempt++ ))
done
return 1
}
open_backend_terminal() {
osascript - "$ROOT_DIR" <<'APPLESCRIPT'
on run argv
set rootDir to item 1 of argv
tell application "Terminal"
activate
do script "cd " & quoted form of rootDir & " && exec ./.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000"
end tell
end run
APPLESCRIPT
}
open_frontend_terminal() {
osascript - "$ROOT_DIR" <<'APPLESCRIPT'
on run argv
set rootDir to item 1 of argv
tell application "Terminal"
activate
do script "cd " & quoted form of (rootDir & "/frontend-admin") & " && exec npm run dev -- --host 0.0.0.0 --port 5173"
end tell
end run
APPLESCRIPT
}
require_file "$ROOT_DIR/.venv/bin/python" "未找到 Python 虚拟环境: .venv/bin/python"
require_file "$ROOT_DIR/frontend-admin/package.json" "未找到前端工程: frontend-admin/package.json"
require_command npm
require_command curl
require_command lsof
require_command osascript
if ! is_port_listening "$BACKEND_PORT"; then
echo "启动后端..."
open_backend_terminal
else
echo "后端已在 $BACKEND_PORT 端口运行"
fi
if ! is_port_listening "$FRONTEND_PORT"; then
echo "启动前端..."
open_frontend_terminal
else
echo "前端已在 $FRONTEND_PORT 端口运行"
fi
echo "等待服务就绪..."
if ! wait_for_http "http://127.0.0.1:$BACKEND_PORT/health" 30; then
echo "后端启动失败,请检查新打开的 Terminal 窗口"
read -r -k 1 "?按任意键关闭..."
exit 1
fi
if ! wait_for_http "http://127.0.0.1:$FRONTEND_PORT/" 30; then
echo "前端启动失败,请检查新打开的 Terminal 窗口"
read -r -k 1 "?按任意键关闭..."
exit 1
fi
open "http://127.0.0.1:$FRONTEND_PORT/"
echo
echo "启动成功"
echo "前端: http://127.0.0.1:$FRONTEND_PORT/"
echo "后端: http://127.0.0.1:$BACKEND_PORT/"
echo
read -r -k 1 "?按任意键关闭..."