feat: 脚本执行平台功能
Some checks failed
continuous-integration/drone/push Build is failing

- 支持 Python 脚本定时执行(类似青龙面板)
- 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储
- 安全沙箱执行,禁用危险模块
- 前端脚本编辑器,支持测试执行
- SDK 文档查看
- 日志通过 TraceID 与 platform_logs 关联
This commit is contained in:
2026-01-28 11:45:02 +08:00
parent ed88099cf0
commit 644255891e
6 changed files with 1153 additions and 35 deletions

View File

@@ -16,7 +16,9 @@
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.4.0",
"dayjs": "^1.11.0"
"dayjs": "^1.11.0",
"codemirror": "^5.65.0",
"vue-codemirror": "^6.1.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",

View File

@@ -32,18 +32,27 @@ const form = reactive({
schedule_type: 'simple',
time_points: [],
cron_expression: '',
execution_type: 'webhook', // webhook | script
webhook_url: '',
input_params: '',
script_content: '',
is_enabled: true
})
// 时间选择器
const newTimePoint = ref('')
// 脚本测试
const testLoading = ref(false)
const testResult = ref(null)
// SDK 文档
const sdkDocsVisible = ref(false)
const sdkDocs = ref(null)
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
webhook_url: [{ required: true, message: '请输入 Webhook URL', trigger: 'blur' }]
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }]
}
// 日志对话框
@@ -136,11 +145,14 @@ function handleCreate() {
schedule_type: 'simple',
time_points: [],
cron_expression: '',
execution_type: 'webhook',
webhook_url: '',
input_params: '',
script_content: '',
is_enabled: true
})
newTimePoint.value = ''
testResult.value = null
dialogVisible.value = true
}
@@ -154,11 +166,14 @@ function handleEdit(row) {
schedule_type: row.schedule_type || 'simple',
time_points: row.time_points || [],
cron_expression: row.cron_expression || '',
webhook_url: row.webhook_url,
execution_type: row.execution_type || 'webhook',
webhook_url: row.webhook_url || '',
input_params: row.input_params ? JSON.stringify(row.input_params, null, 2) : '',
script_content: row.script_content || '',
is_enabled: row.is_enabled
})
newTimePoint.value = ''
testResult.value = null
dialogVisible.value = true
}
@@ -190,6 +205,16 @@ async function handleSubmit() {
return
}
// 验证执行配置
if (form.execution_type === 'webhook' && !form.webhook_url) {
ElMessage.error('请输入 Webhook URL')
return
}
if (form.execution_type === 'script' && !form.script_content) {
ElMessage.error('请输入脚本内容')
return
}
// 解析输入参数
let inputParams = null
if (form.input_params) {
@@ -208,7 +233,9 @@ async function handleSubmit() {
schedule_type: form.schedule_type,
time_points: form.schedule_type === 'simple' ? form.time_points : null,
cron_expression: form.schedule_type === 'cron' ? form.cron_expression : null,
webhook_url: form.webhook_url,
execution_type: form.execution_type,
webhook_url: form.execution_type === 'webhook' ? form.webhook_url : null,
script_content: form.execution_type === 'script' ? form.script_content : null,
input_params: inputParams,
is_enabled: form.is_enabled
}
@@ -294,6 +321,54 @@ function handleLogsPageChange(page) {
fetchLogs()
}
// 测试脚本执行
async function handleTestScript() {
if (!form.script_content) {
ElMessage.warning('请先输入脚本内容')
return
}
if (!form.tenant_id) {
ElMessage.warning('请先选择租户')
return
}
testLoading.value = true
testResult.value = null
try {
const res = await api.post('/api/scheduled-tasks/test-script', {
tenant_id: form.tenant_id,
script_content: form.script_content
})
testResult.value = res.data
if (res.data.success) {
ElMessage.success('脚本执行成功')
} else {
ElMessage.error('脚本执行失败')
}
} catch (e) {
testResult.value = { success: false, error: e.message || '请求失败' }
} finally {
testLoading.value = false
}
}
// 查看 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 getExecutionTypeDesc(row) {
return row.execution_type === 'script' ? '脚本' : 'Webhook'
}
// 快速选择租户
function selectTenant(code) {
if (query.tenant_id === code) {
@@ -369,6 +444,13 @@ onMounted(() => {
</span>
</template>
</el-table-column>
<el-table-column label="执行方式" width="90">
<template #default="{ row }">
<el-tag :type="row.execution_type === 'script' ? 'success' : 'primary'" size="small">
{{ row.execution_type === 'script' ? '脚本' : 'Webhook' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
@@ -502,18 +584,86 @@ onMounted(() => {
</div>
</el-form-item>
<el-form-item label="Webhook URL" prop="webhook_url">
<el-input v-model="form.webhook_url" placeholder="如https://n8n.ireborn.com.cn/webhook/xxx" />
<el-divider content-position="left">执行配置</el-divider>
<el-form-item label="执行方式">
<el-radio-group v-model="form.execution_type">
<el-radio value="webhook">Webhook调用 n8n </el-radio>
<el-radio value="script">Python 脚本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="输入参数">
<el-input
v-model="form.input_params"
type="textarea"
:rows="4"
placeholder='可选JSON 格式,如:{"key": "value"}'
/>
</el-form-item>
<!-- Webhook 模式 -->
<template v-if="form.execution_type === 'webhook'">
<el-form-item label="Webhook URL">
<el-input v-model="form.webhook_url" placeholder="如https://n8n.ireborn.com.cn/webhook/xxx" />
</el-form-item>
<el-form-item label="输入参数">
<el-input
v-model="form.input_params"
type="textarea"
:rows="4"
placeholder='可选JSON 格式,如:{"key": "value"}'
/>
</el-form-item>
</template>
<!-- 脚本模式 -->
<template v-if="form.execution_type === 'script'">
<el-form-item label="脚本内容">
<div class="script-editor-header">
<el-button type="primary" link size="small" @click="handleShowSdkDocs">
查看 SDK 文档
</el-button>
<el-button
type="success"
size="small"
:loading="testLoading"
@click="handleTestScript"
>
测试执行
</el-button>
</div>
<el-input
v-model="form.script_content"
type="textarea"
:rows="15"
placeholder="# Python 脚本
# 可用方法ai(), dingtalk(), wecom(), db(), http_get(), http_post()
# get_var(), set_var(), log()
content = ai('生成一段营销文案')
dingtalk('你的webhook', content)
log('执行完成')"
class="script-textarea"
/>
</el-form-item>
<!-- 测试结果 -->
<el-form-item v-if="testResult" label="测试结果">
<el-alert
:type="testResult.success ? 'success' : 'error'"
:closable="false"
>
<template #title>
{{ testResult.success ? '执行成功' : '执行失败' }}
<span v-if="testResult.execution_time_ms" style="margin-left: 8px; color: #909399">
({{ testResult.execution_time_ms }}ms)
</span>
</template>
<div v-if="testResult.error" class="test-error">{{ testResult.error }}</div>
<div v-if="testResult.output" class="test-output">
<strong>输出</strong>
<pre>{{ testResult.output }}</pre>
</div>
<div v-if="testResult.logs && testResult.logs.length" class="test-logs">
<strong>日志</strong>
<pre>{{ testResult.logs.join('\n') }}</pre>
</div>
</el-alert>
</el-form-item>
</template>
<el-form-item label="启用状态">
<el-switch v-model="form.is_enabled" />
@@ -526,6 +676,26 @@ onMounted(() => {
</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>
<!-- 日志对话框 -->
<el-dialog
v-model="logsDialogVisible"
@@ -642,4 +812,89 @@ onMounted(() => {
font-size: 12px;
margin-top: 4px;
}
/* 脚本编辑器 */
.script-editor-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.script-textarea :deep(.el-textarea__inner) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
}
/* 测试结果 */
.test-error {
color: #f56c6c;
margin-top: 8px;
}
.test-output,
.test-logs {
margin-top: 8px;
}
.test-output pre,
.test-logs pre {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
max-height: 150px;
overflow-y: auto;
margin-top: 4px;
}
/* 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>