feat: 脚本管理页面(类似青龙面板)
Some checks failed
continuous-integration/drone/push Build is failing

- 新增脚本管理页面,左右分栏布局
- 集成 Monaco Editor 代码编辑器(语法高亮、行号、快捷键)
- 支持脚本 CRUD、运行、复制等操作
- 定时任务支持从脚本库导入脚本
- 新增 platform_scripts 表存储脚本
This commit is contained in:
2026-01-28 13:13:08 +08:00
parent 9b72e6127f
commit 2f9d85edb6
8 changed files with 1372 additions and 2 deletions

View File

@@ -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")

View 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": "复制成功"}

View File

@@ -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",

View File

@@ -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' }
] ]

View 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>

View File

@@ -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',

View File

@@ -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;

View 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>