From 2f9d85edb6a5c647d5fe8ddbc52edfce1a562750 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 28 Jan 2026 13:13:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=84=9A=E6=9C=AC=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=88=E7=B1=BB=E4=BC=BC=E9=9D=92=E9=BE=99?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增脚本管理页面,左右分栏布局 - 集成 Monaco Editor 代码编辑器(语法高亮、行号、快捷键) - 支持脚本 CRUD、运行、复制等操作 - 定时任务支持从脚本库导入脚本 - 新增 platform_scripts 表存储脚本 --- backend/app/main.py | 2 + backend/app/routers/scripts.py | 325 ++++++++ frontend/package.json | 4 +- frontend/src/components/Layout.vue | 1 + frontend/src/components/MonacoEditor.vue | 142 ++++ frontend/src/router/index.js | 6 + frontend/src/views/scheduled-tasks/index.vue | 122 ++- frontend/src/views/scripts/index.vue | 772 +++++++++++++++++++ 8 files changed, 1372 insertions(+), 2 deletions(-) create mode 100644 backend/app/routers/scripts.py create mode 100644 frontend/src/components/MonacoEditor.vue create mode 100644 frontend/src/views/scripts/index.vue diff --git a/backend/app/main.py b/backend/app/main.py index 6219de4..0c6d207 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from .routers.cost import router as cost_router from .routers.quota import router as quota_router from .routers.tool_configs import router as tool_configs_router from .routers.tasks import router as tasks_router +from .routers.scripts import router as scripts_router from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware from .middleware.trace import setup_logging from .services.scheduler import start_scheduler, shutdown_scheduler @@ -71,6 +72,7 @@ app.include_router(cost_router, prefix="/api") app.include_router(quota_router, prefix="/api") app.include_router(tool_configs_router, prefix="/api") app.include_router(tasks_router, prefix="/api") +app.include_router(scripts_router, prefix="/api") @app.on_event("startup") diff --git a/backend/app/routers/scripts.py b/backend/app/routers/scripts.py new file mode 100644 index 0000000..9accc87 --- /dev/null +++ b/backend/app/routers/scripts.py @@ -0,0 +1,325 @@ +"""脚本管理路由""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import text +from datetime import datetime + +from ..database import get_db +from .auth import get_current_user, require_operator +from ..models.user import User + +router = APIRouter(prefix="/scripts", tags=["脚本管理"]) + + +# Schemas + +class ScriptCreate(BaseModel): + tenant_id: Optional[str] = None + name: str + filename: Optional[str] = None + description: Optional[str] = None + script_content: str + category: Optional[str] = None + is_enabled: bool = True + + +class ScriptUpdate(BaseModel): + name: Optional[str] = None + filename: Optional[str] = None + description: Optional[str] = None + script_content: Optional[str] = None + category: Optional[str] = None + is_enabled: Optional[bool] = None + + +class ScriptRunRequest(BaseModel): + tenant_id: Optional[str] = None # 可指定以哪个租户身份运行 + + +# API Endpoints + +@router.get("") +async def list_scripts( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + tenant_id: Optional[str] = None, + category: Optional[str] = None, + keyword: Optional[str] = None, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取脚本列表""" + where_clauses = [] + params = {} + + if tenant_id: + where_clauses.append("(tenant_id = :tenant_id OR tenant_id IS NULL)") + params["tenant_id"] = tenant_id + if category: + where_clauses.append("category = :category") + params["category"] = category + if keyword: + where_clauses.append("(name LIKE :keyword OR description LIKE :keyword)") + params["keyword"] = f"%{keyword}%" + + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + + # 查询总数 + count_result = db.execute( + text(f"SELECT COUNT(*) FROM platform_scripts WHERE {where_sql}"), + params + ) + total = count_result.scalar() + + # 查询列表 + params["offset"] = (page - 1) * size + params["limit"] = size + result = db.execute( + text(f""" + SELECT id, tenant_id, name, filename, description, category, + is_enabled, last_run_at, last_run_status, created_by, created_at, updated_at, + LENGTH(script_content) as content_length + FROM platform_scripts + WHERE {where_sql} + ORDER BY updated_at DESC, id DESC + LIMIT :limit OFFSET :offset + """), + params + ) + scripts = [dict(row) for row in result.mappings().all()] + + # 获取分类列表 + cat_result = db.execute( + text("SELECT DISTINCT category FROM platform_scripts WHERE category IS NOT NULL AND category != ''") + ) + categories = [row[0] for row in cat_result.fetchall()] + + return { + "total": total, + "page": page, + "size": size, + "items": scripts, + "categories": categories + } + + +@router.get("/{script_id}") +async def get_script( + script_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取脚本详情(包含内容)""" + result = db.execute( + text("SELECT * FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + script = result.mappings().first() + + if not script: + raise HTTPException(status_code=404, detail="脚本不存在") + + return dict(script) + + +@router.post("") +async def create_script( + data: ScriptCreate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """创建脚本""" + # 自动生成文件名 + filename = data.filename + if not filename and data.name: + # 转换为安全的文件名 + import re + safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', data.name) + filename = f"{safe_name}.py" + + db.execute( + text(""" + INSERT INTO platform_scripts + (tenant_id, name, filename, description, script_content, category, is_enabled, created_by) + VALUES (:tenant_id, :name, :filename, :description, :script_content, :category, :is_enabled, :created_by) + """), + { + "tenant_id": data.tenant_id, + "name": data.name, + "filename": filename, + "description": data.description, + "script_content": data.script_content, + "category": data.category, + "is_enabled": 1 if data.is_enabled else 0, + "created_by": user.username if hasattr(user, 'username') else None + } + ) + db.commit() + + result = db.execute(text("SELECT LAST_INSERT_ID() as id")) + script_id = result.scalar() + + return {"id": script_id, "message": "创建成功"} + + +@router.put("/{script_id}") +async def update_script( + script_id: int, + data: ScriptUpdate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """更新脚本""" + # 检查是否存在 + result = db.execute( + text("SELECT id FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + if not result.scalar(): + raise HTTPException(status_code=404, detail="脚本不存在") + + updates = [] + params = {"id": script_id} + + if data.name is not None: + updates.append("name = :name") + params["name"] = data.name + if data.filename is not None: + updates.append("filename = :filename") + params["filename"] = data.filename + if data.description is not None: + updates.append("description = :description") + params["description"] = data.description + if data.script_content is not None: + updates.append("script_content = :script_content") + params["script_content"] = data.script_content + if data.category is not None: + updates.append("category = :category") + params["category"] = data.category + if data.is_enabled is not None: + updates.append("is_enabled = :is_enabled") + params["is_enabled"] = 1 if data.is_enabled else 0 + + if updates: + db.execute( + text(f"UPDATE platform_scripts SET {', '.join(updates)} WHERE id = :id"), + params + ) + db.commit() + + return {"message": "更新成功"} + + +@router.delete("/{script_id}") +async def delete_script( + script_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """删除脚本""" + result = db.execute( + text("SELECT id FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + if not result.scalar(): + raise HTTPException(status_code=404, detail="脚本不存在") + + db.execute( + text("DELETE FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + db.commit() + + return {"message": "删除成功"} + + +@router.post("/{script_id}/run") +async def run_script( + script_id: int, + data: ScriptRunRequest = None, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """执行脚本""" + from ..services.script_executor import test_script as run_test + + # 获取脚本 + result = db.execute( + text("SELECT * FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + script = result.mappings().first() + + if not script: + raise HTTPException(status_code=404, detail="脚本不存在") + + if not script["script_content"]: + raise HTTPException(status_code=400, detail="脚本内容为空") + + # 确定租户ID + tenant_id = (data.tenant_id if data else None) or script["tenant_id"] or "system" + + # 执行脚本 + exec_result = await run_test( + tenant_id=tenant_id, + script_content=script["script_content"] + ) + + # 更新执行状态 + status = "success" if exec_result.success else "failed" + db.execute( + text(""" + UPDATE platform_scripts + SET last_run_at = NOW(), last_run_status = :status + WHERE id = :id + """), + {"id": script_id, "status": status} + ) + db.commit() + + return exec_result.to_dict() + + +@router.post("/{script_id}/copy") +async def copy_script( + script_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """复制脚本""" + # 获取原脚本 + result = db.execute( + text("SELECT * FROM platform_scripts WHERE id = :id"), + {"id": script_id} + ) + script = result.mappings().first() + + if not script: + raise HTTPException(status_code=404, detail="脚本不存在") + + # 创建副本 + new_name = f"{script['name']} - 副本" + db.execute( + text(""" + INSERT INTO platform_scripts + (tenant_id, name, filename, description, script_content, category, is_enabled, created_by) + VALUES (:tenant_id, :name, :filename, :description, :script_content, :category, 1, :created_by) + """), + { + "tenant_id": script["tenant_id"], + "name": new_name, + "filename": None, + "description": script["description"], + "script_content": script["script_content"], + "category": script["category"], + "created_by": user.username if hasattr(user, 'username') else None + } + ) + db.commit() + + result = db.execute(text("SELECT LAST_INSERT_ID() as id")) + new_id = result.scalar() + + return {"id": new_id, "message": "复制成功"} diff --git a/frontend/package.json b/frontend/package.json index 87e012f..094ac1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,9 @@ "echarts": "^5.4.0", "dayjs": "^1.11.0", "codemirror": "^5.65.0", - "vue-codemirror": "^6.1.1" + "vue-codemirror": "^6.1.1", + "monaco-editor": "^0.45.0", + "@monaco-editor/loader": "^1.4.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 6e84801..29bff2b 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -17,6 +17,7 @@ const menuItems = computed(() => { { path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' }, { path: '/app-config', title: '租户订阅', icon: 'Setting' }, { path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' }, + { path: '/scripts', title: '脚本管理', icon: 'Tickets' }, { path: '/stats', title: '统计分析', icon: 'TrendCharts' }, { path: '/logs', title: '日志查看', icon: 'Document' } ] diff --git a/frontend/src/components/MonacoEditor.vue b/frontend/src/components/MonacoEditor.vue new file mode 100644 index 0000000..3552a18 --- /dev/null +++ b/frontend/src/components/MonacoEditor.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 7067145..418e068 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -61,6 +61,12 @@ const routes = [ component: () => import('@/views/scheduled-tasks/index.vue'), meta: { title: '定时任务', icon: 'Clock' } }, + { + path: 'scripts', + name: 'Scripts', + component: () => import('@/views/scripts/index.vue'), + meta: { title: '脚本管理', icon: 'Document' } + }, { path: 'stats', name: 'Stats', diff --git a/frontend/src/views/scheduled-tasks/index.vue b/frontend/src/views/scheduled-tasks/index.vue index a9efda9..449ff9a 100644 --- a/frontend/src/views/scheduled-tasks/index.vue +++ b/frontend/src/views/scheduled-tasks/index.vue @@ -48,6 +48,11 @@ const form = reactive({ const templateList = ref([]) const templateDialogVisible = ref(false) +// 脚本库 +const scriptLibList = ref([]) +const scriptLibDialogVisible = ref(false) +const scriptLibLoading = ref(false) + // 版本 const versionsDialogVisible = ref(false) const versionsLoading = ref(false) @@ -433,6 +438,33 @@ function applyTemplate(template) { ElMessage.success(`已应用模板:${template.name}`) } +// ============ 脚本库功能 ============ +async function handleSelectFromScriptLib() { + scriptLibLoading.value = true + scriptLibDialogVisible.value = true + + try { + const res = await api.get('/api/scripts', { params: { size: 100 } }) + scriptLibList.value = res.data.items || [] + } catch (e) { + console.error('获取脚本库失败:', e) + } finally { + scriptLibLoading.value = false + } +} + +async function applyScriptFromLib(script) { + try { + // 获取脚本完整内容 + const res = await api.get(`/api/scripts/${script.id}`) + form.script_content = res.data.script_content || '' + scriptLibDialogVisible.value = false + ElMessage.success(`已导入脚本:${script.name}`) + } catch (e) { + console.error(e) + } +} + // ============ 版本功能 ============ async function handleViewVersions(row) { currentVersionTaskId.value = row.id @@ -739,11 +771,14 @@ onMounted(() => {
- 查看 SDK 文档 + SDK 文档 选择模板 + + 从脚本库导入 +
+ + +
+
+ 暂无脚本,请先在「脚本管理」页面创建 +
+
+
+
+ {{ script.name }} + {{ script.category }} + + {{ script.last_run_status }} + +
+
{{ script.description || '暂无描述' }}
+
+ {{ script.filename }} + {{ script.content_length }} 字符 +
+
+
+
+ +
+ @@ -1206,6 +1281,51 @@ log('执行完成')" padding: 40px 0; } +/* 脚本库列表 */ +.script-lib-list { + max-height: 450px; + overflow-y: auto; +} + +.script-lib-item { + padding: 12px 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.script-lib-item:hover { + border-color: #67c23a; + background: #f0f9eb; +} + +.script-lib-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.script-lib-name { + font-weight: 600; + color: #303133; +} + +.script-lib-desc { + font-size: 12px; + color: #606266; + margin-bottom: 6px; +} + +.script-lib-meta { + display: flex; + gap: 16px; + font-size: 11px; + color: #909399; +} + /* 统计 */ .stats-grid { display: grid; diff --git a/frontend/src/views/scripts/index.vue b/frontend/src/views/scripts/index.vue new file mode 100644 index 0000000..92c28e4 --- /dev/null +++ b/frontend/src/views/scripts/index.vue @@ -0,0 +1,772 @@ + + + + +