初始化仓库:AI 接口自动化测试平台
纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
d810abdcee
73
.gitignore
vendored
Normal file
73
.gitignore
vendored
Normal 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
22
AGENTS.md
Normal 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
113
README.md
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
16
app/database.py
Normal file
16
app/database.py
Normal 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
3509
app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
219
app/models.py
Normal file
219
app/models.py
Normal 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
408
app/schemas.py
Normal 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
953
app/services/engine.py
Normal 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
126
app/services/loki.py
Normal 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
220
app/services/ssh_runner.py
Normal 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
|
||||
1
data/ssh-scripts/.gitkeep
Normal file
1
data/ssh-scripts/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
201
docs/mcp_install.md
Normal file
201
docs/mcp_install.md
Normal 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
484
docs/mcp_quickstart.md
Normal 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 任务示例
|
||||
|
||||
### 示例 A:AI 自动创建接口
|
||||
|
||||
```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
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 B:AI 自动创建 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"}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 C:AI 自动创建 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": {}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 D:AI 自动 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"}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 E:AI 执行工作流并分析结果
|
||||
|
||||
```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}
|
||||
}'
|
||||
```
|
||||
|
||||
### 示例 F:AI 批量编排(创建接口 → 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_update(workflow_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
357
docs/mcp_tools_for_ai.md
Normal 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 需 Key;get/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
|
||||
```
|
||||
|
||||
### R2:Apifox 迁移一批接口
|
||||
|
||||
```
|
||||
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 / 执行类鉴权)。*
|
||||
1
frontend-admin/.env.example
Normal file
1
frontend-admin/.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
12
frontend-admin/index.html
Normal file
12
frontend-admin/index.html
Normal 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
1974
frontend-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend-admin/package.json
Normal file
30
frontend-admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
frontend-admin/src/App.vue
Normal file
3
frontend-admin/src/App.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
154
frontend-admin/src/api/http.js
Normal file
154
frontend-admin/src/api/http.js
Normal 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();
|
||||
}
|
||||
42
frontend-admin/src/auth/session.js
Normal file
42
frontend-admin/src/auth/session.js
Normal 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);
|
||||
}
|
||||
200
frontend-admin/src/components/FolderCreateDialog.vue
Normal file
200
frontend-admin/src/components/FolderCreateDialog.vue
Normal 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>
|
||||
258
frontend-admin/src/components/JsonCodeEditor.vue
Normal file
258
frontend-admin/src/components/JsonCodeEditor.vue
Normal 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>
|
||||
49
frontend-admin/src/components/JsonField.vue
Normal file
49
frontend-admin/src/components/JsonField.vue
Normal 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>
|
||||
74
frontend-admin/src/components/KeyValueEditor.vue
Normal file
74
frontend-admin/src/components/KeyValueEditor.vue
Normal 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>
|
||||
179
frontend-admin/src/components/ProfileApiKeyPanel.vue
Normal file
179
frontend-admin/src/components/ProfileApiKeyPanel.vue
Normal 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>
|
||||
127
frontend-admin/src/components/RequestParamsTabs.vue
Normal file
127
frontend-admin/src/components/RequestParamsTabs.vue
Normal 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>
|
||||
14
frontend-admin/src/components/SshFloatingTerminalLayer.vue
Normal file
14
frontend-admin/src/components/SshFloatingTerminalLayer.vue
Normal 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>
|
||||
319
frontend-admin/src/components/SshFloatingTerminalWindow.vue
Normal file
319
frontend-admin/src/components/SshFloatingTerminalWindow.vue
Normal 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>
|
||||
523
frontend-admin/src/components/WorkflowCanvasNode.vue
Normal file
523
frontend-admin/src/components/WorkflowCanvasNode.vue
Normal 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>
|
||||
1345
frontend-admin/src/components/WorkflowPreview.vue
Normal file
1345
frontend-admin/src/components/WorkflowPreview.vue
Normal file
File diff suppressed because it is too large
Load Diff
201
frontend-admin/src/layout/AdminLayout.vue
Normal file
201
frontend-admin/src/layout/AdminLayout.vue
Normal 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>
|
||||
8
frontend-admin/src/main.js
Normal file
8
frontend-admin/src/main.js
Normal 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");
|
||||
114
frontend-admin/src/router/index.js
Normal file
114
frontend-admin/src/router/index.js
Normal 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;
|
||||
1810
frontend-admin/src/styles/index.css
Normal file
1810
frontend-admin/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
94
frontend-admin/src/utils/folderTree.js
Normal file
94
frontend-admin/src/utils/folderTree.js
Normal 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;
|
||||
}
|
||||
29
frontend-admin/src/utils/keyValue.js
Normal file
29
frontend-admin/src/utils/keyValue.js
Normal 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;
|
||||
}
|
||||
47
frontend-admin/src/utils/sshPinnedScripts.js
Normal file
47
frontend-admin/src/utils/sshPinnedScripts.js
Normal 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);
|
||||
}
|
||||
428
frontend-admin/src/utils/sshScriptRunner.js
Normal file
428
frontend-admin/src/utils/sshScriptRunner.js
Normal 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 = [];
|
||||
}
|
||||
1229
frontend-admin/src/views/ApiEditorView.vue
Normal file
1229
frontend-admin/src/views/ApiEditorView.vue
Normal file
File diff suppressed because it is too large
Load Diff
391
frontend-admin/src/views/ApiManagementView.vue
Normal file
391
frontend-admin/src/views/ApiManagementView.vue
Normal 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>
|
||||
248
frontend-admin/src/views/DashboardView.vue
Normal file
248
frontend-admin/src/views/DashboardView.vue
Normal 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>
|
||||
74
frontend-admin/src/views/LoginView.vue
Normal file
74
frontend-admin/src/views/LoginView.vue
Normal 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>
|
||||
369
frontend-admin/src/views/McpManagementView.vue
Normal file
369
frontend-admin/src/views/McpManagementView.vue
Normal 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>
|
||||
764
frontend-admin/src/views/SshManagementView.vue
Normal file
764
frontend-admin/src/views/SshManagementView.vue
Normal 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 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>
|
||||
241
frontend-admin/src/views/UserManagementView.vue
Normal file
241
frontend-admin/src/views/UserManagementView.vue
Normal 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>
|
||||
718
frontend-admin/src/views/WorkflowBatchView.vue
Normal file
718
frontend-admin/src/views/WorkflowBatchView.vue
Normal 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>
|
||||
33
frontend-admin/vite.config.js
Normal file
33
frontend-admin/vite.config.js
Normal 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
181
mcp_bridge.py
Normal 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
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
httpx
|
||||
jinja2
|
||||
python-multipart
|
||||
paramiko
|
||||
115
start_project.command
Executable file
115
start_project.command
Executable 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 "?按任意键关闭..."
|
||||
Loading…
Reference in New Issue
Block a user