- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表 - 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能 - 实现安全的脚本执行器,支持沙箱环境和禁止危险操作 - 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式 - 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理 - 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
This commit is contained in:
@@ -16,10 +16,9 @@ const menuItems = computed(() => {
|
||||
{ path: '/apps', title: '应用管理', icon: 'Grid' },
|
||||
{ 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' }
|
||||
{ path: '/logs', title: '日志查看', icon: 'Document' },
|
||||
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' }
|
||||
]
|
||||
|
||||
// 管理员才能看到用户管理
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<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>
|
||||
@@ -53,19 +53,7 @@ const routes = [
|
||||
path: 'app-config',
|
||||
name: 'AppConfig',
|
||||
component: () => import('@/views/app-config/index.vue'),
|
||||
meta: { title: '租户订阅', icon: 'Setting' }
|
||||
},
|
||||
{
|
||||
path: 'scheduled-tasks',
|
||||
name: 'ScheduledTasks',
|
||||
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' }
|
||||
meta: { title: '租户应用配置', icon: 'Setting' }
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
@@ -84,6 +72,12 @@ const routes = [
|
||||
name: 'Users',
|
||||
component: () => import('@/views/users/index.vue'),
|
||||
meta: { title: '用户管理', icon: 'User', role: 'admin' }
|
||||
},
|
||||
{
|
||||
path: 'scheduled-tasks',
|
||||
name: 'ScheduledTasks',
|
||||
component: () => import('@/views/scheduled-tasks/index.vue'),
|
||||
meta: { title: '定时任务', icon: 'Clock' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -15,14 +14,6 @@ const query = reactive({
|
||||
size: 20
|
||||
})
|
||||
|
||||
// 配置项类型选项
|
||||
const configTypes = [
|
||||
{ value: 'text', label: '文本输入' },
|
||||
{ value: 'radio', label: '单选' },
|
||||
{ value: 'select', label: '下拉选择' },
|
||||
{ value: 'switch', label: '开关' }
|
||||
]
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
@@ -33,8 +24,7 @@ const form = reactive({
|
||||
app_name: '',
|
||||
base_url: '',
|
||||
description: '',
|
||||
require_jssdk: false,
|
||||
config_schema: [] // 配置项定义
|
||||
require_jssdk: false
|
||||
})
|
||||
|
||||
const rules = {
|
||||
@@ -73,8 +63,7 @@ function handleCreate() {
|
||||
app_name: '',
|
||||
base_url: '',
|
||||
description: '',
|
||||
require_jssdk: false,
|
||||
config_schema: []
|
||||
require_jssdk: false
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -87,55 +76,11 @@ function handleEdit(row) {
|
||||
app_name: row.app_name,
|
||||
base_url: row.base_url || '',
|
||||
description: row.description || '',
|
||||
require_jssdk: row.require_jssdk || false,
|
||||
config_schema: row.config_schema ? row.config_schema.map(c => ({
|
||||
...c,
|
||||
options: c.options || [],
|
||||
option_labels: c.option_labels || {}
|
||||
})) : []
|
||||
require_jssdk: row.require_jssdk || false
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 配置项管理
|
||||
function addConfigItem() {
|
||||
form.config_schema.push({
|
||||
key: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
options: [],
|
||||
option_labels: {},
|
||||
default: '',
|
||||
placeholder: '',
|
||||
required: false
|
||||
})
|
||||
}
|
||||
|
||||
function removeConfigItem(index) {
|
||||
form.config_schema.splice(index, 1)
|
||||
}
|
||||
|
||||
// 选项管理(radio/select 类型)
|
||||
function addOption(config) {
|
||||
const optionKey = `option_${config.options.length + 1}`
|
||||
config.options.push(optionKey)
|
||||
config.option_labels[optionKey] = ''
|
||||
}
|
||||
|
||||
function removeOption(config, index) {
|
||||
const optionKey = config.options[index]
|
||||
config.options.splice(index, 1)
|
||||
delete config.option_labels[optionKey]
|
||||
}
|
||||
|
||||
function updateOptionKey(config, index, newKey) {
|
||||
const oldKey = config.options[index]
|
||||
const oldLabel = config.option_labels[oldKey]
|
||||
delete config.option_labels[oldKey]
|
||||
config.options[index] = newKey
|
||||
config.option_labels[newKey] = oldLabel || ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value.validate()
|
||||
|
||||
@@ -209,14 +154,6 @@ onMounted(() => {
|
||||
<el-table-column prop="app_code" label="应用代码" width="150" />
|
||||
<el-table-column prop="app_name" label="应用名称" width="180" />
|
||||
<el-table-column prop="base_url" label="访问地址" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="配置项" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.config_schema && row.config_schema.length > 0" type="primary" size="small">
|
||||
{{ row.config_schema.length }} 项
|
||||
</el-tag>
|
||||
<span v-else style="color: #909399; font-size: 12px">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="JS-SDK" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
|
||||
@@ -254,7 +191,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px">
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="应用代码" prop="app_code">
|
||||
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
|
||||
@@ -275,69 +212,6 @@ onMounted(() => {
|
||||
<el-switch v-model="form.require_jssdk" />
|
||||
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 配置项定义 -->
|
||||
<el-divider content-position="left">配置项定义</el-divider>
|
||||
<div class="config-schema-section">
|
||||
<div class="config-schema-tip">
|
||||
定义租户订阅时可配置的参数,如行业类型、提示词等
|
||||
</div>
|
||||
|
||||
<div v-for="(config, index) in form.config_schema" :key="index" class="config-schema-item">
|
||||
<div class="config-header">
|
||||
<span class="config-index">#{{ index + 1 }}</span>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeConfigItem(index)" />
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<el-input v-model="config.key" placeholder="配置键(如:industry)" style="width: 140px" />
|
||||
<el-input v-model="config.label" placeholder="显示标签(如:行业类型)" style="width: 160px" />
|
||||
<el-select v-model="config.type" placeholder="类型" style="width: 120px">
|
||||
<el-option v-for="t in configTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="config.required">必填</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- text 类型:显示 placeholder -->
|
||||
<div v-if="config.type === 'text'" class="config-row" style="margin-top: 8px">
|
||||
<el-input v-model="config.placeholder" placeholder="输入提示文字" style="width: 300px" />
|
||||
<el-input v-model="config.default" placeholder="默认值" style="width: 200px" />
|
||||
</div>
|
||||
|
||||
<!-- switch 类型:显示默认值 -->
|
||||
<div v-if="config.type === 'switch'" class="config-row" style="margin-top: 8px">
|
||||
<span style="color: #606266; margin-right: 8px">默认值:</span>
|
||||
<el-switch v-model="config.default" active-value="true" inactive-value="false" />
|
||||
</div>
|
||||
|
||||
<!-- radio/select 类型:显示选项编辑 -->
|
||||
<div v-if="config.type === 'radio' || config.type === 'select'" class="config-options">
|
||||
<div class="options-label">选项列表:</div>
|
||||
<div v-for="(opt, optIndex) in config.options" :key="optIndex" class="option-row">
|
||||
<el-input
|
||||
:model-value="opt"
|
||||
@update:model-value="v => updateOptionKey(config, optIndex, v)"
|
||||
placeholder="选项值(如:medical)"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="config.option_labels[opt]"
|
||||
placeholder="显示名(如:医美)"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<el-radio v-model="config.default" :value="opt">默认</el-radio>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeOption(config, optIndex)" />
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="addOption(config)">
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" plain @click="addConfigItem" style="margin-top: 12px">
|
||||
<el-icon><Plus /></el-icon> 添加配置项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -351,61 +225,4 @@ onMounted(() => {
|
||||
.page-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 配置项定义样式 */
|
||||
.config-schema-section {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.config-schema-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-schema-item {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-index {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-options {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.options-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,772 +0,0 @@
|
||||
<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