diff --git a/src/pages/cron.js b/src/pages/cron.js new file mode 100644 index 0000000..779a95f --- /dev/null +++ b/src/pages/cron.js @@ -0,0 +1,491 @@ +/** + * 定时任务管理 + * 通过 Gateway WebSocket RPC 直接管理计划任务(cron.list / cron.add / cron.update / cron.remove / cron.run) + */ +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 } 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) + + // 监听 Gateway 状态变化 + if (_unsub) _unsub() + _unsub = onGatewayChange(() => { + updateGatewayWarning(page) + fetchJobs(page, state) + }) + + updateGatewayWarning(page) + await fetchJobs(page, state) + + return page +} + +export function cleanup() { + if (_unsub) { _unsub(); _unsub = null } +} + +// ── Gateway 连接检查 ── + +function isGatewayUp() { + return wsClient && wsClient._gatewayReady +} + +function updateGatewayWarning(page) { + const el = page.querySelector('#cron-gw-warn') + if (!el) return + if (isGatewayUp()) { + el.style.display = 'none' + } else { + el.style.display = '' + el.innerHTML = ` +
+
+ ${icon('alert-circle', 16)} + Gateway 未连接,定时任务功能需要 Gateway 在线才能使用 +
+
+ ` + } +} + +// ── 数据加载(直连 Gateway RPC) ── + +async function fetchJobs(page, state) { + if (!isGatewayUp()) { + state.jobs = [] + 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 = [] + + // 映射 Gateway CronJob 格式到 UI 格式 + state.jobs = jobs.map(j => ({ + id: j.id, + name: j.name || '未命名', + 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, + lastDurationMs: j.state?.lastDurationMs || null, + nextRunAtMs: j.state?.nextRunAtMs || null, + consecutiveErrors: j.state?.consecutiveErrors || 0, + updatedAtMs: j.updatedAtMs || 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', { id: jid, mode: 'force' }) + 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', { id: 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 (e) => { + const yes = await showConfirm(`确定删除任务「${job.name}」?`) + if (!yes) return + const btn = e.currentTarget + btn.disabled = true + try { + await wsClient.request('cron.remove', { id: jid }) + toast('已删除', 'info') + await fetchJobs(page, state) + } catch (err) { toast('删除失败: ' + err, 'error'); btn.disabled = false } + } + }) +} + +// ── 创建/编辑任务弹窗 ── + +async function openTaskDialog(job, page, state) { + if (!isGatewayUp()) { + toast('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 列表用于选择器 + let agents = [] + try { + const res = await api.listAgents() + agents = Array.isArray(res) ? res : (res?.agents || []) + } catch {} + + const agentOptionsHtml = agents.length + ? `` + agents.map(a => + `` + ).join('') + : `` + + 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, + }) + + // 快捷预设按钮 + 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 + await wsClient.request('cron.update', { id: job.id, patch }) + toast('任务已更新', 'success') + } else { + const params = { + name, + enabled, + schedule: { kind: 'cron', expr: schedule }, + payload: { kind: 'agentTurn', message }, + } + if (agentId) params.agentId = agentId + 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, '>') +}