- 新增脚本管理页面,左右分栏布局 - 集成 Monaco Editor 代码编辑器(语法高亮、行号、快捷键) - 支持脚本 CRUD、运行、复制等操作 - 定时任务支持从脚本库导入脚本 - 新增 platform_scripts 表存储脚本
This commit is contained in:
@@ -16,6 +16,7 @@ from .routers.cost import router as cost_router
|
|||||||
from .routers.quota import router as quota_router
|
from .routers.quota import router as quota_router
|
||||||
from .routers.tool_configs import router as tool_configs_router
|
from .routers.tool_configs import router as tool_configs_router
|
||||||
from .routers.tasks import router as tasks_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 import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
|
||||||
from .middleware.trace import setup_logging
|
from .middleware.trace import setup_logging
|
||||||
from .services.scheduler import start_scheduler, shutdown_scheduler
|
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(quota_router, prefix="/api")
|
||||||
app.include_router(tool_configs_router, prefix="/api")
|
app.include_router(tool_configs_router, prefix="/api")
|
||||||
app.include_router(tasks_router, prefix="/api")
|
app.include_router(tasks_router, prefix="/api")
|
||||||
|
app.include_router(scripts_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
325
backend/app/routers/scripts.py
Normal file
325
backend/app/routers/scripts.py
Normal file
@@ -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": "复制成功"}
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"echarts": "^5.4.0",
|
"echarts": "^5.4.0",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.0",
|
||||||
"codemirror": "^5.65.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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const menuItems = computed(() => {
|
|||||||
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
|
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
|
||||||
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
|
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
|
||||||
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
|
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
|
||||||
|
{ path: '/scripts', title: '脚本管理', icon: 'Tickets' },
|
||||||
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
|
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
|
||||||
{ path: '/logs', title: '日志查看', icon: 'Document' }
|
{ path: '/logs', title: '日志查看', icon: 'Document' }
|
||||||
]
|
]
|
||||||
|
|||||||
142
frontend/src/components/MonacoEditor.vue
Normal file
142
frontend/src/components/MonacoEditor.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
|
import loader from '@monaco-editor/loader'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: String,
|
||||||
|
default: 'python'
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: 'vs-dark'
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save', 'run'])
|
||||||
|
|
||||||
|
const editorContainer = ref(null)
|
||||||
|
let editor = null
|
||||||
|
let monaco = null
|
||||||
|
|
||||||
|
// 初始化编辑器
|
||||||
|
async function initEditor() {
|
||||||
|
if (!editorContainer.value) return
|
||||||
|
|
||||||
|
// 加载 Monaco
|
||||||
|
monaco = await loader.init()
|
||||||
|
|
||||||
|
// 创建编辑器
|
||||||
|
editor = monaco.editor.create(editorContainer.value, {
|
||||||
|
value: props.modelValue,
|
||||||
|
language: props.language,
|
||||||
|
theme: props.theme,
|
||||||
|
readOnly: props.readonly,
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: true },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
tabSize: 4,
|
||||||
|
insertSpaces: true,
|
||||||
|
folding: true,
|
||||||
|
renderLineHighlight: 'all',
|
||||||
|
selectOnLineNumbers: true,
|
||||||
|
roundedSelection: true,
|
||||||
|
cursorStyle: 'line',
|
||||||
|
cursorBlinking: 'smooth',
|
||||||
|
smoothScrolling: true,
|
||||||
|
...props.options
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
const value = editor.getValue()
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 快捷键
|
||||||
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||||
|
emit('save')
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
|
||||||
|
emit('run')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听值变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (editor && editor.getValue() !== newVal) {
|
||||||
|
editor.setValue(newVal || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听主题变化
|
||||||
|
watch(() => props.theme, (newTheme) => {
|
||||||
|
if (monaco) {
|
||||||
|
monaco.editor.setTheme(newTheme)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听只读变化
|
||||||
|
watch(() => props.readonly, (newVal) => {
|
||||||
|
if (editor) {
|
||||||
|
editor.updateOptions({ readOnly: newVal })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initEditor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.dispose()
|
||||||
|
editor = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
getEditor: () => editor,
|
||||||
|
focus: () => editor?.focus(),
|
||||||
|
format: () => editor?.getAction('editor.action.formatDocument')?.run()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="editorContainer"
|
||||||
|
class="monaco-editor-container"
|
||||||
|
:style="{ height }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monaco-editor-container {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -61,6 +61,12 @@ const routes = [
|
|||||||
component: () => import('@/views/scheduled-tasks/index.vue'),
|
component: () => import('@/views/scheduled-tasks/index.vue'),
|
||||||
meta: { title: '定时任务', icon: 'Clock' }
|
meta: { title: '定时任务', icon: 'Clock' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'scripts',
|
||||||
|
name: 'Scripts',
|
||||||
|
component: () => import('@/views/scripts/index.vue'),
|
||||||
|
meta: { title: '脚本管理', icon: 'Document' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'stats',
|
path: 'stats',
|
||||||
name: 'Stats',
|
name: 'Stats',
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ const form = reactive({
|
|||||||
const templateList = ref([])
|
const templateList = ref([])
|
||||||
const templateDialogVisible = ref(false)
|
const templateDialogVisible = ref(false)
|
||||||
|
|
||||||
|
// 脚本库
|
||||||
|
const scriptLibList = ref([])
|
||||||
|
const scriptLibDialogVisible = ref(false)
|
||||||
|
const scriptLibLoading = ref(false)
|
||||||
|
|
||||||
// 版本
|
// 版本
|
||||||
const versionsDialogVisible = ref(false)
|
const versionsDialogVisible = ref(false)
|
||||||
const versionsLoading = ref(false)
|
const versionsLoading = ref(false)
|
||||||
@@ -433,6 +438,33 @@ function applyTemplate(template) {
|
|||||||
ElMessage.success(`已应用模板:${template.name}`)
|
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) {
|
async function handleViewVersions(row) {
|
||||||
currentVersionTaskId.value = row.id
|
currentVersionTaskId.value = row.id
|
||||||
@@ -739,11 +771,14 @@ onMounted(() => {
|
|||||||
<div class="script-editor-header">
|
<div class="script-editor-header">
|
||||||
<div>
|
<div>
|
||||||
<el-button type="primary" link size="small" @click="handleShowSdkDocs">
|
<el-button type="primary" link size="small" @click="handleShowSdkDocs">
|
||||||
查看 SDK 文档
|
SDK 文档
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="warning" link size="small" @click="handleSelectTemplate">
|
<el-button type="warning" link size="small" @click="handleSelectTemplate">
|
||||||
选择模板
|
选择模板
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="success" link size="small" @click="handleSelectFromScriptLib">
|
||||||
|
从脚本库导入
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
type="success"
|
type="success"
|
||||||
@@ -929,6 +964,46 @@ log('执行完成')"
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 脚本库选择对话框 -->
|
||||||
|
<el-dialog v-model="scriptLibDialogVisible" title="从脚本库导入" width="700px">
|
||||||
|
<div v-loading="scriptLibLoading">
|
||||||
|
<div v-if="scriptLibList.length === 0 && !scriptLibLoading" class="empty-tip">
|
||||||
|
暂无脚本,请先在「脚本管理」页面创建
|
||||||
|
</div>
|
||||||
|
<div v-else class="script-lib-list">
|
||||||
|
<div
|
||||||
|
v-for="script in scriptLibList"
|
||||||
|
:key="script.id"
|
||||||
|
class="script-lib-item"
|
||||||
|
@click="applyScriptFromLib(script)"
|
||||||
|
>
|
||||||
|
<div class="script-lib-header">
|
||||||
|
<span class="script-lib-name">{{ script.name }}</span>
|
||||||
|
<el-tag v-if="script.category" size="small" type="info">{{ script.category }}</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="script.last_run_status"
|
||||||
|
size="small"
|
||||||
|
:type="script.last_run_status === 'success' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ script.last_run_status }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="script-lib-desc">{{ script.description || '暂无描述' }}</div>
|
||||||
|
<div class="script-lib-meta">
|
||||||
|
<span>{{ script.filename }}</span>
|
||||||
|
<span v-if="script.content_length">{{ script.content_length }} 字符</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="scriptLibDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="$router.push('/scripts')">
|
||||||
|
管理脚本库
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 版本管理对话框 -->
|
<!-- 版本管理对话框 -->
|
||||||
<el-dialog v-model="versionsDialogVisible" title="脚本版本历史" width="700px">
|
<el-dialog v-model="versionsDialogVisible" title="脚本版本历史" width="700px">
|
||||||
<el-table v-loading="versionsLoading" :data="versionsList" style="width: 100%">
|
<el-table v-loading="versionsLoading" :data="versionsList" style="width: 100%">
|
||||||
@@ -1206,6 +1281,51 @@ log('执行完成')"
|
|||||||
padding: 40px 0;
|
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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
772
frontend/src/views/scripts/index.vue
Normal file
772
frontend/src/views/scripts/index.vue
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Delete, VideoPlay, Document, FolderOpened, Search, Refresh, CopyDocument } from '@element-plus/icons-vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import MonacoEditor from '@/components/MonacoEditor.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 脚本列表
|
||||||
|
const loading = ref(false)
|
||||||
|
const scriptList = ref([])
|
||||||
|
const categories = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const query = reactive({
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
category: '',
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前编辑的脚本
|
||||||
|
const currentScript = ref(null)
|
||||||
|
const scriptContent = ref('')
|
||||||
|
const isModified = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const running = ref(false)
|
||||||
|
|
||||||
|
// 新建对话框
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const createForm = reactive({
|
||||||
|
name: '',
|
||||||
|
filename: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
script_content: '# 新脚本\n\nlog("Hello World!")\n'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 运行结果
|
||||||
|
const runResult = ref(null)
|
||||||
|
const runDialogVisible = ref(false)
|
||||||
|
|
||||||
|
// SDK 文档
|
||||||
|
const sdkDocsVisible = ref(false)
|
||||||
|
const sdkDocs = ref(null)
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
watch(scriptContent, (newVal) => {
|
||||||
|
if (currentScript.value) {
|
||||||
|
isModified.value = newVal !== currentScript.value.script_content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取脚本列表
|
||||||
|
async function fetchScripts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/scripts', { params: query })
|
||||||
|
scriptList.value = res.data.items || []
|
||||||
|
categories.value = res.data.categories || []
|
||||||
|
total.value = res.data.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择脚本
|
||||||
|
async function selectScript(script) {
|
||||||
|
// 检查是否有未保存的修改
|
||||||
|
if (isModified.value && currentScript.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前脚本有未保存的修改,是否放弃?', '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '放弃',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/scripts/${script.id}`)
|
||||||
|
currentScript.value = res.data
|
||||||
|
scriptContent.value = res.data.script_content || ''
|
||||||
|
isModified.value = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存脚本
|
||||||
|
async function handleSave() {
|
||||||
|
if (!currentScript.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/api/scripts/${currentScript.value.id}`, {
|
||||||
|
script_content: scriptContent.value
|
||||||
|
})
|
||||||
|
currentScript.value.script_content = scriptContent.value
|
||||||
|
isModified.value = false
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行脚本
|
||||||
|
async function handleRun() {
|
||||||
|
if (!currentScript.value) return
|
||||||
|
|
||||||
|
// 如果有修改,先保存
|
||||||
|
if (isModified.value) {
|
||||||
|
await handleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
running.value = true
|
||||||
|
runResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/scripts/${currentScript.value.id}/run`)
|
||||||
|
runResult.value = res.data
|
||||||
|
runDialogVisible.value = true
|
||||||
|
fetchScripts() // 刷新执行状态
|
||||||
|
} catch (e) {
|
||||||
|
runResult.value = { success: false, error: e.message || '执行失败' }
|
||||||
|
runDialogVisible.value = true
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建脚本
|
||||||
|
function handleCreate() {
|
||||||
|
Object.assign(createForm, {
|
||||||
|
name: '',
|
||||||
|
filename: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
script_content: '# 新脚本\n\nlog("Hello World!")\n'
|
||||||
|
})
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!createForm.name) {
|
||||||
|
ElMessage.warning('请输入脚本名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/scripts', createForm)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
createDialogVisible.value = false
|
||||||
|
await fetchScripts()
|
||||||
|
|
||||||
|
// 自动选中新创建的脚本
|
||||||
|
const newScript = scriptList.value.find(s => s.id === res.data.id)
|
||||||
|
if (newScript) {
|
||||||
|
selectScript(newScript)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除脚本
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!currentScript.value) return
|
||||||
|
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除脚本「${currentScript.value.name}」吗?此操作不可恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/scripts/${currentScript.value.id}`)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
currentScript.value = null
|
||||||
|
scriptContent.value = ''
|
||||||
|
isModified.value = false
|
||||||
|
fetchScripts()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制脚本
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!currentScript.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/scripts/${currentScript.value.id}/copy`)
|
||||||
|
ElMessage.success('复制成功')
|
||||||
|
await fetchScripts()
|
||||||
|
|
||||||
|
// 自动选中新创建的脚本
|
||||||
|
const newScript = scriptList.value.find(s => s.id === res.data.id)
|
||||||
|
if (newScript) {
|
||||||
|
selectScript(newScript)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新脚本信息
|
||||||
|
async function handleUpdateInfo() {
|
||||||
|
if (!currentScript.value) return
|
||||||
|
|
||||||
|
const { value } = await ElMessageBox.prompt('请输入新的脚本名称', '修改名称', {
|
||||||
|
inputValue: currentScript.value.name,
|
||||||
|
confirmButtonText: '保存',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (value && value !== currentScript.value.name) {
|
||||||
|
try {
|
||||||
|
await api.put(`/api/scripts/${currentScript.value.id}`, { name: value })
|
||||||
|
currentScript.value.name = value
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
fetchScripts()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看 SDK 文档
|
||||||
|
async function handleShowSdkDocs() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/scheduled-tasks/sdk-docs')
|
||||||
|
sdkDocs.value = res.data
|
||||||
|
sdkDocsVisible.value = true
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取文档失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(time) {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态类型
|
||||||
|
function getStatusType(status) {
|
||||||
|
const map = {
|
||||||
|
success: 'success',
|
||||||
|
failed: 'danger',
|
||||||
|
running: 'warning'
|
||||||
|
}
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchScripts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scripts-page">
|
||||||
|
<!-- 左侧:脚本列表 -->
|
||||||
|
<div class="scripts-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>脚本管理</h3>
|
||||||
|
<el-button v-if="authStore.isOperator" type="primary" size="small" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索和筛选 -->
|
||||||
|
<div class="sidebar-filter">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="搜索脚本..."
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
@change="fetchScripts"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="query.category"
|
||||||
|
placeholder="分类"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
style="width: 100%; margin-top: 8px"
|
||||||
|
@change="fetchScripts"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat"
|
||||||
|
:label="cat"
|
||||||
|
:value="cat"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 脚本列表 -->
|
||||||
|
<div class="scripts-list" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-for="script in scriptList"
|
||||||
|
:key="script.id"
|
||||||
|
class="script-item"
|
||||||
|
:class="{ active: currentScript?.id === script.id }"
|
||||||
|
@click="selectScript(script)"
|
||||||
|
>
|
||||||
|
<div class="script-icon">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="script-info">
|
||||||
|
<div class="script-name">{{ script.name }}</div>
|
||||||
|
<div class="script-meta">
|
||||||
|
<el-tag v-if="script.category" size="small" type="info">{{ script.category }}</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="script.last_run_status"
|
||||||
|
size="small"
|
||||||
|
:type="getStatusType(script.last_run_status)"
|
||||||
|
>
|
||||||
|
{{ script.last_run_status }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && scriptList.length === 0" class="empty-tip">
|
||||||
|
暂无脚本
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:编辑器区域 -->
|
||||||
|
<div class="scripts-editor">
|
||||||
|
<template v-if="currentScript">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="script-title" @click="handleUpdateInfo">
|
||||||
|
{{ currentScript.name }}
|
||||||
|
<span v-if="isModified" class="modified-dot">●</span>
|
||||||
|
</span>
|
||||||
|
<span class="script-filename">{{ currentScript.filename }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button link size="small" @click="handleShowSdkDocs">
|
||||||
|
SDK 文档
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!isModified"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
保存 (Ctrl+S)
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
:loading="running"
|
||||||
|
@click="handleRun"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
运行 (Ctrl+Enter)
|
||||||
|
</el-button>
|
||||||
|
<el-button link size="small" @click="handleCopy">
|
||||||
|
<el-icon><CopyDocument /></el-icon>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monaco 编辑器 -->
|
||||||
|
<div class="editor-container">
|
||||||
|
<MonacoEditor
|
||||||
|
v-model="scriptContent"
|
||||||
|
language="python"
|
||||||
|
theme="vs-dark"
|
||||||
|
height="100%"
|
||||||
|
@save="handleSave"
|
||||||
|
@run="handleRun"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 脚本信息 -->
|
||||||
|
<div class="editor-footer">
|
||||||
|
<span>创建:{{ formatTime(currentScript.created_at) }}</span>
|
||||||
|
<span>更新:{{ formatTime(currentScript.updated_at) }}</span>
|
||||||
|
<span v-if="currentScript.last_run_at">
|
||||||
|
上次运行:{{ formatTime(currentScript.last_run_at) }}
|
||||||
|
<el-tag size="small" :type="getStatusType(currentScript.last_run_status)">
|
||||||
|
{{ currentScript.last_run_status }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 未选择脚本时的占位 -->
|
||||||
|
<div v-else class="editor-placeholder">
|
||||||
|
<el-icon :size="64" color="#dcdfe6"><Document /></el-icon>
|
||||||
|
<p>选择一个脚本开始编辑</p>
|
||||||
|
<p style="color: #909399; font-size: 12px">或点击左上角"新建"创建新脚本</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建脚本对话框 -->
|
||||||
|
<el-dialog v-model="createDialogVisible" title="新建脚本" width="500px">
|
||||||
|
<el-form :model="createForm" label-width="80px">
|
||||||
|
<el-form-item label="脚本名称" required>
|
||||||
|
<el-input v-model="createForm.name" placeholder="如:每日数据同步" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文件名">
|
||||||
|
<el-input v-model="createForm.filename" placeholder="可选,如 daily_sync.py" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="createForm.category" placeholder="选择或输入分类" filterable allow-create style="width: 100%">
|
||||||
|
<el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 运行结果对话框 -->
|
||||||
|
<el-dialog v-model="runDialogVisible" title="运行结果" width="700px">
|
||||||
|
<div v-if="runResult" class="run-result">
|
||||||
|
<el-alert
|
||||||
|
:type="runResult.success ? 'success' : 'error'"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
{{ runResult.success ? '执行成功' : '执行失败' }}
|
||||||
|
<span v-if="runResult.execution_time_ms" style="margin-left: 8px; color: #909399">
|
||||||
|
({{ runResult.execution_time_ms }}ms)
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<div v-if="runResult.error" class="result-section">
|
||||||
|
<h4>错误信息</h4>
|
||||||
|
<pre class="result-content error">{{ runResult.error }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="runResult.output" class="result-section">
|
||||||
|
<h4>输出</h4>
|
||||||
|
<pre class="result-content">{{ runResult.output }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="runResult.logs && runResult.logs.length" class="result-section">
|
||||||
|
<h4>日志</h4>
|
||||||
|
<pre class="result-content logs">{{ runResult.logs.join('\n') }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="runDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- SDK 文档对话框 -->
|
||||||
|
<el-dialog v-model="sdkDocsVisible" title="SDK 文档" width="800px">
|
||||||
|
<div v-if="sdkDocs" class="sdk-docs">
|
||||||
|
<p>{{ sdkDocs.description }}</p>
|
||||||
|
|
||||||
|
<h4>可用方法</h4>
|
||||||
|
<div v-for="method in sdkDocs.methods" :key="method.name" class="sdk-method">
|
||||||
|
<code>{{ method.name }}</code>
|
||||||
|
<p>{{ method.description }}</p>
|
||||||
|
<pre class="sdk-example">{{ method.example }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>示例脚本</h4>
|
||||||
|
<pre class="sdk-example-script">{{ sdkDocs.example_script }}</pre>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="sdkDocsVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scripts-page {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧边栏 */
|
||||||
|
.scripts-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
border-right: 1px solid #e4e7ed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-filter {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scripts-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-item:hover {
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-item.active {
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-item.active .script-meta .el-tag {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-item.active .script-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧编辑器 */
|
||||||
|
.scripts-editor {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modified-dot {
|
||||||
|
color: #e6a23c;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-filename {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
background: #fafafa;
|
||||||
|
border-top: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-placeholder p {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 运行结果 */
|
||||||
|
.run-result {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content.error {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content.logs {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SDK 文档 */
|
||||||
|
.sdk-docs {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-docs h4 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-method {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-method code {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-method p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-example {
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sdk-example-script {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user