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

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