253 lines
10 KiB
JavaScript
253 lines
10 KiB
JavaScript
// 定时任务 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 = '<div class="sk-empty">← 选一个定时任务查看详情</div>';
|
|
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 '<span class="cr-st paused">已停用</span>';
|
|
if (j.last_status === "error") return '<span class="cr-st error">上次失败</span>';
|
|
if (j.last_status === "ok") return '<span class="cr-st ok">正常</span>';
|
|
return '<span class="cr-st paused">待运行</span>';
|
|
}
|
|
|
|
function itemHtml(j) {
|
|
return `<div class="sk-item" data-id="${escapeHtml(j.job_id)}">
|
|
<div class="sk-name">${escapeHtml(j.name)} ${statusBadge(j)}</div>
|
|
<div class="cr-sched">${escapeHtml(j.schedule_desc || j.cron)}</div>
|
|
<div class="cr-meta">下次 ${ts(j.next_run_at)} · 上次 ${ts(j.last_run_at)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
async function renderList() {
|
|
const list = $("cr-list");
|
|
list.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
|
|
let data;
|
|
try {
|
|
data = await api("GET", "/v1/schedules");
|
|
} catch (e) {
|
|
list.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
|
return;
|
|
}
|
|
_jobs = data.results || [];
|
|
if (!_jobs.length) {
|
|
list.innerHTML =
|
|
'<div class="muted" style="padding:8px;font-size:12px;">还没有定时任务。' +
|
|
'对助手说「每天早上八点用 brief skill 出份简报发我邮箱」即可创建。</div>';
|
|
return;
|
|
}
|
|
const active = _jobs.filter((j) => j.enabled);
|
|
const paused = _jobs.filter((j) => !j.enabled);
|
|
let html = `<div class="sk-group-title">活跃 (${active.length})</div>`;
|
|
html += active.map(itemHtml).join("") ||
|
|
'<div class="muted" style="padding:4px 8px;font-size:12px;">(无)</div>';
|
|
if (paused.length) {
|
|
html += `<div class="sk-group-title" style="margin-top:12px;">已停用 (${paused.length})</div>`;
|
|
html += paused.map(itemHtml).join("");
|
|
}
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function row(k, v) {
|
|
return `<div class="cr-d-row"><span class="k">${k}</span><span class="v">${v}</span></div>`;
|
|
}
|
|
|
|
// 当前选中 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 || "")} <span class="muted">(${escapeHtml(j.cron)} @${escapeHtml(j.tz)})</span>`) +
|
|
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("上次错误", `<span style="color:#c0392b">${escapeHtml(j.last_error)}</span>`);
|
|
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 =
|
|
`<div id="cr-hist" data-job="${escapeHtml(j.job_id)}"><div class="muted" style="padding:6px;font-size:12px;">加载中…</div></div>`;
|
|
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 =
|
|
`<div class="sk-d-head">
|
|
<span class="sk-d-name">${escapeHtml(j.name)}</span> ${statusBadge(j)}<span class="spacer"></span>
|
|
<button class="small" data-toggle="${escapeHtml(j.job_id)}">${j.enabled ? "停用" : "启用"}</button>
|
|
<button class="small danger" data-del="${escapeHtml(j.job_id)}">删除</button>
|
|
</div>` +
|
|
`<div class="cr-tabs">
|
|
<button class="cr-tab active" data-tab="detail">详情</button>
|
|
<button class="cr-tab" data-tab="history">执行记录</button>
|
|
</div>` +
|
|
`<div id="cr-tab-body"></div>`;
|
|
renderTab("detail");
|
|
}
|
|
|
|
const HIST_PAGE_SIZE = 10;
|
|
|
|
function runStatusLabel(rs) {
|
|
if (rs === "running") return '<span class="cr-st ok">运行中</span>';
|
|
if (rs === "cancelling") return '<span class="cr-st paused">取消中</span>';
|
|
if (rs === "error") return '<span class="cr-st error">失败</span>';
|
|
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 = `<div class="err" style="padding:6px;font-size:12px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
|
return;
|
|
}
|
|
const box = $("cr-hist");
|
|
if (!box || box.getAttribute("data-job") !== jobId) return; // 已切走,丢弃
|
|
const results = data.results || [];
|
|
if (!data.count) {
|
|
box.innerHTML = '<div class="muted" style="padding:6px;font-size:12px;">还没有执行记录(尚未触发过)。</div>';
|
|
return;
|
|
}
|
|
const items = results.map((t) => {
|
|
const st = runStatusLabel(t.run_status);
|
|
return `<div class="cr-hist-item" data-open-task="${escapeHtml(t.task_id)}">
|
|
<span class="cr-hist-ts">${ts(t.created_at)}</span>
|
|
<span class="cr-hist-name">${escapeHtml(t.name || "(未命名)")}</span>
|
|
<span class="cr-hist-meta">${st}${t.n_messages ? ` ${t.n_messages} 条` : ""}</span>
|
|
</div>`;
|
|
}).join("");
|
|
const totalPages = Math.max(1, Math.ceil(data.count / HIST_PAGE_SIZE));
|
|
const pager = totalPages > 1
|
|
? `<div class="cr-hist-pager">
|
|
<button class="small" data-hist-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
|
|
<span class="muted">第 ${page} / ${totalPages} 页 · 共 ${data.count}</span>
|
|
<button class="small" data-hist-page="${page + 1}" ${page >= totalPages ? "disabled" : ""}>下一页</button>
|
|
</div>`
|
|
: `<div class="cr-hist-pager"><span class="muted">共 ${data.count} 次</span></div>`;
|
|
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;
|
|
}
|
|
}
|
|
});
|