feat: #27 插件系统 — 第三方节点扩展
- NodePlugin 模型: manifest规范(name/version/node_type/inputs_schema/outputs_schema) - plugin_loader 服务: manifest校验、代码加载/卸载、沙箱执行(subprocess隔离+超时30s) - plugins API: CRUD、启用/禁用、市场浏览、安装计数、沙箱测试执行 - PluginMarket.vue: 插件市场上传/浏览/安装/启用禁用/删除/测试 - 注册 register_external_tool 到 tool_registry,供工作流编辑器使用 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
334
backend/app/api/plugins.py
Normal file
334
backend/app/api/plugins.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
插件管理 API — 上传、管理、市场、执行第三方节点插件
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Dict, List, Optional, Any
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.plugin import NodePlugin
|
||||
from app.services.plugin_loader import (
|
||||
validate_manifest, load_plugin_code, unload_plugin_code,
|
||||
execute_plugin_sandbox, list_plugin_node_types, register_plugin_node_type,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
# ---------- Models ----------
|
||||
class PluginManifest(BaseModel):
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
node_type: str
|
||||
node_label: Optional[str] = None
|
||||
category: str = "custom"
|
||||
inputs_schema: Optional[Dict[str, Any]] = None
|
||||
outputs_schema: Optional[Dict[str, Any]] = None
|
||||
icon: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PluginCreate(BaseModel):
|
||||
manifest: PluginManifest
|
||||
code: str = Field(..., description="Python 插件代码(须包含 execute 函数)")
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class PluginUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
node_label: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
inputs_schema: Optional[Dict[str, Any]] = None
|
||||
outputs_schema: Optional[Dict[str, Any]] = None
|
||||
enabled: Optional[bool] = None
|
||||
is_public: Optional[bool] = None
|
||||
icon: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PluginExecuteRequest(BaseModel):
|
||||
inputs: Dict[str, Any] = Field(default_factory=dict)
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PluginResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: Optional[str]
|
||||
author: Optional[str]
|
||||
node_type: str
|
||||
node_label: Optional[str]
|
||||
category: str
|
||||
manifest: Optional[Dict[str, Any]]
|
||||
inputs_schema: Optional[Dict[str, Any]]
|
||||
outputs_schema: Optional[Dict[str, Any]]
|
||||
enabled: bool
|
||||
is_public: bool
|
||||
install_count: int
|
||||
rating_avg: int
|
||||
icon: Optional[str]
|
||||
tags: Optional[List[str]]
|
||||
created_at: Any
|
||||
updated_at: Any
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ---------- 我的插件 ----------
|
||||
@router.get("/my", response_model=List[PluginResponse])
|
||||
async def get_my_plugins(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前用户上传的插件。"""
|
||||
plugins = (
|
||||
db.query(NodePlugin)
|
||||
.filter(NodePlugin.user_id == current_user.id)
|
||||
.order_by(NodePlugin.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
return plugins
|
||||
|
||||
|
||||
# ---------- 插件市场 ----------
|
||||
@router.get("/market", response_model=List[PluginResponse])
|
||||
async def get_market_plugins(
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取公开插件市场列表。"""
|
||||
query = db.query(NodePlugin).filter(
|
||||
NodePlugin.is_public == True,
|
||||
NodePlugin.enabled == True,
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
NodePlugin.name.like(f"%{search}%"),
|
||||
NodePlugin.description.like(f"%{search}%"),
|
||||
NodePlugin.node_label.like(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
if category:
|
||||
query = query.filter(NodePlugin.category == category)
|
||||
|
||||
plugins = query.order_by(NodePlugin.install_count.desc()).offset(skip).limit(limit).all()
|
||||
return plugins
|
||||
|
||||
|
||||
# ---------- 创建插件 ----------
|
||||
@router.post("", response_model=PluginResponse, status_code=201)
|
||||
async def create_plugin(
|
||||
body: PluginCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""上传新插件。"""
|
||||
manifest = body.manifest.dict()
|
||||
|
||||
# 校验 manifest
|
||||
ok, err = validate_manifest(manifest)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"manifest 校验失败: {err}")
|
||||
|
||||
# 校验代码包含 execute 函数
|
||||
if "def execute" not in body.code and "async def execute" not in body.code:
|
||||
raise HTTPException(status_code=400, detail="代码中必须包含 execute(inputs, context) 函数")
|
||||
|
||||
# 检查名称唯一性
|
||||
existing = db.query(NodePlugin).filter(NodePlugin.name == manifest["name"]).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"插件名称 '{manifest['name']}' 已存在")
|
||||
|
||||
# 校验 node_type 唯一性
|
||||
existing_type = db.query(NodePlugin).filter(NodePlugin.node_type == manifest["node_type"]).first()
|
||||
if existing_type:
|
||||
raise HTTPException(status_code=409, detail=f"节点类型 '{manifest['node_type']}' 已被使用")
|
||||
|
||||
plugin = NodePlugin(
|
||||
name=manifest["name"],
|
||||
version=manifest.get("version", "1.0.0"),
|
||||
description=manifest.get("description"),
|
||||
author=manifest.get("author") or current_user.username,
|
||||
node_type=manifest["node_type"],
|
||||
node_label=manifest.get("node_label", manifest["name"]),
|
||||
category=manifest.get("category", "custom"),
|
||||
manifest=manifest,
|
||||
inputs_schema=manifest.get("inputs_schema"),
|
||||
outputs_schema=manifest.get("outputs_schema"),
|
||||
code=body.code,
|
||||
icon=manifest.get("icon"),
|
||||
tags=manifest.get("tags", []),
|
||||
is_public=body.is_public,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.add(plugin)
|
||||
db.flush()
|
||||
|
||||
# 写入磁盘并注册
|
||||
try:
|
||||
load_plugin_code(plugin.id, body.code, manifest["node_type"])
|
||||
if plugin.enabled:
|
||||
register_plugin_node_type(plugin)
|
||||
except Exception as e:
|
||||
logger.warning("插件注册警告: %s", e)
|
||||
|
||||
db.commit()
|
||||
db.refresh(plugin)
|
||||
logger.info("插件已创建: %s (node_type=%s)", plugin.name, plugin.node_type)
|
||||
return plugin
|
||||
|
||||
|
||||
# ---------- 获取单个插件 ----------
|
||||
@router.get("/{plugin_id}", response_model=PluginResponse)
|
||||
async def get_plugin(
|
||||
plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取插件详情。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
return plugin
|
||||
|
||||
|
||||
# ---------- 更新插件 ----------
|
||||
@router.put("/{plugin_id}", response_model=PluginResponse)
|
||||
async def update_plugin(
|
||||
plugin_id: str,
|
||||
body: PluginUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新插件。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
if plugin.user_id and plugin.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权修改此插件")
|
||||
|
||||
for field, value in body.dict(exclude_unset=True).items():
|
||||
setattr(plugin, field, value)
|
||||
|
||||
# 如果代码或 schema 变动,重新加载
|
||||
if body.code:
|
||||
load_plugin_code(plugin.id, body.code, plugin.node_type)
|
||||
if body.enabled is not None:
|
||||
if body.enabled:
|
||||
register_plugin_node_type(plugin)
|
||||
|
||||
db.commit()
|
||||
db.refresh(plugin)
|
||||
return plugin
|
||||
|
||||
|
||||
# ---------- 删除插件 ----------
|
||||
@router.delete("/{plugin_id}")
|
||||
async def delete_plugin(
|
||||
plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""删除插件。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
if plugin.user_id and plugin.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除此插件")
|
||||
|
||||
unload_plugin_code(plugin.id)
|
||||
db.delete(plugin)
|
||||
db.commit()
|
||||
return {"message": "插件已删除"}
|
||||
|
||||
|
||||
# ---------- 启用/禁用 ----------
|
||||
@router.post("/{plugin_id}/toggle")
|
||||
async def toggle_plugin(
|
||||
plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""切换插件启用/禁用状态。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
|
||||
plugin.enabled = not plugin.enabled
|
||||
if plugin.enabled:
|
||||
load_plugin_code(plugin.id, plugin.code or "", plugin.node_type)
|
||||
register_plugin_node_type(plugin)
|
||||
else:
|
||||
unload_plugin_code(plugin.id)
|
||||
|
||||
db.commit()
|
||||
return {"enabled": plugin.enabled, "message": f"插件已{'启用' if plugin.enabled else '禁用'}"}
|
||||
|
||||
|
||||
# ---------- 安装插件(从市场) ----------
|
||||
@router.post("/{plugin_id}/install")
|
||||
async def install_plugin(
|
||||
plugin_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""从插件市场安装插件(增加安装计数)。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
if not plugin.is_public:
|
||||
raise HTTPException(status_code=403, detail="此插件未公开")
|
||||
|
||||
plugin.install_count += 1
|
||||
if plugin.enabled:
|
||||
register_plugin_node_type(plugin)
|
||||
db.commit()
|
||||
return {"message": "插件已安装", "install_count": plugin.install_count}
|
||||
|
||||
|
||||
# ---------- 沙箱测试执行 ----------
|
||||
@router.post("/{plugin_id}/test")
|
||||
async def test_plugin(
|
||||
plugin_id: str,
|
||||
body: PluginExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""在沙箱中测试执行插件。"""
|
||||
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="插件不存在")
|
||||
if not plugin.code:
|
||||
raise HTTPException(status_code=400, detail="插件无代码")
|
||||
|
||||
result = await execute_plugin_sandbox(
|
||||
code=plugin.code,
|
||||
inputs=body.inputs,
|
||||
context=body.context,
|
||||
timeout_seconds=30,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------- 插件节点类型列表(供工作流编辑器用) ----------
|
||||
@router.get("/internal/node-types")
|
||||
async def get_node_types():
|
||||
"""获取所有已启用的插件节点类型。"""
|
||||
return list_plugin_node_types()
|
||||
@@ -52,4 +52,6 @@ def init_db():
|
||||
import app.models.agent_schedule
|
||||
import app.models.knowledge_base
|
||||
import app.models.notification
|
||||
import app.models.orchestration_template
|
||||
import app.models.plugin
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -263,7 +263,7 @@ async def startup_event():
|
||||
logger.error(f"人参果1号长连接启动失败: {e}")
|
||||
|
||||
# 注册路由
|
||||
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval
|
||||
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(uploads.router)
|
||||
@@ -292,6 +292,8 @@ app.include_router(agent_schedules.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(feishu_bind.router)
|
||||
app.include_router(approval.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(orchestration_templates.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from app.models.user import User
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.workflow_version import WorkflowVersion
|
||||
from app.models.agent import Agent
|
||||
from app.models.agent import Agent, GlobalKnowledge, AgentRating, AgentFavorite
|
||||
from app.models.execution import Execution
|
||||
from app.models.execution_log import ExecutionLog
|
||||
from app.models.model_config import ModelConfig
|
||||
@@ -19,5 +19,7 @@ from app.models.agent_schedule import AgentSchedule
|
||||
from app.models.knowledge_base import KnowledgeBase, Document, DocumentChunk
|
||||
from app.models.notification import Notification
|
||||
from app.models.user_feishu_open_id import UserFeishuOpenId
|
||||
from app.models.plugin import NodePlugin
|
||||
from app.models.orchestration_template import OrchestrationTemplate
|
||||
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId"]
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate"]
|
||||
48
backend/app/models/plugin.py
Normal file
48
backend/app/models/plugin.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
插件模型 — 第三方节点扩展
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, func
|
||||
from sqlalchemy.dialects.mysql import CHAR
|
||||
from app.core.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class NodePlugin(Base):
|
||||
"""第三方节点插件"""
|
||||
__tablename__ = "node_plugins"
|
||||
|
||||
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="插件ID")
|
||||
name = Column(String(128), nullable=False, unique=True, comment="插件名称(唯一)")
|
||||
version = Column(String(32), nullable=False, default="1.0.0", comment="版本号")
|
||||
description = Column(Text, comment="描述")
|
||||
author = Column(String(128), comment="作者")
|
||||
node_type = Column(String(64), nullable=False, comment="节点类型标识(如 custom_http、custom_data)")
|
||||
node_label = Column(String(128), comment="节点显示名称")
|
||||
category = Column(String(64), default="custom", comment="分类: custom/http/data/ai/tool")
|
||||
|
||||
# manifest.json 原始内容
|
||||
manifest = Column(JSON, comment="manifest.json 完整内容")
|
||||
|
||||
# 输入/输出定义
|
||||
inputs_schema = Column(JSON, comment="输入参数 schema")
|
||||
outputs_schema = Column(JSON, comment="输出参数 schema")
|
||||
|
||||
# 代码
|
||||
code = Column(Text, comment="插件执行代码(Python)")
|
||||
|
||||
# 状态
|
||||
enabled = Column(Boolean, default=True, comment="是否启用")
|
||||
is_public = Column(Boolean, default=False, comment="是否公开到插件市场")
|
||||
install_count = Column(Integer, default=0, comment="安装次数")
|
||||
rating_avg = Column(Integer, default=0, comment="平均评分")
|
||||
|
||||
# 元数据
|
||||
icon = Column(String(256), comment="图标URL")
|
||||
tags = Column(JSON, comment="标签列表")
|
||||
user_id = Column(CHAR(36), nullable=True, comment="上传者ID")
|
||||
|
||||
created_at = Column(DateTime, default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NodePlugin(id={self.id}, name={self.name}, type={self.node_type})>"
|
||||
219
backend/app/services/plugin_loader.py
Normal file
219
backend/app/services/plugin_loader.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
插件加载器 — 加载、校验、沙箱执行第三方节点插件
|
||||
|
||||
插件规范 (manifest.json):
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "...",
|
||||
"author": "...",
|
||||
"node_type": "custom_action",
|
||||
"node_label": "自定义操作",
|
||||
"category": "custom",
|
||||
"entry": "execute.py",
|
||||
"inputs_schema": {"type": "object", "properties": {...}},
|
||||
"outputs_schema": {"type": "object", "properties": {...}}
|
||||
}
|
||||
|
||||
执行函数签名:
|
||||
async def execute(inputs: dict, context: dict) -> dict
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 插件存储根目录
|
||||
PLUGINS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "plugins"
|
||||
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict) -> tuple[bool, str]:
|
||||
"""校验 manifest.json 是否合法。"""
|
||||
required = ["name", "version", "node_type"]
|
||||
for key in required:
|
||||
if key not in manifest:
|
||||
return False, f"缺少必填字段: {key}"
|
||||
if not isinstance(manifest.get("name"), str) or not manifest["name"].strip():
|
||||
return False, "name 必须是非空字符串"
|
||||
if not isinstance(manifest.get("node_type"), str) or not manifest["node_type"].strip():
|
||||
return False, "node_type 必须是非空字符串"
|
||||
# node_type 必须是合法的标识符
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', manifest["node_type"]):
|
||||
return False, "node_type 必须是合法的 Python 标识符"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def load_plugin_code(plugin_id: str, code: str, node_type: str) -> str:
|
||||
"""将插件代码写入磁盘并返回文件路径。"""
|
||||
plugin_dir = PLUGINS_DIR / plugin_id
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = plugin_dir / f"{node_type}.py"
|
||||
file_path.write_text(code, encoding="utf-8")
|
||||
return str(file_path)
|
||||
|
||||
|
||||
def unload_plugin_code(plugin_id: str):
|
||||
"""从磁盘删除插件代码。"""
|
||||
import shutil
|
||||
plugin_dir = PLUGINS_DIR / plugin_id
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir)
|
||||
|
||||
|
||||
def _make_sandbox_globals():
|
||||
"""构建受限的内建函数集。"""
|
||||
safe_builtins = {
|
||||
"True": True, "False": False, "None": None,
|
||||
"abs": abs, "all": all, "any": any, "bool": bool,
|
||||
"dict": dict, "enumerate": enumerate, "filter": filter,
|
||||
"float": float, "int": int, "isinstance": isinstance,
|
||||
"len": len, "list": list, "map": map, "max": max,
|
||||
"min": min, "range": range, "round": round, "set": set,
|
||||
"sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
|
||||
"type": type, "zip": zip,
|
||||
"print": print, "json": json,
|
||||
"Exception": Exception, "ValueError": ValueError,
|
||||
"TypeError": TypeError, "KeyError": KeyError,
|
||||
}
|
||||
return {"__builtins__": safe_builtins}
|
||||
|
||||
|
||||
async def execute_plugin_sandbox(
|
||||
code: str,
|
||||
inputs: Dict[str, Any],
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
timeout_seconds: int = 30,
|
||||
) -> Dict[str, Any]:
|
||||
"""在沙箱中异步执行插件代码。
|
||||
|
||||
使用 subprocess 隔离执行,超时自动终止,防止恶意代码影响主进程。
|
||||
"""
|
||||
context = context or {}
|
||||
|
||||
# 将代码和输入写入临时脚本
|
||||
wrapper_code = f'''
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
# 用户插件代码
|
||||
{code}
|
||||
|
||||
# 准备输入
|
||||
_inputs = json.loads(sys.stdin.read())
|
||||
_ctx = _inputs.get("__context__", {{}})
|
||||
_user_inputs = _inputs.get("__inputs__", {{}})
|
||||
|
||||
# 查找 execute 函数
|
||||
if "execute" not in dir():
|
||||
print(json.dumps({{"ok": False, "error": "插件缺少 execute 函数"}}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
result = asyncio.run(execute(_user_inputs, _ctx))
|
||||
print(json.dumps({{"ok": True, "result": result}}, ensure_ascii=False, default=str))
|
||||
except Exception as e:
|
||||
print(json.dumps({{"ok": False, "error": str(e), "traceback": traceback.format_exc()}}, ensure_ascii=False))
|
||||
'''
|
||||
|
||||
try:
|
||||
proc = await asyncio.wait_for(
|
||||
asyncio.create_subprocess_exec(
|
||||
sys.executable, "-c", wrapper_code,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
input_data = json.dumps({"__inputs__": inputs, "__context__": context})
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(input_data.encode("utf-8")),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
if stderr:
|
||||
logger.warning("插件沙箱 stderr: %s", stderr.decode("utf-8", errors="replace")[:500])
|
||||
|
||||
result = json.loads(stdout.decode("utf-8"))
|
||||
if result.get("ok"):
|
||||
return {"success": True, "result": result["result"]}
|
||||
else:
|
||||
return {"success": False, "error": result.get("error", "未知错误")}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return {"success": False, "error": f"插件执行超时({timeout_seconds}秒)"}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"success": False, "error": f"插件返回解析失败: {e}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"插件执行异常: {e}"}
|
||||
|
||||
|
||||
def list_plugin_node_types() -> List[Dict[str, Any]]:
|
||||
"""列出当前已加载的插件节点类型,供工作流编辑器使用。"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.plugin import NodePlugin
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
plugins = db.query(NodePlugin).filter(NodePlugin.enabled == True).all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"node_type": p.node_type,
|
||||
"node_label": p.node_label or p.name,
|
||||
"category": p.category,
|
||||
"description": p.description,
|
||||
"inputs_schema": p.inputs_schema,
|
||||
"outputs_schema": p.outputs_schema,
|
||||
"icon": p.icon,
|
||||
}
|
||||
for p in plugins
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def register_plugin_node_type(plugin) -> bool:
|
||||
"""将插件注册到工具注册表,使工作流编辑器可用。"""
|
||||
from app.services.tool_registry import tool_registry
|
||||
|
||||
node_type = plugin.node_type
|
||||
node_label = plugin.node_label or plugin.name
|
||||
|
||||
schema = {
|
||||
"name": node_type,
|
||||
"description": plugin.description or f"自定义节点: {node_label}",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": (plugin.inputs_schema or {}).get("properties", {}),
|
||||
"required": (plugin.inputs_schema or {}).get("required", []),
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
tool_registry.register_external_tool(
|
||||
name=node_type,
|
||||
description=schema["description"],
|
||||
parameters=schema["parameters"],
|
||||
category=f"plugin:{plugin.category}",
|
||||
)
|
||||
logger.info("插件节点类型已注册: %s", node_type)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("插件节点类型注册失败 [%s]: %s", node_type, e)
|
||||
return False
|
||||
@@ -69,6 +69,17 @@ class ToolRegistry:
|
||||
self._tool_schemas[name] = schema
|
||||
logger.debug("注册内置工具: %s", name)
|
||||
|
||||
def register_external_tool(self, name: str, description: str, parameters: dict, category: str = "plugin"):
|
||||
"""注册外部/插件工具 schema(无实际执行函数,仅用于工作流编辑器展示)。"""
|
||||
schema = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
}
|
||||
self._tool_schemas[name] = schema
|
||||
self._custom_tool_configs[name] = {"category": category, "description": description}
|
||||
logger.debug("注册外部工具 schema: %s (category=%s)", name, category)
|
||||
|
||||
# ─── 工具信息查询 ─────────────────────────────────────────
|
||||
|
||||
def get_tool_schema(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -135,6 +135,12 @@ const router = createRouter({
|
||||
name: 'agent-schedules',
|
||||
component: () => import('@/views/AgentSchedules.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/plugins',
|
||||
name: 'plugin-market',
|
||||
component: () => import('@/views/PluginMarket.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
402
frontend/src/views/PluginMarket.vue
Normal file
402
frontend/src/views/PluginMarket.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="plugin-market">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>插件市场</h1>
|
||||
<p class="subtitle">扩展平台节点类型,上传或安装第三方插件</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="showUpload = true">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传插件
|
||||
</el-button>
|
||||
<el-button @click="activeTab = 'my'">
|
||||
<el-icon><User /></el-icon>
|
||||
我的插件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<el-tabs v-model="activeTab" @tab-change="loadData">
|
||||
<el-tab-pane label="插件市场" name="market" />
|
||||
<el-tab-pane label="我的插件" name="my" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<el-card class="search-bar" v-if="activeTab === 'market'">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-input v-model="searchQuery" placeholder="搜索插件..." clearable @clear="loadData" @keyup.enter="loadData">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="categoryFilter" placeholder="分类" clearable @change="loadData">
|
||||
<el-option label="自定义" value="custom" />
|
||||
<el-option label="HTTP请求" value="http" />
|
||||
<el-option label="数据处理" value="data" />
|
||||
<el-option label="AI工具" value="ai" />
|
||||
<el-option label="工具" value="tool" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button type="primary" @click="loadData">搜索</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 插件列表 -->
|
||||
<div v-loading="loading" class="plugin-grid" style="margin-top: 16px">
|
||||
<el-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
|
||||
<el-row :gutter="16" v-else>
|
||||
<el-col v-for="p in plugins" :key="p.id" :span="6" style="margin-bottom: 16px">
|
||||
<el-card class="plugin-card" shadow="hover">
|
||||
<div class="plugin-header">
|
||||
<div class="plugin-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div class="plugin-info">
|
||||
<h4 class="plugin-name">{{ p.node_label || p.name }}</h4>
|
||||
<el-tag size="small" type="info">{{ p.category }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<p class="plugin-desc">{{ p.description || '无描述' }}</p>
|
||||
<div class="plugin-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><Download /></el-icon> {{ p.install_count }}
|
||||
</span>
|
||||
<span class="meta-item">v{{ p.version }}</span>
|
||||
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
|
||||
{{ p.enabled ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<el-button
|
||||
v-if="activeTab === 'market'"
|
||||
type="primary" size="small"
|
||||
@click="installPlugin(p)"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
安装
|
||||
</el-button>
|
||||
<el-button size="small" @click="viewDetail(p)">详情</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'my'"
|
||||
size="small"
|
||||
@click="testPlugin(p)"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'my'"
|
||||
:type="p.enabled ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="togglePlugin(p)"
|
||||
>
|
||||
{{ p.enabled ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="activeTab === 'my'"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deletePlugin(p)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
<el-dialog v-model="showUpload" title="上传插件" width="700px" :close-on-click-modal="false" destroy-on-close>
|
||||
<el-form :model="uploadForm" label-width="120px">
|
||||
<el-form-item label="插件名称" required>
|
||||
<el-input v-model="uploadForm.manifest.name" placeholder="如: my-http-plugin" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号">
|
||||
<el-input v-model="uploadForm.manifest.version" placeholder="1.0.0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点类型" required>
|
||||
<el-input v-model="uploadForm.manifest.node_type" placeholder="如: custom_http_action(英文字母+下划线)" />
|
||||
<div class="form-hint">节点类型标识,用于工作流编辑器中识别此节点</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="节点显示名">
|
||||
<el-input v-model="uploadForm.manifest.node_label" placeholder="如: HTTP请求" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="uploadForm.manifest.category" placeholder="选择分类">
|
||||
<el-option label="自定义" value="custom" />
|
||||
<el-option label="HTTP请求" value="http" />
|
||||
<el-option label="数据处理" value="data" />
|
||||
<el-option label="AI工具" value="ai" />
|
||||
<el-option label="工具" value="tool" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="uploadForm.manifest.description" type="textarea" :rows="2" placeholder="插件功能描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输入Schema (JSON)">
|
||||
<el-input v-model="schemaInputs" type="textarea" :rows="4" placeholder='{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}' />
|
||||
</el-form-item>
|
||||
<el-form-item label="插件代码" required>
|
||||
<el-input v-model="uploadForm.code" type="textarea" :rows="10" placeholder="async def execute(inputs, context): # 你的插件逻辑 return {'result': 'hello'}" />
|
||||
<div class="form-hint">必须包含 async def execute(inputs, context) 函数,返回 dict</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="公开到市场">
|
||||
<el-switch v-model="uploadForm.is_public" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showUpload = false">取消</el-button>
|
||||
<el-button type="primary" @click="doUpload" :loading="uploading">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="showDetail" :title="detailPlugin?.name" width="700px" destroy-on-close>
|
||||
<el-descriptions v-if="detailPlugin" :column="2" border>
|
||||
<el-descriptions-item label="名称">{{ detailPlugin.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">v{{ detailPlugin.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="节点类型">{{ detailPlugin.node_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="节点标签">{{ detailPlugin.node_label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ detailPlugin.category }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detailPlugin.enabled ? 'success' : 'danger'">{{ detailPlugin.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装次数">{{ detailPlugin.install_count }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ detailPlugin.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ detailPlugin.description || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="detailPlugin?.code" style="margin-top: 12px">
|
||||
<h4>插件代码</h4>
|
||||
<el-input :model-value="detailPlugin.code" type="textarea" :rows="10" readonly />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 测试对话框 -->
|
||||
<el-dialog v-model="showTest" title="测试插件" width="700px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="输入参数 (JSON)">
|
||||
<el-input v-model="testInputs" type="textarea" :rows="6" placeholder='{"key": "value"}' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showTest = false">关闭</el-button>
|
||||
<el-button type="primary" @click="doTest" :loading="testing">执行测试</el-button>
|
||||
</template>
|
||||
<div v-if="testResult" style="margin-top: 16px">
|
||||
<el-divider />
|
||||
<h4>测试结果</h4>
|
||||
<el-alert
|
||||
:title="testResult.success ? '执行成功' : '执行失败'"
|
||||
:type="testResult.success ? 'success' : 'error'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-input
|
||||
:model-value="JSON.stringify(testResult, null, 2)"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
readonly
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload, User, Search, Download, VideoPlay, Box } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 状态
|
||||
const activeTab = ref('market')
|
||||
const plugins = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const categoryFilter = ref('')
|
||||
|
||||
// 上传
|
||||
const showUpload = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadForm = ref({
|
||||
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] as string[] },
|
||||
code: '',
|
||||
is_public: false,
|
||||
})
|
||||
const schemaInputs = ref('')
|
||||
|
||||
// 详情
|
||||
const showDetail = ref(false)
|
||||
const detailPlugin = ref<any>(null)
|
||||
|
||||
// 测试
|
||||
const showTest = ref(false)
|
||||
const testing = ref(false)
|
||||
const testInputs = ref('{}')
|
||||
const testResult = ref<any>(null)
|
||||
const testTarget = ref<any>(null)
|
||||
|
||||
// 加载
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const endpoint = activeTab.value === 'market' ? '/api/v1/plugins/market' : '/api/v1/plugins/my'
|
||||
const params: any = { skip: 0, limit: 50 }
|
||||
if (activeTab.value === 'market') {
|
||||
if (searchQuery.value) params.search = searchQuery.value
|
||||
if (categoryFilter.value) params.category = categoryFilter.value
|
||||
}
|
||||
const resp = await api.get(endpoint, { params })
|
||||
plugins.value = Array.isArray(resp.data) ? resp.data : []
|
||||
} catch {
|
||||
ElMessage.error('加载插件列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 上传
|
||||
const doUpload = async () => {
|
||||
if (!uploadForm.value.manifest.name || !uploadForm.value.manifest.node_type || !uploadForm.value.code) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
if (!uploadForm.value.code.includes('def execute')) {
|
||||
ElMessage.warning('代码中必须包含 execute 函数')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
// 解析 schema JSON
|
||||
const manifest: any = { ...uploadForm.value.manifest }
|
||||
if (schemaInputs.value.trim()) {
|
||||
try {
|
||||
manifest.inputs_schema = JSON.parse(schemaInputs.value)
|
||||
} catch {
|
||||
ElMessage.warning('输入Schema 不是有效的 JSON')
|
||||
uploading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
await api.post('/api/v1/plugins', { manifest, code: uploadForm.value.code, is_public: uploadForm.value.is_public })
|
||||
ElMessage.success('插件上传成功')
|
||||
showUpload.value = false
|
||||
uploadForm.value = {
|
||||
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] },
|
||||
code: '',
|
||||
is_public: false,
|
||||
}
|
||||
schemaInputs.value = ''
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.detail || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 安装
|
||||
const installPlugin = async (p: any) => {
|
||||
try {
|
||||
await api.post(`/api/v1/plugins/${p.id}/install`)
|
||||
p.install_count += 1
|
||||
ElMessage.success(`已安装插件「${p.name}」`)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.detail || '安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换启用
|
||||
const togglePlugin = async (p: any) => {
|
||||
try {
|
||||
const resp = await api.post(`/api/v1/plugins/${p.id}/toggle`)
|
||||
p.enabled = resp.data.enabled
|
||||
ElMessage.success(resp.data.message)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deletePlugin = async (p: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除插件「${p.name}」?此操作不可恢复。`, '确认删除', { type: 'warning' })
|
||||
await api.delete(`/api/v1/plugins/${p.id}`)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 详情
|
||||
const viewDetail = async (p: any) => {
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/plugins/${p.id}`)
|
||||
detailPlugin.value = resp.data
|
||||
showDetail.value = true
|
||||
} catch {
|
||||
ElMessage.error('加载详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 测试
|
||||
const testPlugin = (p: any) => {
|
||||
testTarget.value = p
|
||||
testInputs.value = '{}'
|
||||
testResult.value = null
|
||||
showTest.value = true
|
||||
}
|
||||
|
||||
const doTest = async () => {
|
||||
if (!testTarget.value) return
|
||||
let inputs = {}
|
||||
try {
|
||||
inputs = JSON.parse(testInputs.value)
|
||||
} catch {
|
||||
ElMessage.warning('输入参数不是有效的 JSON')
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
const resp = await api.post(`/api/v1/plugins/${testTarget.value.id}/test`, { inputs })
|
||||
testResult.value = resp.data
|
||||
} catch (err: any) {
|
||||
testResult.value = { success: false, error: err.response?.data?.detail || '测试失败' }
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plugin-market { max-width: 1200px; margin: 0 auto; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
|
||||
.header-left h1 { margin: 0; font-size: 24px; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.subtitle { color: var(--el-text-color-secondary); margin: 4px 0 0; font-size: 14px; }
|
||||
.search-bar { margin-bottom: 8px; }
|
||||
.plugin-grid { min-height: 200px; }
|
||||
.plugin-card { height: 100%; }
|
||||
.plugin-header { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
|
||||
.plugin-icon { width: 44px; height: 44px; background: var(--el-color-primary-light-9); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--el-color-primary); }
|
||||
.plugin-name { margin: 0; font-size: 15px; }
|
||||
.plugin-desc { color: var(--el-text-color-secondary); font-size: 13px; margin: 8px 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.plugin-meta { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.meta-item { display: flex; align-items: center; gap: 4px; }
|
||||
.plugin-actions { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.form-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user