feat: 定时任务调度功能
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_scheduled_tasks 和 platform_task_logs 数据表
- 实现 APScheduler 调度器服务(支持简单模式和CRON表达式)
- 添加定时任务 CRUD API
- 支持手动触发执行和查看执行日志
- 前端任务管理页面
This commit is contained in:
2026-01-28 11:27:42 +08:00
parent e45fe8128c
commit ed88099cf0
7 changed files with 1319 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ 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: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
]

View File

@@ -53,7 +53,13 @@ const routes = [
path: 'app-config',
name: 'AppConfig',
component: () => import('@/views/app-config/index.vue'),
meta: { title: '租户应用配置', icon: 'Setting' }
meta: { title: '租户订阅', icon: 'Setting' }
},
{
path: 'scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/scheduled-tasks/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
},
{
path: 'stats',

View File

@@ -0,0 +1,645 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, VideoPlay, Clock, Document } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: ''
})
// 租户列表
const tenantList = ref([])
const tenantMap = ref({})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
task_name: '',
task_desc: '',
schedule_type: 'simple',
time_points: [],
cron_expression: '',
webhook_url: '',
input_params: '',
is_enabled: true
})
// 时间选择器
const newTimePoint = ref('')
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' }]
}
// 日志对话框
const logsDialogVisible = ref(false)
const logsLoading = ref(false)
const logsData = ref([])
const logsTotal = ref(0)
const logsTaskId = ref(null)
const logsQuery = reactive({
page: 1,
size: 10
})
// 获取租户名称
function getTenantName(code) {
return tenantMap.value[code] || code
}
// 获取调度描述
function getScheduleDesc(row) {
if (row.schedule_type === 'cron') {
return `CRON: ${row.cron_expression}`
}
const points = row.time_points || []
if (points.length === 0) return '-'
if (points.length <= 3) return points.join(', ')
return `${points.slice(0, 3).join(', ')}${points.length}个时间点`
}
// 获取状态标签类型
function getStatusType(status) {
const map = {
success: 'success',
failed: 'danger',
running: 'warning'
}
return map[status] || 'info'
}
// 格式化时间
function formatTime(time) {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
const map = {}
tenantList.value.forEach(t => {
map[t.code] = t.name
})
tenantMap.value = map
} catch (e) {
console.error('获取租户列表失败:', e)
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/scheduled-tasks', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建定时任务'
Object.assign(form, {
tenant_id: '',
task_name: '',
task_desc: '',
schedule_type: 'simple',
time_points: [],
cron_expression: '',
webhook_url: '',
input_params: '',
is_enabled: true
})
newTimePoint.value = ''
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑定时任务'
Object.assign(form, {
tenant_id: row.tenant_id,
task_name: row.task_name,
task_desc: row.task_desc || '',
schedule_type: row.schedule_type || 'simple',
time_points: row.time_points || [],
cron_expression: row.cron_expression || '',
webhook_url: row.webhook_url,
input_params: row.input_params ? JSON.stringify(row.input_params, null, 2) : '',
is_enabled: row.is_enabled
})
newTimePoint.value = ''
dialogVisible.value = true
}
// 添加时间点
function addTimePoint() {
if (!newTimePoint.value) return
if (!form.time_points.includes(newTimePoint.value)) {
form.time_points.push(newTimePoint.value)
form.time_points.sort()
}
newTimePoint.value = ''
}
// 移除时间点
function removeTimePoint(index) {
form.time_points.splice(index, 1)
}
async function handleSubmit() {
await formRef.value.validate()
// 验证调度配置
if (form.schedule_type === 'simple' && form.time_points.length === 0) {
ElMessage.error('请至少添加一个执行时间点')
return
}
if (form.schedule_type === 'cron' && !form.cron_expression) {
ElMessage.error('请输入 CRON 表达式')
return
}
// 解析输入参数
let inputParams = null
if (form.input_params) {
try {
inputParams = JSON.parse(form.input_params)
} catch (e) {
ElMessage.error('输入参数格式错误,请输入有效的 JSON')
return
}
}
const data = {
tenant_id: form.tenant_id,
task_name: form.task_name,
task_desc: form.task_desc,
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,
input_params: inputParams,
is_enabled: form.is_enabled
}
try {
if (editingId.value) {
await api.put(`/api/scheduled-tasks/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/scheduled-tasks', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除任务「${row.task_name}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/scheduled-tasks/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleToggle(row) {
try {
const res = await api.post(`/api/scheduled-tasks/${row.id}/toggle`)
ElMessage.success(res.data.message)
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleRunNow(row) {
await ElMessageBox.confirm(`确定立即执行任务「${row.task_name}」吗?`, '手动执行', {
type: 'info'
})
try {
await api.post(`/api/scheduled-tasks/${row.id}/run`)
ElMessage.success('任务已触发执行')
// 延迟刷新以获取最新状态
setTimeout(() => fetchList(), 2000)
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewLogs(row) {
logsTaskId.value = row.id
logsQuery.page = 1
logsDialogVisible.value = true
await fetchLogs()
}
async function fetchLogs() {
logsLoading.value = true
try {
const res = await api.get(`/api/scheduled-tasks/${logsTaskId.value}/logs`, {
params: logsQuery
})
logsData.value = res.data.items || []
logsTotal.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
logsLoading.value = false
}
}
function handleLogsPageChange(page) {
logsQuery.page = page
fetchLogs()
}
// 快速选择租户
function selectTenant(code) {
if (query.tenant_id === code) {
query.tenant_id = ''
} else {
query.tenant_id = code
}
handleSearch()
}
onMounted(() => {
fetchTenants()
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">定时任务</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
<div class="page-tip">
<el-alert type="info" :closable="false">
管理定时任务支持简单时间点和 CRON 表达式两种调度方式可自动调用 n8n 工作流
</el-alert>
</div>
<!-- 租户快速筛选标签 -->
<div class="tenant-tags">
<span class="tag-label">租户筛选</span>
<el-tag
v-for="tenant in tenantList"
:key="tenant.code"
:type="query.tenant_id === tenant.code ? '' : 'info'"
:effect="query.tenant_id === tenant.code ? 'dark' : 'plain'"
class="tenant-tag"
@click="selectTenant(tenant.code)"
>
{{ tenant.name }}
</el-tag>
<el-tag
v-if="query.tenant_id"
type="danger"
effect="plain"
class="tenant-tag clear-tag"
@click="selectTenant('')"
>
清除筛选
</el-tag>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="租户" width="100">
<template #default="{ row }">
{{ getTenantName(row.tenant_id) }}
</template>
</el-table-column>
<el-table-column prop="task_name" label="任务名称" width="150" />
<el-table-column label="调度配置" width="200">
<template #default="{ row }">
<el-tag size="small" :type="row.schedule_type === 'cron' ? 'warning' : ''">
{{ row.schedule_type === 'cron' ? 'CRON' : '简单' }}
</el-tag>
<span style="margin-left: 8px; color: #606266; font-size: 12px">
{{ getScheduleDesc(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次执行" width="180">
<template #default="{ row }">
<div v-if="row.last_run_at" style="font-size: 12px">
<div>{{ formatTime(row.last_run_at) }}</div>
<el-tag :type="getStatusType(row.last_run_status)" size="small">
{{ row.last_run_status || '-' }}
</el-tag>
</div>
<span v-else style="color: #909399">未执行</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link size="small" @click="handleRunNow(row)">
<el-icon><VideoPlay /></el-icon>
执行
</el-button>
<el-button
:type="row.is_enabled ? 'warning' : 'success'"
link
size="small"
@click="handleToggle(row)"
>
{{ row.is_enabled ? '禁用' : '启用' }}
</el-button>
<el-button type="info" link size="small" @click="handleViewLogs(row)">
<el-icon><Document /></el-icon>
日志
</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
:lock-scroll="true"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户" prop="tenant_id">
<el-select
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="请选择租户"
filterable
style="width: 100%"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
</el-form-item>
<el-form-item label="任务名称" prop="task_name">
<el-input v-model="form.task_name" placeholder="如:每日数据同步" />
</el-form-item>
<el-form-item label="任务描述">
<el-input v-model="form.task_desc" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
<el-form-item label="调度模式">
<el-radio-group v-model="form.schedule_type">
<el-radio value="simple">简单模式选择时间点</el-radio>
<el-radio value="cron">CRON 表达式</el-radio>
</el-radio-group>
</el-form-item>
<!-- 简单模式 -->
<el-form-item v-if="form.schedule_type === 'simple'" label="执行时间">
<div class="time-points-editor">
<div class="time-points-list">
<el-tag
v-for="(time, index) in form.time_points"
:key="time"
closable
@close="removeTimePoint(index)"
style="margin-right: 8px; margin-bottom: 8px"
>
{{ time }}
</el-tag>
</div>
<div class="time-points-add">
<el-time-select
v-model="newTimePoint"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择时间"
style="width: 140px"
/>
<el-button type="primary" @click="addTimePoint" :disabled="!newTimePoint">
添加
</el-button>
</div>
</div>
</el-form-item>
<!-- CRON 模式 -->
<el-form-item v-if="form.schedule_type === 'cron'" label="CRON 表达式">
<el-input v-model="form.cron_expression" placeholder="如0 9,18 * * *每天9点和18点" />
<div class="form-tip">
格式 例如0 9 * * * 表示每天9点执行
</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-form-item>
<el-form-item label="输入参数">
<el-input
v-model="form.input_params"
type="textarea"
:rows="4"
placeholder='可选JSON 格式,如:{"key": "value"}'
/>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.is_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 日志对话框 -->
<el-dialog
v-model="logsDialogVisible"
title="执行日志"
width="800px"
:lock-scroll="true"
>
<el-table v-loading="logsLoading" :data="logsData" style="width: 100%">
<el-table-column label="开始时间" width="170">
<template #default="{ row }">
{{ formatTime(row.started_at) }}
</template>
</el-table-column>
<el-table-column label="结束时间" width="170">
<template #default="{ row }">
{{ formatTime(row.finished_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="响应码" width="80">
<template #default="{ row }">
{{ row.response_code || '-' }}
</template>
</el-table-column>
<el-table-column label="错误信息">
<template #default="{ row }">
<span v-if="row.error_message" style="color: #f56c6c">{{ row.error_message }}</span>
<span v-else style="color: #67c23a">成功</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="logsQuery.page"
:page-size="logsQuery.size"
:total="logsTotal"
layout="total, prev, pager, next"
@current-change="handleLogsPageChange"
/>
</div>
<template #footer>
<el-button @click="logsDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
.tenant-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
}
.tag-label {
color: #606266;
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.tenant-tag {
cursor: pointer;
transition: all 0.2s;
}
.tenant-tag:hover {
transform: translateY(-1px);
}
.clear-tag {
margin-left: 8px;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.time-points-editor {
width: 100%;
}
.time-points-list {
min-height: 32px;
margin-bottom: 8px;
}
.time-points-add {
display: flex;
gap: 8px;
}
.form-tip {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
</style>