feat: add 8 builtin tools, AgentSchedules management page, Celery Beat integration

- Add 3 schedule tools (create/list/delete) and 5 utility tools (crypto, random, email, URL, regex)
- Add frontend AgentSchedules.vue page with full CRUD, cron presets, manual trigger
- Integrate Celery Beat for automatic schedule execution
- Update startup scripts with Celery Beat launch
- Fix schedule list API to show all schedules for admin users
- Add celrybeat-schedule.* to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-02 19:14:25 +08:00
parent b608267fb5
commit 68fbadae76
11 changed files with 1299 additions and 8 deletions

View File

@@ -35,6 +35,10 @@
<el-icon><ChatLineSquare /></el-icon>
<span>Agent对话</span>
</el-menu-item>
<el-menu-item index="agent-schedules" @click="router.push('/agent-schedules')">
<el-icon><Clock /></el-icon>
<span>定时任务</span>
</el-menu-item>
<el-menu-item index="executions">
<el-icon><List /></el-icon>
<span>执行历史</span>
@@ -94,7 +98,7 @@
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools } from '@element-plus/icons-vue'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -117,6 +121,7 @@ const activeMenu = computed(() => {
if (route.path === '/agent-monitoring') return 'agent-monitoring'
if (route.path === '/alert-rules') return 'alert-rules'
if (route.path === '/agent-chat' || route.path.startsWith('/agent-chat/')) return 'agent-chat'
if (route.path === '/agent-schedules') return 'agent-schedules'
return 'workflows'
})
@@ -148,6 +153,8 @@ const handleMenuSelect = (key: string) => {
router.push('/agent-monitoring')
} else if (key === 'alert-rules') {
router.push('/alert-rules')
} else if (key === 'agent-schedules') {
router.push('/agent-schedules')
}
}

View File

