Files
sproutclaw-cron/webui/frontend/app.js
shumengya c10cacd5c6 feat: 多语言任务、WebUI 增强与 Agent MCP 集成
重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:45:25 +08:00

576 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API = "/api";
const REFRESH_INTERVAL = 15000;
const els = {
runtimes: document.getElementById("runtimes"),
taskBody: document.getElementById("task-body"),
taskCards: document.getElementById("task-cards"),
stats: document.getElementById("stats"),
summaryText: document.getElementById("summary-text"),
lastRefresh: document.getElementById("last-refresh"),
filter: document.getElementById("filter"),
toast: document.getElementById("toast"),
logDialog: document.getElementById("log-dialog"),
logTitle: document.getElementById("log-title"),
logSubtitle: document.getElementById("log-subtitle"),
logContent: document.getElementById("log-content"),
editDialog: document.getElementById("edit-dialog"),
editForm: document.getElementById("edit-form"),
editTitle: document.getElementById("edit-title"),
editDescription: document.getElementById("edit-description"),
editMinute: document.getElementById("edit-minute"),
editHour: document.getElementById("edit-hour"),
editDom: document.getElementById("edit-dom"),
editMonth: document.getElementById("edit-month"),
editDow: document.getElementById("edit-dow"),
editPreview: document.getElementById("edit-preview"),
cronPresets: document.getElementById("cron-presets"),
editTagListEl: document.getElementById("edit-tags"),
editTagInput: document.getElementById("edit-tag-input"),
};
let allTasks = [];
let currentLogTaskId = null;
let currentEditTaskId = null;
let editTagList = [];
let refreshTimer = null;
const escapeHtml = (str) =>
String(str ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function nowText() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function showToast(message, isError = false) {
els.toast.hidden = false;
els.toast.textContent = message;
els.toast.classList.toggle("is-error", isError);
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
els.toast.hidden = true;
}, 2800);
}
async function api(path, options = {}) {
const res = await fetch(`${API}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
const msg = Array.isArray(detail)
? detail.map((d) => d.msg || d).join("; ")
: detail || data.message || `请求失败 (${res.status})`;
throw new Error(msg);
}
return data;
}
function statusMarkup(task) {
if (task.running) {
return '<span class="badge badge-run"><span class="dot"></span>运行中</span>';
}
if (task.enabled) {
return '<span class="badge badge-on"><span class="dot"></span>已开启</span>';
}
return '<span class="badge badge-off"><span class="dot"></span>已关闭</span>';
}
function scheduleMarkup(task) {
return task.schedule
? `<span class="schedule-code">${escapeHtml(task.schedule)}</span>`
: '<span class="mono">—</span>';
}
function logMarkup(task) {
const size = formatBytes(task.log_size);
const updated = task.log_updated
? `<span class="muted">${escapeHtml(task.log_updated)}</span>`
: '<span class="muted">无记录</span>';
return `<div class="log-meta">${size}</div>${updated}`;
}
function actionButtons(task) {
const toggleLabel = task.enabled ? "关闭" : "开启";
const toggleClass = task.enabled ? "btn-danger" : "btn-success";
return `
<div class="actions">
<button type="button" class="btn ${toggleClass}" data-action="toggle" data-id="${escapeHtml(task.task_id)}">${toggleLabel}</button>
<button type="button" class="btn" data-action="run" data-id="${escapeHtml(task.task_id)}" ${task.running ? "disabled" : ""}>运行</button>
<button type="button" class="btn" data-action="edit" data-id="${escapeHtml(task.task_id)}">修改</button>
<button type="button" class="btn" data-action="sync" data-id="${escapeHtml(task.task_id)}">同步</button>
<button type="button" class="btn" data-action="log" data-id="${escapeHtml(task.task_id)}">日志</button>
</div>
`;
}
const CRON_FIELD_KEYS = ["editMinute", "editHour", "editDom"];
function parseCron(expr) {
const parts = (expr || "0 8 * * *").trim().split(/\s+/);
return {
minute: parts[0] ?? "0",
hour: parts[1] ?? "8",
dom: parts[2] ?? "*",
month: parts[3] ?? "*",
dow: parts[4] ?? "*",
};
}
const CRON_DOW_OPTIONS = [
["*", "每"],
["0", "周日"],
["1", "周一"],
["2", "周二"],
["3", "周三"],
["4", "周四"],
["5", "周五"],
["6", "周六"],
];
const CRON_MONTH_OPTIONS = [
["*", "每"],
...Array.from({ length: 12 }, (_, i) => [String(i + 1), `${i + 1}`]),
];
function resetSelectOptions(selectEl, options, value) {
selectEl.innerHTML = options.map(
([val, label]) => `<option value="${val}">${label}</option>`
).join("");
if ([...selectEl.options].some((opt) => opt.value === value)) {
selectEl.value = value;
return;
}
const opt = document.createElement("option");
opt.value = value;
opt.textContent = value;
opt.selected = true;
selectEl.appendChild(opt);
}
function resetDowSelect(value) {
resetSelectOptions(els.editDow, CRON_DOW_OPTIONS, value);
}
function resetMonthSelect(value) {
resetSelectOptions(els.editMonth, CRON_MONTH_OPTIONS, value);
}
function fillCronForm(expr) {
const { minute, hour, dom, month, dow } = parseCron(expr);
els.editMinute.value = minute;
els.editHour.value = hour;
els.editDom.value = dom;
resetMonthSelect(month);
resetDowSelect(dow);
}
function buildScheduleFromForm() {
const values = CRON_FIELD_KEYS.map((key) => {
const raw = els[key].value.trim();
return raw === "" ? "*" : raw;
});
values.push(els.editMonth.value.trim() || "*");
values.push(els.editDow.value.trim() || "*");
return values.join(" ");
}
function updateEditPreview() {
try {
els.editPreview.textContent = buildScheduleFromForm() || "—";
} catch {
els.editPreview.textContent = "—";
}
}
const MAX_TASK_TAGS = 4;
const RUNTIME_LABELS = {
python: "Python",
javascript: "JavaScript",
bash: "Bash",
powershell: "PowerShell",
};
function runtimeLabel(task) {
return RUNTIME_LABELS[task.runtime] || task.runtime || "Python";
}
function displayTags(task) {
const tags = Array.isArray(task.tags) ? task.tags.filter(Boolean) : [];
if (tags.length) return tags.slice(0, MAX_TASK_TAGS);
return [runtimeLabel(task)];
}
function tagsMarkup(task) {
return displayTags(task)
.map((tag) => `<span class="task-tag">${escapeHtml(tag)}</span>`)
.join("");
}
function nameMarkup(task) {
return `
<div class="task-name-row">
<span class="task-name">${escapeHtml(task.task_id)}</span>
${tagsMarkup(task)}
</div>
${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ""}
`;
}
function renderRuntimes(items) {
els.runtimes.innerHTML = items
.map((item) => {
const version = item.available
? escapeHtml(item.version || "—")
: '<span class="runtime-missing">无</span>';
const stateClass = item.available ? "is-ok" : "is-missing";
return `
<div class="runtime-card ${stateClass}">
<span class="runtime-name">${escapeHtml(item.name)}</span>
<span class="runtime-version">${version}</span>
</div>
`;
})
.join("");
}
async function loadRuntimes() {
try {
const items = await api("/runtimes");
renderRuntimes(items);
} catch {
els.runtimes.innerHTML = '<div class="runtime-card is-missing"><span class="runtime-name">运行时</span><span class="runtime-version"><span class="runtime-missing">检测失败</span></span></div>';
}
}
function renderStats(tasks) {
const enabled = tasks.filter((t) => t.enabled).length;
const running = tasks.filter((t) => t.running).length;
const off = tasks.length - enabled;
els.stats.innerHTML = `
<div class="stat-card">
<span class="stat-label">任务总数</span>
<span class="stat-value">${tasks.length}</span>
</div>
<div class="stat-card stat-accent">
<span class="stat-label">已开启</span>
<span class="stat-value">${enabled}</span>
</div>
<div class="stat-card">
<span class="stat-label">已关闭</span>
<span class="stat-value">${off}</span>
</div>
<div class="stat-card stat-run">
<span class="stat-label">运行中</span>
<span class="stat-value">${running}</span>
</div>
`;
els.summaryText.textContent = running
? `${tasks.length} 个任务,${enabled} 个已开启,${running} 个正在运行`
: `${tasks.length} 个任务,${enabled} 个已开启`;
}
function renderTasks(tasks) {
if (!tasks.length) {
els.taskBody.innerHTML =
'<tr><td colspan="5" class="empty">没有匹配的任务</td></tr>';
els.taskCards.innerHTML = '<div class="empty">没有匹配的任务</div>';
return;
}
els.taskBody.innerHTML = tasks
.map(
(task) => `
<tr>
<td>${statusMarkup(task)}</td>
<td>${nameMarkup(task)}</td>
<td>${scheduleMarkup(task)}</td>
<td>${logMarkup(task)}</td>
<td>${actionButtons(task)}</td>
</tr>
`
)
.join("");
els.taskCards.innerHTML = tasks
.map(
(task) => `
<article class="task-card">
<div class="task-card-head">
<div class="name">${nameMarkup(task)}</div>
${statusMarkup(task)}
</div>
<div class="task-card-meta">
<div class="meta-block">
<span class="field-label">调度</span>
${scheduleMarkup(task)}
</div>
<div class="meta-block">
<span class="field-label">日志</span>
<div class="log-meta">${formatBytes(task.log_size)}</div>
<div class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</div>
</div>
</div>
<div class="task-card-actions">${actionButtons(task)}</div>
</article>
`
)
.join("");
}
function applyFilter() {
const q = els.filter.value.trim().toLowerCase();
const filtered = q
? allTasks.filter((t) => {
const tagText = displayTags(t).join(" ").toLowerCase();
return (
t.task_id.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q) ||
tagText.includes(q)
);
})
: allTasks;
renderTasks(filtered);
}
async function loadTasks() {
try {
const tasks = await api("/tasks");
allTasks = tasks;
renderStats(tasks);
applyFilter();
els.lastRefresh.textContent = `已刷新 ${nowText()}`;
await loadRuntimes();
} catch (err) {
els.taskBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(err.message)}</td></tr>`;
els.taskCards.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
els.lastRefresh.textContent = `刷新失败 ${nowText()}`;
showToast(err.message, true);
}
}
async function handleAction(action, taskId) {
try {
if (action === "log") {
await openLog(taskId);
return;
}
if (action === "edit") {
openEdit(taskId);
return;
}
if (action === "run") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" });
showToast(res.ok === false ? res.message : `已启动 ${taskId}`);
} else if (action === "sync") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/sync-cron`, { method: "POST" });
showToast(res.message || `已同步 ${taskId} 的 cron`);
} else if (action === "toggle") {
await api(`/tasks/${encodeURIComponent(taskId)}/toggle`, { method: "POST" });
showToast(`已切换 ${taskId} 状态`);
}
await loadTasks();
} catch (err) {
showToast(err.message, true);
}
}
async function openLog(taskId) {
currentLogTaskId = taskId;
els.logTitle.textContent = taskId;
els.logSubtitle.textContent = "加载日志…";
els.logContent.textContent = "";
els.logDialog.showModal();
await refreshLog();
}
async function refreshLog() {
if (!currentLogTaskId) return;
try {
const data = await api(`/tasks/${encodeURIComponent(currentLogTaskId)}/log?lines=300`);
const lineCount = data.content ? data.content.split("\n").length : 0;
els.logSubtitle.textContent = `最近 ${lineCount}`;
els.logContent.textContent = data.content || "";
els.logContent.scrollTop = els.logContent.scrollHeight;
} catch (err) {
els.logSubtitle.textContent = err.message;
showToast(err.message, true);
}
}
function renderEditTags() {
if (!els.editTagListEl) return;
els.editTagListEl.innerHTML = editTagList
.map(
(tag, index) => `
<span class="task-tag is-editable">
${escapeHtml(tag)}
<button type="button" class="tag-remove" data-index="${index}" aria-label="移除 ${escapeHtml(tag)}">×</button>
</span>
`
)
.join("");
const atLimit = editTagList.length >= MAX_TASK_TAGS;
if (els.editTagInput) els.editTagInput.disabled = atLimit;
const addBtn = document.getElementById("btn-tag-add");
if (addBtn) addBtn.disabled = atLimit;
}
function addEditTag() {
if (!els.editTagInput) return;
const value = els.editTagInput.value.trim();
if (!value) return;
if (editTagList.length >= MAX_TASK_TAGS) {
showToast(`最多 ${MAX_TASK_TAGS} 个标签`, true);
return;
}
if (editTagList.includes(value)) {
showToast("标签已存在", true);
return;
}
editTagList.push(value);
els.editTagInput.value = "";
renderEditTags();
}
function removeEditTag(index) {
if (index < 0 || index >= editTagList.length) return;
editTagList.splice(index, 1);
renderEditTags();
}
function openEdit(taskId) {
const task = allTasks.find((t) => t.task_id === taskId);
if (!task) {
showToast("任务不存在", true);
return;
}
currentEditTaskId = taskId;
els.editTitle.textContent = taskId;
els.editDescription.value = task.description || "";
editTagList = [...displayTags(task)];
if (els.editTagInput) els.editTagInput.value = "";
renderEditTags();
fillCronForm(task.schedule || "0 8 * * *");
updateEditPreview();
els.editDialog.showModal();
}
async function saveEdit(event) {
event.preventDefault();
if (!currentEditTaskId) return;
const schedule = buildScheduleFromForm();
if (!schedule || schedule.split(/\s+/).length !== 5) {
showToast("请填写完整的分、时、日、月、周", true);
return;
}
try {
els.btnEditSave.disabled = true;
const res = await api(`/tasks/${encodeURIComponent(currentEditTaskId)}/schedule`, {
method: "PATCH",
body: JSON.stringify({
description: els.editDescription.value.trim(),
schedule,
tags: editTagList,
}),
});
els.editDialog.close();
showToast(res.message || "已保存");
await loadTasks();
} catch (err) {
showToast(err.message, true);
} finally {
els.btnEditSave.disabled = false;
}
}
function bindActions(root) {
root.addEventListener("click", (event) => {
const btn = event.target.closest("[data-action]");
if (!btn) return;
handleAction(btn.dataset.action, btn.dataset.id);
});
}
document.getElementById("btn-refresh-top").addEventListener("click", loadTasks);
document.getElementById("btn-log-refresh").addEventListener("click", refreshLog);
document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close());
document.getElementById("btn-edit-close").addEventListener("click", () => els.editDialog.close());
document.getElementById("btn-edit-cancel").addEventListener("click", () => els.editDialog.close());
els.editForm.addEventListener("submit", saveEdit);
els.editForm.addEventListener("click", (event) => {
if (event.target.closest("#btn-tag-add")) {
event.preventDefault();
addEditTag();
return;
}
const removeBtn = event.target.closest(".tag-remove");
if (removeBtn) {
event.preventDefault();
removeEditTag(Number(removeBtn.dataset.index));
}
});
els.editForm.addEventListener("keydown", (event) => {
if (event.target !== els.editTagInput || event.key !== "Enter") return;
event.preventDefault();
addEditTag();
});
CRON_FIELD_KEYS.forEach((key) => els[key].addEventListener("input", updateEditPreview));
els.editMonth.addEventListener("change", updateEditPreview);
els.editDow.addEventListener("change", updateEditPreview);
els.cronPresets.addEventListener("click", (event) => {
const btn = event.target.closest("[data-preset]");
if (!btn) return;
fillCronForm(btn.dataset.preset);
updateEditPreview();
});
els.btnEditSave = document.getElementById("btn-edit-save");
els.filter.addEventListener("input", applyFilter);
els.logDialog.addEventListener("click", (event) => {
if (event.target === els.logDialog) els.logDialog.close();
});
els.editDialog.addEventListener("click", (event) => {
if (event.target === els.editDialog) els.editDialog.close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (els.logDialog.open) els.logDialog.close();
else if (els.editDialog.open) els.editDialog.close();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
els.filter.focus();
}
});
bindActions(document.body);
loadTasks();
refreshTimer = setInterval(loadTasks, REFRESH_INTERVAL);
window.addEventListener("beforeunload", () => clearInterval(refreshTimer));