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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
432
frontend/src/views/AgentSchedules.vue
Normal file
432
frontend/src/views/AgentSchedules.vue
Normal 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>
|
||||
Reference in New Issue
Block a user