// 定时任务 modal:只读两栏 master-detail(左列表 / 右详情),仅「停用/启用」「删除」 // 两个便捷动作;新建 / 修改全走对话(schedule_* 工具,DESIGN §8.5)。 // 左侧 rail 底部「定时」按钮触发。 // 后端:GET /v1/schedules(列表)、PATCH /v1/schedules/{id}{enabled}(停用/启用)、 // DELETE /v1/schedules/{id}(删除)。建/改无 REST —— 故意只读。 import { $ } from "./dom.js"; import { api } from "./api.js"; import { escapeHtml } from "./format.js"; import { selectTask } from "./chat.js"; const PLACEHOLDER = '
← 选一个定时任务查看详情
'; let _jobs = []; function openCronsModal() { $("crons-modal").classList.add("show"); $("cr-detail").innerHTML = PLACEHOLDER; renderList(); } export function closeCronsModal() { $("crons-modal").classList.remove("show"); } // UTC ISO → 浏览器本地短时刻(用户浏览器基本就在 CST,直观)。 function ts(iso) { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; return d.toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, }); } function statusBadge(j) { if (!j.enabled) return '已停用'; if (j.last_status === "error") return '上次失败'; if (j.last_status === "ok") return '正常'; return '待运行'; } function itemHtml(j) { return `
${escapeHtml(j.name)} ${statusBadge(j)}
${escapeHtml(j.schedule_desc || j.cron)}
下次 ${ts(j.next_run_at)} · 上次 ${ts(j.last_run_at)}
`; } async function renderList() { const list = $("cr-list"); list.innerHTML = '
加载中…
'; let data; try { data = await api("GET", "/v1/schedules"); } catch (e) { list.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; return; } _jobs = data.results || []; if (!_jobs.length) { list.innerHTML = '
还没有定时任务。' + '对助手说「每天早上八点用 brief skill 出份简报发我邮箱」即可创建。
'; return; } const active = _jobs.filter((j) => j.enabled); const paused = _jobs.filter((j) => !j.enabled); let html = `
活跃 (${active.length})
`; html += active.map(itemHtml).join("") || '
(无)
'; if (paused.length) { html += `
已停用 (${paused.length})
`; html += paused.map(itemHtml).join(""); } list.innerHTML = html; } function row(k, v) { return `
${k}${v}
`; } // 当前选中 job —— tab 切换 / 历史分页都靠它定位(详情切走即失效)。 let _selJobId = null; // 详情 tab 的字段行(纯展示,不含动作按钮 —— 按钮已提到顶部 head)。 function detailRowsHtml(j) { const notify = j.notify && j.notify.to ? `必达邮件 → ${escapeHtml(j.notify.to)}` : "无(结果进任务线程 / 由指令自行投递)"; const modeDesc = j.mode === "persistent" ? "持续(同一任务线程,有连续性)" : "独立(每次新建任务,省 token)"; let rows = row("排程", `${escapeHtml(j.schedule_desc || "")} (${escapeHtml(j.cron)} @${escapeHtml(j.tz)})`) + row("模式", escapeHtml(modeDesc)) + row("指令", escapeHtml(j.prompt)) + (j.skill ? row("技能", escapeHtml(j.skill)) : "") + row("通知", notify) + row("下次触发", ts(j.next_run_at)) + row("上次执行", `${ts(j.last_run_at)} · ${escapeHtml(j.last_status || "—")}`); if (j.last_error) rows += row("上次错误", `${escapeHtml(j.last_error)}`); rows += row("累计运行", `${j.run_count} 次` + (j.consecutive_failures ? ` · 连续失败 ${j.consecutive_failures}` : "")); return rows; } // 切换右栏 tab:详情 = 字段行;执行记录 = 历史列表(异步拉)。 function renderTab(tab) { const j = _jobs.find((x) => x.job_id === _selJobId); if (!j) return; $("cr-detail").querySelectorAll(".cr-tab").forEach((el) => el.classList.toggle("active", el.getAttribute("data-tab") === tab)); const body = $("cr-tab-body"); if (tab === "history") { body.innerHTML = `
加载中…
`; loadHistory(j.job_id, 1); } else { body.innerHTML = detailRowsHtml(j); } } function selectJob(id, itemEl) { $("cr-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active")); if (itemEl) itemEl.classList.add("active"); const j = _jobs.find((x) => x.job_id === id); if (!j) return; _selJobId = id; $("cr-detail").innerHTML = `
${escapeHtml(j.name)} ${statusBadge(j)}
` + `
` + `
`; renderTab("detail"); } const HIST_PAGE_SIZE = 10; function runStatusLabel(rs) { if (rs === "running") return '运行中'; if (rs === "cancelling") return '取消中'; if (rs === "error") return '失败'; return ""; // idle:跑完的常态,不加噪 } // 拉某 job 的历史执行 task 一页,渲染进 #cr-hist(含上一页/下一页)。 // 切 job / 切 tab 时容器会被换掉,故 await 后**重新**查 #cr-hist 并校验 data-job, // 避免迟到的响应往已切走的视图里串显。 async function loadHistory(jobId, page) { if (!$("cr-hist")) return; let data; try { data = await api( "GET", `/v1/schedules/${encodeURIComponent(jobId)}/tasks?page=${page}&page_size=${HIST_PAGE_SIZE}`, ); } catch (e) { const b = $("cr-hist"); if (b && b.getAttribute("data-job") === jobId) b.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; return; } const box = $("cr-hist"); if (!box || box.getAttribute("data-job") !== jobId) return; // 已切走,丢弃 const results = data.results || []; if (!data.count) { box.innerHTML = '
还没有执行记录(尚未触发过)。
'; return; } const items = results.map((t) => { const st = runStatusLabel(t.run_status); return `
${ts(t.created_at)} ${escapeHtml(t.name || "(未命名)")} ${st}${t.n_messages ? ` ${t.n_messages} 条` : ""}
`; }).join(""); const totalPages = Math.max(1, Math.ceil(data.count / HIST_PAGE_SIZE)); const pager = totalPages > 1 ? `
第 ${page} / ${totalPages} 页 · 共 ${data.count}
` : `
共 ${data.count} 次
`; box.innerHTML = items + pager; } // ───── 顶层绑定 ───── $("hd-crons").onclick = openCronsModal; $("cr-close").onclick = closeCronsModal; $("crons-modal").addEventListener("click", (e) => { if (e.target.id === "crons-modal") closeCronsModal(); // 点遮罩关闭 }); $("cr-list").addEventListener("click", (e) => { const item = e.target.closest(".sk-item"); if (item) selectJob(item.getAttribute("data-id"), item); }); $("cr-detail").addEventListener("click", async (e) => { const toggle = e.target.closest("[data-toggle]"); const del = e.target.closest("[data-del]"); const openTask = e.target.closest("[data-open-task]"); const histPage = e.target.closest("[data-hist-page]"); const tab = e.target.closest("[data-tab]"); if (tab) { renderTab(tab.getAttribute("data-tab")); return; } if (histPage) { const box = $("cr-hist"); if (box) loadHistory(box.getAttribute("data-job"), Number(histPage.getAttribute("data-hist-page"))); return; } if (openTask) { closeCronsModal(); selectTask(openTask.getAttribute("data-open-task")); return; } if (toggle) { const id = toggle.getAttribute("data-toggle"); const j = _jobs.find((x) => x.job_id === id); toggle.disabled = true; try { await api("PATCH", "/v1/schedules/" + encodeURIComponent(id), { enabled: !j.enabled }); await renderList(); selectJob(id); } catch (err) { alert("操作失败: " + err.message); toggle.disabled = false; } return; } if (del) { const id = del.getAttribute("data-del"); const j = _jobs.find((x) => x.job_id === id); if (!confirm(`删除定时任务「${j ? j.name : id}」?不可撤销(停用可保留改用「停用」)。`)) return; del.disabled = true; try { await api("DELETE", "/v1/schedules/" + encodeURIComponent(id)); $("cr-detail").innerHTML = PLACEHOLDER; await renderList(); } catch (err) { alert("删除失败: " + err.message); del.disabled = false; } } });