@@ -129,6 +129,12 @@ const router = createRouter({
name: 'agent-chat-with-agent',
component: () => import('@/views/AgentChat.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-schedules',
name: 'agent-schedules',
component: () => import('@/views/AgentSchedules.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -0,0 +1,432 @@
<template>
<MainLayout>
<div class="schedules-page">
<el-card>
<template #header>
<div class="card-header">
<h2>Agent 定时任务管理</h2>
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
创建定时任务
</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #title>
定时任务按 cron 表达式周期执行 Agent执行结果可通过飞书 Webhook 推送通知
Celery Beat 每分钟检查一次到期任务
</template>
</el-alert>
<el-table v-loading="loading" :data="schedules" stripe>
<el-table-column prop="name" label="任务名称" min-width="140" />
<el-table-column label="关联 Agent" min-width="120">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ agentName(row.agent_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cron_expression" label="Cron 表达式" width="130" />
<el-table-column label="下次执行" width="170">
<template #default="{ row }">
{{ formatTime(row.next_run_at) }}
</template>
</el-table-column>
<el-table-column label="上次执行" width="170">
<template #default="{ row }">
<span v-if="row.last_run_at">
{{ formatTime(row.last_run_at) }}
<el-tag
:type="row.last_run_status === 'success' ? 'success' : 'danger'"
size="small"
style="margin-left: 4px"
>
{{ row.last_run_status }}
</el-tag>
</span>
<span v-else style="color: #999">未执行</span>
</template>
</el-table-column>
<el-table-column label="启用" width="70">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
@change="(val: boolean) => toggleEnabled(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" @click="triggerNow(row)">
手动触发
</el-button>
<el-button size="small" @click="openEditDialog(row)">
编辑
</el-button>
<el-popconfirm
title="确定删除该定时任务?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && schedules.length === 0" description="暂无定时任务" />
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="editingSchedule ? '编辑定时任务' : '创建定时任务'"
width="580px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="110px"
@submit.prevent="handleSubmit"
>
<el-form-item label="任务名称" prop="name">
<el-input v-model="form.name" placeholder="如:每日早报推送" />
</el-form-item>
<el-form-item label="关联 Agent" prop="agent_id">
<el-select
v-model="form.agent_id"
placeholder="选择要执行的 Agent"
filterable
style="width: 100%"
:disabled="!!editingSchedule"
>
<el-option
v-for="agent in agents"
:key="agent.id"
:label="agent.name"
:value="agent.id"
/>
</el-select>
</el-form-item>
<el-form-item label="Cron 表达式" prop="cron_expression">
<el-input
v-model="form.cron_expression"
placeholder="如0 9 * * * (每天9点)"
/>
<div class="cron-hints">
<span class="hint-label">快捷选择</span>
<el-tag
v-for="preset in cronPresets"
:key="preset.value"
size="small"
class="cron-tag"
:type="form.cron_expression === preset.value ? 'primary' : ''"
@click="form.cron_expression = preset.value"
>
{{ preset.label }}
</el-tag>
</div>
<div
v-if="form.cron_expression.trim()"
class="cron-field-count"
:class="{
'cron-ok': cronFieldCount >= 5 && cronFieldCount <= 7,
'cron-err': cronFieldCount < 5 || cronFieldCount > 7
}"
>
当前 {{ cronFieldCount }} 个字段需要 5~7 空格分隔
</div>
</el-form-item>
<el-form-item label="执行消息" prop="input_message">
<el-input
v-model="form.input_message"
type="textarea"
:rows="3"
placeholder="每次定时执行时发送给 Agent 的消息内容"
/>
</el-form-item>
<el-form-item label="飞书 Webhook">
<el-input
v-model="form.webhook_url"
placeholder="可选,执行完成后推送到飞书机器人"
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
填入飞书机器人 Webhook URL任务执行完成后自动推送通知
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ editingSchedule ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
// ── 类型 ──────────────────────────────────
interface AgentSchedule {
id: string
agent_id: string
name: string
cron_expression: string
input_message: string
timezone: string
enabled: boolean
webhook_url?: string | null
last_run_at?: string | null
last_run_status?: string | null
next_run_at: string
created_at: string
updated_at: string
}
interface AgentItem {
id: string
name: string
}
// ── 数据 ──────────────────────────────────
const loading = ref(false)
const submitting = ref(false)
const schedules = ref<AgentSchedule[]>([])
const agents = ref<AgentItem[]>([])
const dialogVisible = ref(false)
const editingSchedule = ref<AgentSchedule | null>(null)
const formRef = ref()
const form = reactive({
agent_id: '',
name: '',
cron_expression: '',
input_message: '',
webhook_url: '',
})
const cronPresets = [
{ label: '每分钟', value: '* * * * *' },
{ label: '每5分钟', value: '*/5 * * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '每天9点', value: '0 9 * * *' },
{ label: '每天18点', value: '0 18 * * *' },
{ label: '每周一9点', value: '0 9 * * 1' },
{ label: '每月1号9点', value: '0 9 1 * *' },
]
const cronFieldCount = computed(() => {
const v = form.cron_expression?.trim() || ''
if (!v) return 0
return v.split(/\s+/).length
})
function validateCron(_rule: any, value: string, cb: any) {
if (!value || !value.trim()) {
cb(new Error('请输入 cron 表达式'))
return
}
const parts = value.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 7) {
cb(new Error(`cron 表达式需要 5~7 个字段,当前有 ${parts.length} 个(用空格分隔)`))
return
}
cb()
}
const rules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
agent_id: [{ required: true, message: '请选择 Agent', trigger: 'change' }],
cron_expression: [{ required: true, validator: validateCron, trigger: 'blur' }],
input_message: [{ required: true, message: '请输入执行消息', trigger: 'blur' }],
}
// ── 方法 ──────────────────────────────────
function agentName(id: string) {
const a = agents.value.find((x) => x.id === id)
return a ? a.name : id
}
function formatTime(ts: string | null) {
if (!ts) return '-'
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadAgents() {
try {
const { data } = await api.get('/api/v1/agents', { params: { limit: 100 } })
agents.value = data
} catch {
// 静默失败
}
}
async function loadSchedules() {
loading.value = true
try {
const { data } = await api.get('/api/v1/agent-schedules')
schedules.value = data
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function openCreateDialog() {
editingSchedule.value = null
form.agent_id = ''
form.name = ''
form.cron_expression = ''
form.input_message = ''
form.webhook_url = ''
dialogVisible.value = true
}
function openEditDialog(row: AgentSchedule) {
editingSchedule.value = row
form.agent_id = row.agent_id
form.name = row.name
form.cron_expression = row.cron_expression
form.input_message = row.input_message
form.webhook_url = row.webhook_url || ''
dialogVisible.value = true
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const payload = {
agent_id: form.agent_id,
name: form.name,
cron_expression: form.cron_expression,
input_message: form.input_message,
}
if (editingSchedule.value) {
await api.put(`/api/v1/agent-schedules/${editingSchedule.value.id}`, payload)
ElMessage.success('定时任务已更新')
} else {
await api.post('/api/v1/agent-schedules', payload)
ElMessage.success('定时任务已创建')
}
dialogVisible.value = false
await loadSchedules()
} catch {
// 错误由拦截器处理
} finally {
submitting.value = false
}
}
async function toggleEnabled(row: AgentSchedule, val: boolean) {
try {
await api.put(`/api/v1/agent-schedules/${row.id}`, { enabled: val })
ElMessage.success(val ? '已启用' : '已停用')
} catch {
row.enabled = !val
}
}
async function triggerNow(row: AgentSchedule) {
try {
await api.post(`/api/v1/agent-schedules/${row.id}/trigger`)
ElMessage.success('已触发执行,请到执行历史查看')
await loadSchedules()
} catch {
// 错误由拦截器处理
}
}
async function handleDelete(id: string) {
try {
await api.delete(`/api/v1/agent-schedules/${id}`)
ElMessage.success('定时任务已删除')
await loadSchedules()
} catch {
// 错误由拦截器处理
}
}
onMounted(async () => {
await Promise.all([loadAgents(), loadSchedules()])
})
</script>
<style scoped>
.schedules-page {
height: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
margin: 0;
}
.cron-hints {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.hint-label {
font-size: 12px;
color: #999;
}
.cron-tag {
cursor: pointer;
user-select: none;
}
.cron-field-count {
margin-top: 4px;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
display: inline-block;
}
.cron-field-count.cron-ok {
color: #67c23a;
background: #f0f9eb;
}
.cron-field-count.cron-err {
color: #f56c6c;
background: #fef0f0;
}
</style>