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 = `
+
+
+
+
+ `
+}
+
+// ── 任务列表渲染 ──
+
+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 = `
+
+ `
+
+ 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, '>')
+}