/** * 定时任务管理 * 通过 Gateway WebSocket RPC 管理(cron.list / cron.add / cron.update / cron.remove / cron.run) * 注意:openclaw.json 不支持 cron.jobs 字段,定时任务只能通过 Gateway 在线管理 */ import { toast } from '../components/toast.js' import { showContentModal, showConfirm } from '../components/modal.js' import { icon } from '../lib/icons.js' import { onGatewayChange } from '../lib/app-state.js' import { wsClient } from '../lib/ws-client.js' import { api, invalidate } from '../lib/tauri-api.js' let _unsub = null // ── Cron 表达式快捷预设 ── const CRON_SHORTCUTS = [ { expr: '*/5 * * * *', text: '每 5 分钟' }, { expr: '*/15 * * * *', text: '每 15 分钟' }, { expr: '0 * * * *', text: '每小时整点' }, { expr: '0 9 * * *', text: '每天 9:00' }, { expr: '0 18 * * *', text: '每天 18:00' }, { expr: '0 9 * * 1', text: '每周一 9:00' }, { expr: '0 9 1 * *', text: '每月 1 号 9:00' }, ] // ── 页面生命周期 ── export async function render() { const page = document.createElement('div') page.className = 'page' page.innerHTML = `
` const state = { jobs: [], loading: false } page.querySelector('#btn-new-task').onclick = () => openTaskDialog(null, page, state) page.querySelector('#btn-refresh-tasks').onclick = () => fetchJobs(page, state) // 自动修复:移除可能被写入的无效 cron.jobs 字段 fixInvalidCronConfig() // 监听 Gateway 状态变化 if (_unsub) _unsub() _unsub = onGatewayChange(() => { updateGatewayHint(page) fetchJobs(page, state) }) updateGatewayHint(page) await fetchJobs(page, state) return page } export function cleanup() { if (_unsub) { _unsub(); _unsub = null } } /** 自动移除无效的 cron.jobs 字段(之前版本错误写入,会导致 Gateway 崩溃) */ async function fixInvalidCronConfig() { try { invalidate('read_openclaw_config') const config = await api.readOpenclawConfig() if (config?.cron?.jobs) { delete config.cron.jobs if (Object.keys(config.cron).length === 0) delete config.cron await api.writeOpenclawConfig(config) toast('已自动修复配置(移除无效的 cron.jobs)', 'info') } } catch {} } function isGatewayUp() { return wsClient && wsClient.gatewayReady } function updateGatewayHint(page) { const el = page.querySelector('#cron-gw-hint') if (!el) return el.style.display = isGatewayUp() ? 'none' : '' } // ── 数据加载(Gateway RPC) ── async function fetchJobs(page, state) { if (!isGatewayUp()) { state.jobs = [] state.loading = false renderStats(page, state) renderList(page, state) return } state.loading = true renderList(page, state) try { const res = await wsClient.request('cron.list', { includeDisabled: true }) let jobs = res?.jobs || res if (!Array.isArray(jobs)) jobs = [] state.jobs = jobs.map(j => ({ id: j.id, name: j.name || j.id || '未命名', description: j.description || '', message: j.payload?.message || j.payload?.text || '', payloadKind: j.payload?.kind || 'agentTurn', schedule: j.schedule || {}, enabled: j.enabled !== false, agentId: j.agentId || null, lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, lastRunAtMs: j.state?.lastRunAtMs || null, lastError: j.state?.lastError || null, })) } catch (e) { toast('获取任务列表失败: ' + e, 'error') state.jobs = [] } state.loading = false renderStats(page, state) renderList(page, state) } // ── 统计卡片 ── function renderStats(page, state) { const el = page.querySelector('#cron-stats') const total = state.jobs.length const active = state.jobs.filter(j => j.enabled).length const paused = total - active const failed = state.jobs.filter(j => j.lastRunStatus === 'error').length el.innerHTML = `
总任务
${total}
运行中
${active}
已暂停
${paused}
近期失败
${failed}
` } // ── 任务列表渲染 ── function renderList(page, state) { const el = page.querySelector('#cron-list') if (state.loading) { el.innerHTML = `
` return } if (!state.jobs.length) { el.innerHTML = `
${icon('clock', 48)}
暂无定时任务
点击「+ 创建任务」添加你的第一个计划任务
` return } el.innerHTML = state.jobs.map(job => { const scheduleText = describeCronFull(job.schedule) const lastRunOk = job.lastRunStatus === 'ok' || job.lastRunStatus === 'skipped' const lastRunHtml = job.lastRunAtMs ? ` ${lastRunOk ? icon('check', 12) : icon('x', 12)} ${relativeTime(job.lastRunAtMs)} ` : '' return `
${escapeHtml(job.name)} ${job.enabled ? '运行中' : '已暂停'} ${lastRunHtml}
${icon('clock', 12)} ${scheduleText}${job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : ''}
${escapeHtml(job.message)}
${job.lastRunStatus === 'error' && job.lastError ? `
${escapeHtml(job.lastError)}
` : ''}
` }).join('') // 绑定事件 el.querySelectorAll('.cron-job-card').forEach(card => { const jid = card.dataset.jid const job = state.jobs.find(j => j.id === jid) if (!job) return card.querySelector('[data-action="trigger"]').onclick = async (e) => { const btn = e.currentTarget btn.disabled = true try { await wsClient.request('cron.run', { jobId: jid }) toast('任务已触发执行', 'success') setTimeout(() => fetchJobs(page, state), 2000) } catch (err) { toast('触发失败: ' + err, 'error') } finally { btn.disabled = false } } card.querySelector('[data-action="toggle"]').onclick = async (e) => { const btn = e.currentTarget btn.disabled = true btn.innerHTML = icon('refresh-cw', 14) try { await wsClient.request('cron.update', { jobId: jid, patch: { enabled: !job.enabled } }) toast(job.enabled ? '已暂停' : '已启用', 'info') await fetchJobs(page, state) } catch (err) { toast('操作失败: ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) } } card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state) card.querySelector('[data-action="delete"]').onclick = async function() { const btn = this const yes = await showConfirm(`确定删除任务「${job.name}」?`) if (!yes) return if (btn) btn.disabled = true try { await wsClient.request('cron.remove', { jobId: jid }) toast('已删除', 'info') await fetchJobs(page, state) } catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false } } }) } // ── 创建/编辑任务弹窗 ── async function openTaskDialog(job, page, state) { if (!isGatewayUp()) { toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning') return } const isEdit = !!job const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *' const formId = 'cron-form-' + Date.now() const shortcutsHtml = CRON_SHORTCUTS.map(s => { const selected = s.expr === initSchedule ? 'selected' : '' return `` }).join('') // 先用默认选项,弹窗后异步加载 Agent 列表 const agentOptionsHtml = `${job?.agentId ? `` : ''}` const content = `
不选则使用默认 Agent 执行
配置了多个消息渠道时必须指定,否则任务会报错
${shortcutsHtml}
${describeCron(initSchedule)}
` const modal = showContentModal({ title: isEdit ? '编辑任务' : '创建定时任务', content, buttons: [ { label: isEdit ? '保存修改' : '创建', className: 'btn btn-primary', id: 'btn-cron-save' }, ], width: 500, }) // 异步加载渠道列表 api.readOpenclawConfig().then(cfg => { const channels = cfg?.channels || {} const channelIds = Object.keys(channels).filter(k => k !== 'defaults') if (channelIds.length <= 1) return // 单渠道或无渠道不需要选 const select = modal.querySelector('select[name="deliveryChannel"]') if (!select) return const current = job?.delivery?.channel || '' select.innerHTML = `` + channelIds.map(ch => `` ).join('') }).catch(() => {}) // 异步加载 Agent 列表并更新下拉框(不阻塞弹窗显示) api.listAgents().then(res => { const agents = Array.isArray(res) ? res : (res?.agents || []) if (!agents.length) return const select = modal.querySelector('select[name="agentId"]') if (!select) return const currentVal = select.value select.innerHTML = `` + agents.map(a => `` ).join('') }).catch(() => {}) // 快捷预设按钮 modal.querySelectorAll('.cron-shortcut').forEach(btn => { btn.onclick = () => { modal.querySelectorAll('.cron-shortcut').forEach(b => { b.classList.remove('btn-primary') b.classList.add('btn-secondary') }) btn.classList.remove('btn-secondary') btn.classList.add('btn-primary') const input = modal.querySelector('input[name="schedule"]') input.value = btn.dataset.expr modal.querySelector('#cron-preview').textContent = describeCron(btn.dataset.expr) } }) // 自定义表达式实时预览 const schedInput = modal.querySelector('input[name="schedule"]') schedInput.oninput = () => { modal.querySelector('#cron-preview').textContent = describeCron(schedInput.value.trim()) // 取消预设按钮高亮 modal.querySelectorAll('.cron-shortcut').forEach(b => { b.classList.remove('btn-primary') b.classList.add('btn-secondary') if (b.dataset.expr === schedInput.value.trim()) { b.classList.remove('btn-secondary') b.classList.add('btn-primary') } }) } // 保存 modal.querySelector('#btn-cron-save').onclick = async () => { const name = modal.querySelector('input[name="name"]').value.trim() const message = modal.querySelector('textarea[name="message"]').value.trim() const schedule = modal.querySelector('input[name="schedule"]').value.trim() const agentId = modal.querySelector('select[name="agentId"]').value || undefined const enabled = modal.querySelector('input[name="enabled"]').checked if (!name) { toast('请输入任务名称', 'warning'); return } if (!message) { toast('请输入执行指令', 'warning'); return } if (!schedule) { toast('请设置执行周期', 'warning'); return } const saveBtn = modal.querySelector('#btn-cron-save') saveBtn.disabled = true saveBtn.textContent = '保存中...' try { if (isEdit) { const patch = { name, enabled } patch.schedule = { kind: 'cron', expr: schedule } patch.payload = { kind: 'agentTurn', message } if (agentId) patch.agentId = agentId const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value if (deliveryChannel) { patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } } await wsClient.request('cron.update', { jobId: job.id, patch }) toast('任务已更新', 'success') } else { const params = { name, enabled, schedule: { kind: 'cron', expr: schedule }, payload: { kind: 'agentTurn', message }, } if (agentId) params.agentId = agentId const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value if (deliveryChannel) { params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } } await wsClient.request('cron.add', params) toast('任务已创建', 'success') } modal.close?.() || modal.remove?.() await fetchJobs(page, state) } catch (e) { toast('保存失败: ' + e, 'error') saveBtn.disabled = false saveBtn.textContent = isEdit ? '保存修改' : '创建' } } } // ── 工具函数 ── /** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */ function extractCronExpr(schedule) { if (!schedule) return null if (typeof schedule === 'string') return schedule if (typeof schedule === 'object' && schedule.expr) return schedule.expr if (typeof schedule === 'object' && schedule.kind === 'cron' && schedule.expr) return schedule.expr return null } /** 将 cron 表达式转为可读文字 */ function describeCron(raw) { const expr = typeof raw === 'string' ? raw : extractCronExpr(raw) if (!expr) return '未知周期' const hit = CRON_SHORTCUTS.find(s => s.expr === expr) if (hit) return hit.text const parts = expr.split(' ') if (parts.length !== 5) return expr const [min, hr, dom, , dow] = parts if (min === '*' && hr === '*') return '每分钟' if (min.startsWith('*/')) return `每 ${min.slice(2)} 分钟` if (hr === '*' && min === '0') return '每小时整点' if (dow !== '*' && dom === '*') return `每周 ${dow} 的 ${hr}:${min.padStart(2, '0')}` if (dom !== '*') return `每月 ${dom} 号 ${hr}:${min.padStart(2, '0')}` if (hr !== '*') return `每天 ${hr}:${min.padStart(2, '0')}` return expr } /** 将 Gateway 返回的 CronSchedule 对象也处理成可读文字 */ function describeCronFull(schedule) { if (!schedule) return '未知' if (typeof schedule === 'string') return describeCron(schedule) if (typeof schedule === 'object') { if (schedule.kind === 'every' && schedule.everyMs) { const ms = schedule.everyMs if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒` if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟` return `每 ${Math.round(ms / 3600000)} 小时` } if (schedule.kind === 'at' && schedule.at) { try { return '一次性: ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at } } if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr) } return String(schedule) } /** 相对时间描述 */ function relativeTime(ts) { if (!ts) return '' const t = typeof ts === 'number' ? ts : new Date(ts).getTime() const diff = Date.now() - t if (diff < 60000) return '刚刚' if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前' if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前' return Math.floor(diff / 86400000) + ' 天前' } function escapeHtml(str) { return (str || '').replace(/&/g, '&').replace(//g, '>') } function escapeAttr(str) { return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') }