Files
000-platform/frontend/src/views/scripts/index.vue
Admin 2f9d85edb6
Some checks failed
continuous-integration/drone/push Build is failing
feat: 脚本管理页面(类似青龙面板)
- 新增脚本管理页面,左右分栏布局
- 集成 Monaco Editor 代码编辑器(语法高亮、行号、快捷键)
- 支持脚本 CRUD、运行、复制等操作
- 定时任务支持从脚本库导入脚本
- 新增 platform_scripts 表存储脚本
2026-01-28 13:13:08 +08:00

773 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>