1308 lines
48 KiB
JavaScript
1308 lines
48 KiB
JavaScript
// cron.js -- 定时任务(Cron)模式前端逻辑
|
||
//
|
||
// 职责:
|
||
// 1. 侧栏任务列表渲染(含状态徽章 + 悬停操作)
|
||
// 2. 任务新建/编辑/删除/启停(弹窗表单)
|
||
// 3. 聊天区复用 chat-render 渲染执行记录 + 顶部任务信息条
|
||
// 4. SSE 实时流(/api/cron/{id}/live/stream)→ 复用 injectLiveToolCalls
|
||
// 5. 追问发送(POST /api/cron/{id}/run {prompt})→ 子进程执行
|
||
//
|
||
// 与 chat/edit 模式的关系:
|
||
// - 共享 #chatMessages DOM 与 renderMessages,切换离开时由 leaveCronMode 恢复聊天视图。
|
||
// - 不触碰 state.sessionId(chat 会话标识),cron 任务用 state.activeCronTaskId 标识。
|
||
|
||
import { $, showToast, escapeHtml } from './utils.js';
|
||
import { showConfirm, showAlert } from './dialog.js';
|
||
import { api } from './api.js';
|
||
import { state } from './state.js';
|
||
import { renderMessages, appendThinkingIndicator, scrollChatToBottom } from './chat-render.js';
|
||
import { injectLiveToolCalls } from './toolcalls.js';
|
||
|
||
// ===== 状态文案 / 颜色映射 =====
|
||
const STATUS_TEXT = {
|
||
pending: '待运行',
|
||
running: '运行中',
|
||
completed: '已完成',
|
||
failed: '失败',
|
||
expired: '过时失效',
|
||
disabled: '已禁用',
|
||
};
|
||
|
||
const TRIGGER_TEXT = {
|
||
cron: (t) => formatCronTrigger(t.cron),
|
||
interval: (t) => `每 ${t.interval_seconds || 0}s`,
|
||
once: (t) => t.run_at ? `定时: ${t.run_at.replace('T', ' ').slice(0, 16)}` : '一次性延时',
|
||
};
|
||
|
||
// 将 cron 表达式格式化为用户友好的闹钟文案(无法解析时回退显示原始 cron)
|
||
function formatCronTrigger(cronExpr) {
|
||
const alarm = cronToAlarm(cronExpr);
|
||
if (!alarm) return cronExpr ? `cron: ${cronExpr}` : 'cron 表达式';
|
||
const time = `${String(alarm.hour).padStart(2, '0')}:${String(alarm.minute).padStart(2, '0')}`;
|
||
if (alarm.weekdays.length === 7) return `每天 ${time}`;
|
||
const wdLabels = { 0: '日', 1: '一', 2: '二', 3: '三', 4: '四', 5: '五', 6: '六' };
|
||
const sorted = [...alarm.weekdays].sort((a, b) => (a === 0 ? 7 : a) - (b === 0 ? 7 : b));
|
||
return `周${sorted.map((d) => wdLabels[d]).join('、')} ${time}`;
|
||
}
|
||
|
||
// ===== DOM 引用 =====
|
||
let cronListEl = null;
|
||
let cronNewBtnEl = null;
|
||
let chatPanelEl = null;
|
||
let chatMessagesEl = null;
|
||
let chatPlaceholderEl = null;
|
||
let textInputEl = null;
|
||
let sendBtnEl = null;
|
||
|
||
// cron 专属 DOM(动态创建/管理)
|
||
let cronBannerEl = null; // 顶部任务信息条
|
||
let cronPlaceholderEl = null; // 空状态占位
|
||
let cronEditOverlayEl = null; // 编辑弹窗
|
||
|
||
// 运行态追踪
|
||
let _typingEl = null; // 当前 live 工具调用挂载的思考气泡
|
||
let _eventSource = null; // SSE 连接
|
||
let _statusPollTimer = null; // 状态轮询定时器
|
||
let _reconnectTimer = null;
|
||
let _collectedReflections = [];
|
||
let _lastReflectionCount = 0;
|
||
let _turnStartedAt = null;
|
||
let _cronActive = false; // 是否处于 cron 模式(控制 SSE/轮询生命周期)
|
||
let _periodMinutes = 30; // 时间段长度(分钟),供冲突检测使用,启动时从后端拉取
|
||
|
||
/** 从后端拉取时间段长度并缓存 */
|
||
async function refreshPeriodMinutes() {
|
||
try {
|
||
const res = await api("/api/cron/period");
|
||
if (res && typeof res.period_minutes === "number") {
|
||
_periodMinutes = res.period_minutes;
|
||
}
|
||
} catch (_) { /* 拉取失败沿用默认值 */ }
|
||
}
|
||
|
||
// ===== 初始化 =====
|
||
export function initCronMode() {
|
||
cronListEl = $("cronList");
|
||
cronNewBtnEl = $("cronNewBtn");
|
||
chatPanelEl = $("chatPanel");
|
||
chatMessagesEl = $("chatMessages");
|
||
chatPlaceholderEl = $("chatPlaceholder");
|
||
textInputEl = $("textInput");
|
||
sendBtnEl = $("sendBtn");
|
||
|
||
// 新建按钮
|
||
cronNewBtnEl?.addEventListener("click", () => openCronEditor(null));
|
||
|
||
// 任务列表事件委托:选中 / 操作按钮
|
||
cronListEl?.addEventListener("click", (e) => {
|
||
const actionBtn = e.target.closest("[data-cron-action]");
|
||
if (actionBtn) {
|
||
e.stopPropagation();
|
||
const tid = actionBtn.dataset.taskId;
|
||
const action = actionBtn.dataset.cronAction;
|
||
if (tid && action) handleListAction(action, tid);
|
||
return;
|
||
}
|
||
const item = e.target.closest("[data-cron-task-id]");
|
||
if (item) {
|
||
const tid = item.dataset.cronTaskId;
|
||
if (tid) selectCronTask(tid);
|
||
}
|
||
});
|
||
|
||
// 编辑弹窗事件
|
||
bindCronEditorEvents();
|
||
}
|
||
|
||
// ===== 模式进入 / 离开(由 edit-mode.js 钩子调用) =====
|
||
export async function enterCronMode() {
|
||
_cronActive = true;
|
||
ensureCronPlaceholders();
|
||
// 拉取时间段长度供冲突检测使用
|
||
refreshPeriodMinutes();
|
||
await renderCronTasks();
|
||
// 恢复上次选中的任务,否则展示空状态
|
||
if (state.activeCronTaskId) {
|
||
await selectCronTask(state.activeCronTaskId, { silent: true });
|
||
} else {
|
||
showCronEmptyState();
|
||
}
|
||
}
|
||
|
||
export async function leaveCronMode() {
|
||
_cronActive = false;
|
||
stopCronLive();
|
||
hideCronPlaceholders();
|
||
// 恢复聊天会话视图(cron 模式复用了 #chatMessages)
|
||
await restoreChatView();
|
||
}
|
||
|
||
// ===== 任务列表渲染 =====
|
||
export async function renderCronTasks() {
|
||
if (!cronListEl) return;
|
||
try {
|
||
const data = await api("/api/cron");
|
||
state.cronTasks = data.tasks || [];
|
||
} catch (e) {
|
||
state.cronTasks = [];
|
||
showToast("加载定时任务失败: " + (e.message || e));
|
||
}
|
||
cronListEl.innerHTML = "";
|
||
if (state.cronTasks.length === 0) {
|
||
const empty = document.createElement("li");
|
||
empty.className = "cron-empty";
|
||
empty.textContent = "暂无定时任务,点击 + 新建";
|
||
cronListEl.appendChild(empty);
|
||
return;
|
||
}
|
||
for (const t of state.cronTasks) {
|
||
cronListEl.appendChild(renderCronListItem(t));
|
||
}
|
||
}
|
||
|
||
function renderCronListItem(task) {
|
||
const li = document.createElement("li");
|
||
li.className = "cron-item";
|
||
if (task.task_id === state.activeCronTaskId) li.classList.add("active");
|
||
li.dataset.cronTaskId = task.task_id;
|
||
li.title = task.prompt ? String(task.prompt).slice(0, 120) : (task.name || "");
|
||
|
||
const status = task.is_running ? "running" : (task.last_status || "pending");
|
||
const dot = document.createElement("span");
|
||
dot.className = `cron-status-dot cron-status-dot--${status}`;
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "cron-item-body";
|
||
|
||
const name = document.createElement("div");
|
||
name.className = "cron-item-name";
|
||
name.textContent = task.name || "未命名任务";
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "cron-item-meta";
|
||
const trigText = task.trigger ? (TRIGGER_TEXT[task.trigger.type] || (() => ""))(task.trigger) : "";
|
||
const nextText = task.next_run_at ? `下次: ${task.next_run_at.replace("T", " ").slice(5, 16)}` : STATUS_TEXT[status];
|
||
meta.textContent = `${trigText} · ${nextText}`;
|
||
|
||
body.appendChild(name);
|
||
body.appendChild(meta);
|
||
|
||
// 悬停操作按钮
|
||
const actions = document.createElement("div");
|
||
actions.className = "cron-item-actions";
|
||
|
||
// toggle: 启用时显示暂停图标,禁用时显示启用(播放)图标
|
||
actions.appendChild(makeActionBtn(task.task_id, "toggle",
|
||
task.enabled ? "暂停" : "启用", task.enabled ? SVG_PAUSE : SVG_PLAY, false));
|
||
actions.appendChild(makeActionBtn(task.task_id, "run", "立即运行", SVG_PLAY, false));
|
||
actions.appendChild(makeActionBtn(task.task_id, "edit", "编辑", null, false, "/image/编辑.svg"));
|
||
actions.appendChild(makeActionBtn(task.task_id, "delete", "删除", null, true, "/image/删除.svg"));
|
||
|
||
li.appendChild(dot);
|
||
li.appendChild(body);
|
||
li.appendChild(actions);
|
||
return li;
|
||
}
|
||
|
||
// 内联 SVG(缺失图标文件的兜底,保证渲染清晰)
|
||
const SVG_PAUSE = '<svg viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>';
|
||
const SVG_PLAY = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11a.5.5 0 0 0 .77.42l8.5-5.5a.5.5 0 0 0 0-.84l-8.5-5.5A.5.5 0 0 0 4 2.5z"/></svg>';
|
||
|
||
// ===== 闹钟式触发 UI 构建与 cron 互转 =====
|
||
// 周几定义(cron 标准:0=周日, 1=周一 ... 6=周六),显示顺序为周一~周日
|
||
const WEEKDAYS = [
|
||
{ cron: 1, label: '一' },
|
||
{ cron: 2, label: '二' },
|
||
{ cron: 3, label: '三' },
|
||
{ cron: 4, label: '四' },
|
||
{ cron: 5, label: '五' },
|
||
{ cron: 6, label: '六' },
|
||
{ cron: 0, label: '日' },
|
||
];
|
||
|
||
const ARROW_UP = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M6 2.2l4.2 4.8H1.8z"/></svg>';
|
||
const ARROW_DOWN = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M6 9.8L1.8 5h8.4z"/></svg>';
|
||
|
||
// 闹钟式选择器实例(在 bindCronEditorEvents 中构建)
|
||
let _alarmWeekdayPicker = null;
|
||
let _alarmTimePicker = null;
|
||
let _onceDatePicker = null;
|
||
let _onceTimePicker = null;
|
||
|
||
// cron 表达式 → 闹钟状态 {weekdays, hour, minute};无法解析返回 null
|
||
function cronToAlarm(cronExpr) {
|
||
if (!cronExpr) return null;
|
||
const parts = String(cronExpr).trim().split(/\s+/);
|
||
if (parts.length !== 5) return null;
|
||
const [minF, hourF, domF, monF, dowF] = parts;
|
||
// 仅支持 "分 时 * * 周" 的闹钟式形态(不支持 */N、范围、步进)
|
||
if (domF !== '*' || monF !== '*') return null;
|
||
if (!/^\d+$/.test(minF) || !/^\d+$/.test(hourF)) return null;
|
||
const minute = parseInt(minF, 10);
|
||
const hour = parseInt(hourF, 10);
|
||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) return null;
|
||
let weekdays;
|
||
if (dowF === '*') {
|
||
weekdays = [0, 1, 2, 3, 4, 5, 6];
|
||
} else {
|
||
if (!/^[\d,]+$/.test(dowF)) return null;
|
||
const nums = dowF.split(',').map((s) => parseInt(s, 10));
|
||
if (nums.some((n) => n < 0 || n > 7)) return null;
|
||
weekdays = [...new Set(nums.map((n) => (n === 7 ? 0 : n)))];
|
||
if (weekdays.length === 0) return null;
|
||
}
|
||
return { weekdays, hour, minute };
|
||
}
|
||
|
||
// 闹钟状态 → cron 表达式(全选周几时 DOW 用 * 表示每天)
|
||
function alarmToCron(weekdays, hour, minute) {
|
||
const h = String(hour).padStart(2, '0');
|
||
const m = String(minute).padStart(2, '0');
|
||
let dow;
|
||
if (!weekdays || weekdays.length === 0 || weekdays.length === 7) {
|
||
dow = '*';
|
||
} else {
|
||
dow = [...weekdays].sort((a, b) => {
|
||
// 排序:周一(1)~周六(6)在前,周日(0)最后
|
||
const ra = a === 0 ? 7 : a;
|
||
const rb = b === 0 ? 7 : b;
|
||
return ra - rb;
|
||
}).join(',');
|
||
}
|
||
return `${m} ${h} * * ${dow}`;
|
||
}
|
||
|
||
// 构建时分滚轮选择器:上下箭头 + 点击输入 + 滚轮
|
||
function buildTimePicker(container, initHour, initMinute) {
|
||
if (!container) return null;
|
||
container.innerHTML = '';
|
||
let hour = initHour ?? 9;
|
||
let minute = initMinute ?? 0;
|
||
|
||
function makeUnit(max, getVal, setVal) {
|
||
const unit = document.createElement('div');
|
||
unit.className = 'cron-time-unit';
|
||
const upBtn = document.createElement('button');
|
||
upBtn.type = 'button';
|
||
upBtn.className = 'cron-time-arrow';
|
||
upBtn.innerHTML = ARROW_UP;
|
||
upBtn.setAttribute('aria-label', '增加');
|
||
const valEl = document.createElement('div');
|
||
valEl.className = 'cron-time-value';
|
||
valEl.textContent = String(getVal()).padStart(2, '0');
|
||
valEl.tabIndex = 0;
|
||
valEl.setAttribute('role', 'spinbutton');
|
||
valEl.setAttribute('aria-valuemin', '0');
|
||
valEl.setAttribute('aria-valuemax', String(max));
|
||
const downBtn = document.createElement('button');
|
||
downBtn.type = 'button';
|
||
downBtn.className = 'cron-time-arrow';
|
||
downBtn.innerHTML = ARROW_DOWN;
|
||
downBtn.setAttribute('aria-label', '减少');
|
||
|
||
const render = () => { valEl.textContent = String(getVal()).padStart(2, '0'); valEl.setAttribute('aria-valuenow', String(getVal())); };
|
||
const bump = (delta) => {
|
||
let v = getVal() + delta;
|
||
if (v < 0) v = max;
|
||
if (v > max) v = 0;
|
||
setVal(v);
|
||
render();
|
||
};
|
||
upBtn.addEventListener('click', () => bump(1));
|
||
downBtn.addEventListener('click', () => bump(-1));
|
||
|
||
// 点击数值进入可编辑态,回车/失焦提交
|
||
const enterEdit = () => {
|
||
valEl.classList.add('is-editing');
|
||
valEl.contentEditable = 'true';
|
||
valEl.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(valEl);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
};
|
||
const commitEdit = () => {
|
||
valEl.classList.remove('is-editing');
|
||
valEl.contentEditable = 'false';
|
||
const raw = (valEl.textContent || '').replace(/\D/g, '');
|
||
let v = raw ? parseInt(raw, 10) : getVal();
|
||
if (isNaN(v)) v = getVal();
|
||
if (v < 0) v = 0;
|
||
if (v > max) v = max;
|
||
setVal(v);
|
||
render();
|
||
};
|
||
valEl.addEventListener('click', enterEdit);
|
||
valEl.addEventListener('blur', commitEdit);
|
||
valEl.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); valEl.blur(); }
|
||
else if (e.key === 'Escape') { render(); valEl.blur(); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); bump(1); }
|
||
else if (e.key === 'ArrowDown') { e.preventDefault(); bump(-1); }
|
||
});
|
||
// 鼠标滚轮在 unit 内滚动调值
|
||
unit.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
bump(e.deltaY < 0 ? 1 : -1);
|
||
}, { passive: false });
|
||
|
||
unit.appendChild(upBtn);
|
||
unit.appendChild(valEl);
|
||
unit.appendChild(downBtn);
|
||
return { unit, render };
|
||
}
|
||
|
||
const hourUnit = makeUnit(23, () => hour, (v) => { hour = v; });
|
||
const sep = document.createElement('span');
|
||
sep.className = 'cron-time-sep';
|
||
sep.textContent = ':';
|
||
const minUnit = makeUnit(59, () => minute, (v) => { minute = v; });
|
||
|
||
container.appendChild(hourUnit.unit);
|
||
container.appendChild(sep);
|
||
container.appendChild(minUnit.unit);
|
||
|
||
return {
|
||
getHour: () => hour,
|
||
getMinute: () => minute,
|
||
setHour: (v) => { hour = Math.max(0, Math.min(23, v | 0)); hourUnit.render(); },
|
||
setMinute: (v) => { minute = Math.max(0, Math.min(59, v | 0)); minUnit.render(); },
|
||
};
|
||
}
|
||
|
||
// 构建周几方块选择器:点击切换 + 弹跳动画
|
||
function buildWeekdaySelector(container, selected) {
|
||
if (!container) return null;
|
||
container.innerHTML = '';
|
||
const set = new Set(selected || []);
|
||
WEEKDAYS.forEach((wd) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'cron-weekday-btn' + (set.has(wd.cron) ? ' is-on' : '');
|
||
btn.textContent = wd.label;
|
||
btn.dataset.cron = String(wd.cron);
|
||
btn.setAttribute('aria-pressed', set.has(wd.cron) ? 'true' : 'false');
|
||
btn.addEventListener('click', () => {
|
||
if (set.has(wd.cron)) {
|
||
set.delete(wd.cron);
|
||
btn.classList.remove('is-on');
|
||
btn.setAttribute('aria-pressed', 'false');
|
||
} else {
|
||
set.add(wd.cron);
|
||
// 强制 reflow 重启弹跳动画
|
||
btn.classList.remove('is-on');
|
||
void btn.offsetWidth;
|
||
btn.classList.add('is-on');
|
||
btn.setAttribute('aria-pressed', 'true');
|
||
}
|
||
});
|
||
container.appendChild(btn);
|
||
});
|
||
return {
|
||
getWeekdays: () => [...set],
|
||
setWeekdays: (arr) => {
|
||
set.clear();
|
||
(arr || []).forEach((d) => set.add(d));
|
||
container.querySelectorAll('.cron-weekday-btn').forEach((b) => {
|
||
const on = set.has(Number(b.dataset.cron));
|
||
b.classList.toggle('is-on', on);
|
||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
// 构建主题化日期选择器:触发按钮 + 下拉日历
|
||
function buildDatePicker(container, initialDate) {
|
||
if (!container) return null;
|
||
container.innerHTML = '';
|
||
let selected = initialDate ? { ...initialDate } : null;
|
||
const today = new Date();
|
||
let viewYear, viewMonth;
|
||
if (initialDate) {
|
||
viewYear = initialDate.year;
|
||
viewMonth = initialDate.month;
|
||
} else {
|
||
viewYear = today.getFullYear();
|
||
viewMonth = today.getMonth();
|
||
}
|
||
|
||
const trigger = document.createElement('button');
|
||
trigger.type = 'button';
|
||
trigger.className = 'cron-date-trigger';
|
||
trigger.innerHTML = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M5 1a1 1 0 0 1 1 1v1h4V2a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h1V2a1 1 0 0 1 1-1zM3 6v8h10V6H3z"/></svg><span class="cron-date-trigger-text"></span>';
|
||
const trigText = trigger.querySelector('.cron-date-trigger-text');
|
||
container.appendChild(trigger);
|
||
|
||
const cal = document.createElement('div');
|
||
cal.className = 'cron-calendar';
|
||
cal.hidden = true;
|
||
container.appendChild(cal);
|
||
|
||
const fmtSelected = () => selected
|
||
? `${selected.year}-${String(selected.month + 1).padStart(2, '0')}-${String(selected.day).padStart(2, '0')}`
|
||
: '';
|
||
const updateTrigger = () => {
|
||
if (selected) {
|
||
trigText.textContent = fmtSelected();
|
||
trigger.classList.remove('is-placeholder');
|
||
} else {
|
||
trigText.textContent = '选择日期…';
|
||
trigger.classList.add('is-placeholder');
|
||
}
|
||
};
|
||
|
||
function renderCalendar() {
|
||
cal.innerHTML = '';
|
||
const header = document.createElement('div');
|
||
header.className = 'cron-calendar-header';
|
||
const prevBtn = document.createElement('button');
|
||
prevBtn.type = 'button';
|
||
prevBtn.className = 'cron-calendar-nav';
|
||
prevBtn.innerHTML = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M8 1L3 6l5 5z"/></svg>';
|
||
prevBtn.setAttribute('aria-label', '上个月');
|
||
const title = document.createElement('span');
|
||
title.className = 'cron-calendar-title';
|
||
title.textContent = `${viewYear}年${viewMonth + 1}月`;
|
||
const nextBtn = document.createElement('button');
|
||
nextBtn.type = 'button';
|
||
nextBtn.className = 'cron-calendar-nav';
|
||
nextBtn.innerHTML = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M4 1l5 5-5 5z"/></svg>';
|
||
nextBtn.setAttribute('aria-label', '下个月');
|
||
prevBtn.addEventListener('click', () => {
|
||
viewMonth--;
|
||
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||
renderCalendar();
|
||
});
|
||
nextBtn.addEventListener('click', () => {
|
||
viewMonth++;
|
||
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||
renderCalendar();
|
||
});
|
||
header.appendChild(prevBtn);
|
||
header.appendChild(title);
|
||
header.appendChild(nextBtn);
|
||
cal.appendChild(header);
|
||
|
||
const grid = document.createElement('div');
|
||
grid.className = 'cron-calendar-grid';
|
||
['日', '一', '二', '三', '四', '五', '六'].forEach((d) => {
|
||
const dow = document.createElement('span');
|
||
dow.className = 'cron-calendar-dow';
|
||
dow.textContent = d;
|
||
grid.appendChild(dow);
|
||
});
|
||
|
||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||
const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();
|
||
const todayY = today.getFullYear();
|
||
const todayM = today.getMonth();
|
||
const todayD = today.getDate();
|
||
|
||
for (let i = firstDay - 1; i >= 0; i--) {
|
||
const cell = document.createElement('button');
|
||
cell.type = 'button';
|
||
cell.className = 'cron-calendar-day is-other-month';
|
||
cell.textContent = String(daysInPrev - i);
|
||
cell.disabled = true;
|
||
grid.appendChild(cell);
|
||
}
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
const cell = document.createElement('button');
|
||
cell.type = 'button';
|
||
cell.className = 'cron-calendar-day';
|
||
cell.textContent = String(day);
|
||
if (viewYear === todayY && viewMonth === todayM && day === todayD) cell.classList.add('is-today');
|
||
if (selected && selected.year === viewYear && selected.month === viewMonth && selected.day === day) cell.classList.add('is-selected');
|
||
cell.addEventListener('click', () => {
|
||
selected = { year: viewYear, month: viewMonth, day };
|
||
renderCalendar();
|
||
updateTrigger();
|
||
cal.hidden = true;
|
||
});
|
||
grid.appendChild(cell);
|
||
}
|
||
const totalCells = firstDay + daysInMonth;
|
||
const trailing = (7 - (totalCells % 7)) % 7;
|
||
for (let i = 1; i <= trailing; i++) {
|
||
const cell = document.createElement('button');
|
||
cell.type = 'button';
|
||
cell.className = 'cron-calendar-day is-other-month';
|
||
cell.textContent = String(i);
|
||
cell.disabled = true;
|
||
grid.appendChild(cell);
|
||
}
|
||
cal.appendChild(grid);
|
||
|
||
const footer = document.createElement('div');
|
||
footer.className = 'cron-calendar-footer';
|
||
const clearBtn = document.createElement('button');
|
||
clearBtn.type = 'button';
|
||
clearBtn.className = 'cron-cal-clear';
|
||
clearBtn.textContent = '清除';
|
||
clearBtn.addEventListener('click', () => {
|
||
selected = null;
|
||
updateTrigger();
|
||
renderCalendar();
|
||
});
|
||
const todayBtn = document.createElement('button');
|
||
todayBtn.type = 'button';
|
||
todayBtn.className = 'cron-cal-today';
|
||
todayBtn.textContent = '今天';
|
||
todayBtn.addEventListener('click', () => {
|
||
viewYear = todayY; viewMonth = todayM;
|
||
selected = { year: todayY, month: todayM, day: todayD };
|
||
updateTrigger();
|
||
renderCalendar();
|
||
cal.hidden = true;
|
||
});
|
||
footer.appendChild(clearBtn);
|
||
footer.appendChild(todayBtn);
|
||
cal.appendChild(footer);
|
||
}
|
||
|
||
trigger.addEventListener('click', () => {
|
||
cal.hidden = !cal.hidden;
|
||
if (!cal.hidden) renderCalendar();
|
||
});
|
||
// 点击外部关闭日历
|
||
const outsideHandler = (e) => {
|
||
if (cal.hidden) return;
|
||
if (!container.contains(e.target)) cal.hidden = true;
|
||
};
|
||
document.addEventListener('click', outsideHandler);
|
||
|
||
updateTrigger();
|
||
return {
|
||
getSelected: () => fmtSelected(),
|
||
setSelected: (dateStr) => {
|
||
if (!dateStr) { selected = null; updateTrigger(); return; }
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
||
if (m) {
|
||
selected = { year: parseInt(m[1], 10), month: parseInt(m[2], 10) - 1, day: parseInt(m[3], 10) };
|
||
viewYear = selected.year;
|
||
viewMonth = selected.month;
|
||
}
|
||
updateTrigger();
|
||
},
|
||
};
|
||
}
|
||
|
||
function makeActionBtn(taskId, action, title, inlineSvg, danger, iconSrc) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "cron-item-action-btn" + (danger ? " danger" : "");
|
||
btn.title = title;
|
||
btn.dataset.taskId = taskId;
|
||
btn.dataset.cronAction = action;
|
||
if (iconSrc) {
|
||
const img = document.createElement("img");
|
||
img.src = iconSrc;
|
||
img.alt = "";
|
||
btn.appendChild(img);
|
||
} else if (inlineSvg) {
|
||
btn.innerHTML = inlineSvg;
|
||
}
|
||
return btn;
|
||
}
|
||
|
||
async function handleListAction(action, taskId) {
|
||
if (action === "toggle") {
|
||
try {
|
||
await api(`/api/cron/${encodeURIComponent(taskId)}/toggle`, { method: "POST" });
|
||
await renderCronTasks();
|
||
if (taskId === state.activeCronTaskId) await refreshCronBanner();
|
||
} catch (e) { showToast("切换状态失败: " + (e.message || e)); }
|
||
} else if (action === "run") {
|
||
if (taskId === state.activeCronTaskId) {
|
||
await runCronTaskNow(taskId, null);
|
||
} else {
|
||
await selectCronTask(taskId);
|
||
await runCronTaskNow(taskId, null);
|
||
}
|
||
} else if (action === "edit") {
|
||
openCronEditor(taskId);
|
||
} else if (action === "delete") {
|
||
const ok = await showConfirm(`确认删除该定时任务?\n\n任务的所有执行记录将被一并删除,此操作不可撤销。`);
|
||
if (!ok) return;
|
||
try {
|
||
await api(`/api/cron/${encodeURIComponent(taskId)}`, { method: "DELETE" });
|
||
if (taskId === state.activeCronTaskId) {
|
||
state.activeCronTaskId = "";
|
||
showCronEmptyState();
|
||
}
|
||
await renderCronTasks();
|
||
showToast("已删除定时任务");
|
||
} catch (e) { showToast("删除失败: " + (e.message || e)); }
|
||
}
|
||
}
|
||
|
||
// ===== 选中任务 / 加载消息 =====
|
||
export async function selectCronTask(taskId, opts = {}) {
|
||
if (!taskId) return;
|
||
state.activeCronTaskId = taskId;
|
||
// 更新列表选中态
|
||
cronListEl?.querySelectorAll(".cron-item").forEach((el) => {
|
||
el.classList.toggle("active", el.dataset.cronTaskId === taskId);
|
||
});
|
||
await loadCronMessages(taskId);
|
||
await refreshCronBanner();
|
||
// 若任务正在运行,接入 SSE 实时流
|
||
const task = state.cronTasks.find((t) => t.task_id === taskId);
|
||
const running = task ? !!task.is_running : false;
|
||
if (running) {
|
||
startCronRunningSession(taskId);
|
||
}
|
||
}
|
||
|
||
async function loadCronMessages(taskId) {
|
||
try {
|
||
const data = await api(`/api/cron/${encodeURIComponent(taskId)}/messages`);
|
||
const msgs = data.messages || [];
|
||
showCronChatArea();
|
||
if (msgs.length === 0) {
|
||
// 任务尚无执行记录:展示提示而非触发 chat 欢迎屏
|
||
if (chatMessagesEl) {
|
||
chatMessagesEl.hidden = false;
|
||
chatMessagesEl.style.display = "";
|
||
chatMessagesEl.innerHTML = "";
|
||
const note = document.createElement("div");
|
||
note.className = "msg assistant";
|
||
note.innerHTML = `<div class="msg-bubble"><div class="cron-empty-record">该任务暂无执行记录。点击顶部"立即运行"可手动触发一次。</div></div>`;
|
||
chatMessagesEl.appendChild(note);
|
||
}
|
||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||
} else {
|
||
renderMessages(msgs);
|
||
}
|
||
// 若有 last_error 且非运行中,追加错误提示
|
||
if (data.last_error && data.last_status === "failed") {
|
||
appendCronErrorNote(data.last_error);
|
||
}
|
||
} catch (e) {
|
||
showToast("加载执行记录失败: " + (e.message || e));
|
||
showCronEmptyState();
|
||
}
|
||
}
|
||
|
||
function appendCronErrorNote(error) {
|
||
if (!chatMessagesEl) return;
|
||
const note = document.createElement("div");
|
||
note.className = "msg assistant";
|
||
note.innerHTML = `<div class="msg-bubble"><div class="cron-error-note">⚠ 上次执行失败: ${escapeHtml(String(error).slice(0, 300))}</div></div>`;
|
||
chatMessagesEl.appendChild(note);
|
||
scrollChatToBottom();
|
||
}
|
||
|
||
// ===== 顶部任务信息条 =====
|
||
async function refreshCronBanner() {
|
||
const taskId = state.activeCronTaskId;
|
||
if (!taskId) { hideCronBanner(); return; }
|
||
let task;
|
||
try {
|
||
const data = await api(`/api/cron/${encodeURIComponent(taskId)}`);
|
||
task = data.task;
|
||
} catch { task = state.cronTasks.find((t) => t.task_id === taskId); }
|
||
if (!task) { hideCronBanner(); return; }
|
||
renderCronBanner(task);
|
||
}
|
||
|
||
function renderCronBanner(task) {
|
||
ensureCronBanner();
|
||
const status = task.is_running ? "running" : (task.last_status || "pending");
|
||
const trigText = task.trigger ? (TRIGGER_TEXT[task.trigger.type] || (() => ""))(task.trigger) : "";
|
||
const nextText = task.next_run_at ? `下次运行 ${task.next_run_at.replace("T", " ").slice(0, 16)}` : "无后续运行";
|
||
|
||
cronBannerEl.innerHTML = "";
|
||
const name = document.createElement("span");
|
||
name.className = "cron-chat-banner-name";
|
||
name.textContent = task.name || "未命名任务";
|
||
|
||
const info = document.createElement("div");
|
||
info.className = "cron-chat-banner-info";
|
||
info.innerHTML = `<span>${escapeHtml(trigText)}</span><span>${escapeHtml(nextText)}</span>`;
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = `cron-status-badge cron-status-badge--${status}`;
|
||
badge.textContent = STATUS_TEXT[status] || status;
|
||
info.appendChild(badge);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "cron-banner-actions";
|
||
if (status === "running") {
|
||
const abortBtn = document.createElement("button");
|
||
abortBtn.type = "button";
|
||
abortBtn.className = "cron-banner-btn cron-banner-btn--danger";
|
||
abortBtn.textContent = "终止";
|
||
abortBtn.addEventListener("click", () => abortCronTask());
|
||
actions.appendChild(abortBtn);
|
||
} else {
|
||
const runBtn = document.createElement("button");
|
||
runBtn.type = "button";
|
||
runBtn.className = "cron-banner-btn";
|
||
runBtn.textContent = "立即运行";
|
||
runBtn.addEventListener("click", () => runCronTaskNow(task.task_id, null));
|
||
actions.appendChild(runBtn);
|
||
}
|
||
|
||
cronBannerEl.appendChild(name);
|
||
cronBannerEl.appendChild(info);
|
||
cronBannerEl.appendChild(actions);
|
||
cronBannerEl.hidden = false;
|
||
}
|
||
|
||
function hideCronBanner() {
|
||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||
}
|
||
|
||
// ===== 运行 / 追问 =====
|
||
export async function runCronTaskNow(taskId, prompt) {
|
||
if (!taskId) return;
|
||
if (state.cronRunning) { showToast("任务正在运行中"); return; }
|
||
try {
|
||
await api(`/api/cron/${encodeURIComponent(taskId)}/run`, {
|
||
method: "POST",
|
||
body: JSON.stringify(prompt ? { prompt } : {}),
|
||
});
|
||
} catch (e) {
|
||
showToast("启动任务失败: " + (e.message || e));
|
||
return;
|
||
}
|
||
startCronRunningSession(taskId);
|
||
}
|
||
|
||
// 启动运行态:SSE 实时流 + 状态轮询 + 思考气泡
|
||
function startCronRunningSession(taskId) {
|
||
state.cronRunning = true;
|
||
updateSendBtnForCron();
|
||
// 追加思考气泡作为 live 工具调用挂载点
|
||
_typingEl = appendThinkingIndicator();
|
||
_collectedReflections = [];
|
||
_lastReflectionCount = 0;
|
||
_turnStartedAt = null;
|
||
startCronLiveStream(taskId);
|
||
startStatusPolling(taskId);
|
||
refreshCronBanner();
|
||
}
|
||
|
||
// 追问发送(由 app.js 在 cron 模式下调用)
|
||
export async function sendCronChat() {
|
||
const taskId = state.activeCronTaskId;
|
||
if (!taskId) { showToast("请先选择一个定时任务"); return; }
|
||
if (state.cronRunning) { showToast("任务正在运行中,请等待完成或终止"); return; }
|
||
const trimmed = textInputEl ? textInputEl.value.trim() : "";
|
||
if (!trimmed) return;
|
||
if (textInputEl) textInputEl.value = "";
|
||
await runCronTaskNow(taskId, trimmed);
|
||
}
|
||
|
||
export async function abortCronTask() {
|
||
const taskId = state.activeCronTaskId;
|
||
if (!taskId || !state.cronRunning) return;
|
||
const ok = await showConfirm("确认终止当前运行中的定时任务?\n\n已完成的工具调用将被保留。");
|
||
if (!ok) return;
|
||
try {
|
||
await api(`/api/cron/${encodeURIComponent(taskId)}/abort`, { method: "POST" });
|
||
} catch (e) { showToast("终止失败: " + (e.message || e)); }
|
||
// 状态将由 finished/SSE 或轮询收敛
|
||
}
|
||
|
||
export function isCronRunning() { return state.cronRunning; }
|
||
|
||
// ===== SSE 实时流(复用 injectLiveToolCalls 契约) =====
|
||
function startCronLiveStream(taskId) {
|
||
stopCronLiveStream();
|
||
try {
|
||
_eventSource = new EventSource(`/api/cron/${encodeURIComponent(taskId)}/live/stream`);
|
||
} catch {
|
||
return;
|
||
}
|
||
_eventSource.addEventListener("update", (e) => {
|
||
try { handleLiveSnapshot(JSON.parse(e.data)); } catch { /* ignore */ }
|
||
});
|
||
_eventSource.addEventListener("finished", (e) => {
|
||
try {
|
||
const data = e.data ? JSON.parse(e.data) : {};
|
||
handleCronFinished(state.activeCronTaskId, data);
|
||
} catch { handleCronFinished(state.activeCronTaskId, {}); }
|
||
});
|
||
_eventSource.addEventListener("ping", () => { /* 心跳保活 */ });
|
||
_eventSource.onerror = () => {
|
||
if (_eventSource) { _eventSource.close(); _eventSource = null; }
|
||
// 非运行态不重连;运行态延迟重连以接续 live
|
||
if (state.cronRunning && _cronActive) {
|
||
_reconnectTimer = setTimeout(() => {
|
||
if (state.cronRunning && _cronActive) startCronLiveStream(taskId);
|
||
}, 1500);
|
||
}
|
||
};
|
||
}
|
||
|
||
function stopCronLiveStream() {
|
||
if (_eventSource) { _eventSource.close(); _eventSource = null; }
|
||
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
||
}
|
||
|
||
function handleLiveSnapshot(liveData) {
|
||
if (!liveData) return;
|
||
if (liveData.started_at && !_turnStartedAt) {
|
||
_turnStartedAt = Number(liveData.started_at);
|
||
}
|
||
// 收集思考内容
|
||
if (liveData.reflections && liveData.reflections.length > _lastReflectionCount) {
|
||
const newOnes = liveData.reflections.slice(_lastReflectionCount);
|
||
newOnes.forEach((r) => {
|
||
if (r && r.content) {
|
||
_collectedReflections.push({ content: r.content, between_calls: liveData.tool_calls ? liveData.tool_calls.length : -1 });
|
||
}
|
||
});
|
||
_lastReflectionCount = liveData.reflections.length;
|
||
}
|
||
// 工具调用渲染
|
||
if (_typingEl && liveData.tool_calls && liveData.tool_calls.length > 0) {
|
||
injectLiveToolCalls(_typingEl, liveData.tool_calls, _turnStartedAt, _collectedReflections);
|
||
}
|
||
}
|
||
|
||
// ===== 状态轮询(备份 finished 检测 + banner 更新) =====
|
||
function startStatusPolling(taskId) {
|
||
stopStatusPolling();
|
||
const poll = async () => {
|
||
if (!state.cronRunning || !_cronActive) return;
|
||
try {
|
||
const data = await api(`/api/cron/${encodeURIComponent(taskId)}`);
|
||
const t = data.task;
|
||
if (t && !t.is_running && t.last_status !== "running") {
|
||
// 任务已结束
|
||
handleCronFinished(taskId, { status: t.last_status, error: t.last_error });
|
||
return;
|
||
}
|
||
// 仍在运行:刷新 banner
|
||
renderCronBanner(t);
|
||
} catch { /* ignore */ }
|
||
if (state.cronRunning && _cronActive) {
|
||
_statusPollTimer = setTimeout(poll, 2500);
|
||
}
|
||
};
|
||
_statusPollTimer = setTimeout(poll, 2500);
|
||
}
|
||
|
||
function stopStatusPolling() {
|
||
if (_statusPollTimer) { clearTimeout(_statusPollTimer); _statusPollTimer = null; }
|
||
}
|
||
|
||
// 任务结束收敛:移除思考气泡、停止流、重载消息、更新状态
|
||
async function handleCronFinished(taskId, data) {
|
||
if (!state.cronRunning) return;
|
||
state.cronRunning = false;
|
||
stopCronLiveStream();
|
||
stopStatusPolling();
|
||
// 移除思考气泡
|
||
if (_typingEl && _typingEl.parentNode) {
|
||
const wrap = _typingEl.querySelector(".tool-call-list-wrap");
|
||
if (wrap && typeof wrap._stopTimer === "function") wrap._stopTimer();
|
||
_typingEl.remove();
|
||
}
|
||
_typingEl = null;
|
||
_collectedReflections = [];
|
||
_lastReflectionCount = 0;
|
||
_turnStartedAt = null;
|
||
updateSendBtnForCron();
|
||
// 刷新列表 + banner + 消息
|
||
await renderCronTasks();
|
||
if (taskId === state.activeCronTaskId) {
|
||
await loadCronMessages(taskId);
|
||
await refreshCronBanner();
|
||
}
|
||
// 状态提示
|
||
const status = data && data.status;
|
||
if (status === "failed") {
|
||
showToast("定时任务执行失败" + (data.error ? `: ${String(data.error).slice(0, 80)}` : ""));
|
||
} else if (status === "completed") {
|
||
showToast("定时任务执行完成");
|
||
}
|
||
}
|
||
|
||
function stopCronLive() {
|
||
stopCronLiveStream();
|
||
stopStatusPolling();
|
||
if (_typingEl && _typingEl.parentNode) {
|
||
const wrap = _typingEl.querySelector(".tool-call-list-wrap");
|
||
if (wrap && typeof wrap._stopTimer === "function") wrap._stopTimer();
|
||
_typingEl.remove();
|
||
}
|
||
_typingEl = null;
|
||
state.cronRunning = false;
|
||
}
|
||
|
||
// ===== 发送按钮状态(cron 模式) =====
|
||
function updateSendBtnForCron() {
|
||
if (!sendBtnEl) return;
|
||
if (state.cronRunning) {
|
||
sendBtnEl.classList.add("btn-pause");
|
||
sendBtnEl.disabled = false;
|
||
sendBtnEl.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>';
|
||
} else {
|
||
sendBtnEl.classList.remove("btn-pause");
|
||
sendBtnEl.disabled = false;
|
||
sendBtnEl.innerHTML = '<img src="/image/发送.svg" alt="" aria-hidden="true" class="send-btn-icon" />';
|
||
}
|
||
}
|
||
|
||
// ===== 占位 / 聊天区显隐管理 =====
|
||
function ensureCronPlaceholders() {
|
||
ensureCronBanner();
|
||
ensureCronEmptyPlaceholder();
|
||
}
|
||
|
||
function ensureCronBanner() {
|
||
if (cronBannerEl || !chatPanelEl) return;
|
||
cronBannerEl = document.createElement("div");
|
||
cronBannerEl.className = "cron-chat-banner";
|
||
cronBannerEl.hidden = true;
|
||
chatPanelEl.insertBefore(cronBannerEl, chatPanelEl.firstChild);
|
||
}
|
||
|
||
function ensureCronEmptyPlaceholder() {
|
||
if (cronPlaceholderEl || !chatPanelEl) return;
|
||
cronPlaceholderEl = document.createElement("div");
|
||
cronPlaceholderEl.className = "cron-chat-placeholder";
|
||
cronPlaceholderEl.hidden = true;
|
||
cronPlaceholderEl.innerHTML = `
|
||
<img src="/image/时钟.svg" alt="" />
|
||
<h3>定时任务模式</h3>
|
||
<p>从左侧选择一个任务查看执行记录,或点击 + 新建定时任务。任务触发时将自动在子进程执行,运行过程实时展示于此,完成后可继续追问。</p>
|
||
`;
|
||
chatPanelEl.appendChild(cronPlaceholderEl);
|
||
}
|
||
|
||
function hideCronPlaceholders() {
|
||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||
}
|
||
|
||
function showCronEmptyState() {
|
||
if (!chatPanelEl) return;
|
||
ensureCronPlaceholders();
|
||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||
if (chatMessagesEl) { chatMessagesEl.hidden = true; chatMessagesEl.style.display = "none"; }
|
||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = false;
|
||
hideCronBanner();
|
||
// cron 空状态保持输入框在底部,避免触发 chat 欢迎屏(welcomeView)
|
||
import("../app.js").then((m) => m.setComposerCentered(false)).catch(() => {});
|
||
}
|
||
|
||
function showCronChatArea() {
|
||
ensureCronPlaceholders();
|
||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||
if (chatMessagesEl) { chatMessagesEl.hidden = false; chatMessagesEl.style.display = ""; }
|
||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||
import("../app.js").then((m) => m.setComposerCentered(false)).catch(() => {});
|
||
}
|
||
|
||
// 离开 cron 模式时恢复聊天会话视图
|
||
async function restoreChatView() {
|
||
if (!chatPanelEl) return;
|
||
// 隐藏 cron 专属元素
|
||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||
// 重新加载当前聊天会话消息
|
||
try {
|
||
if (state.sessionId) {
|
||
const data = await api(`/api/sessions/${encodeURIComponent(state.sessionId)}/messages`);
|
||
renderMessages(data.messages || []);
|
||
} else {
|
||
renderMessages([]);
|
||
}
|
||
} catch {
|
||
// 静默失败,保留现状
|
||
}
|
||
}
|
||
|
||
// ===== 编辑 / 新建弹窗 =====
|
||
function bindCronEditorEvents() {
|
||
// 弹窗 DOM 由 index.html 提供;此处只绑定事件
|
||
const overlay = $("cronEditOverlay");
|
||
if (!overlay) return;
|
||
cronEditOverlayEl = overlay;
|
||
|
||
$("cronEditClose")?.addEventListener("click", () => closeCronEditor());
|
||
$("cronEditCancel")?.addEventListener("click", () => closeCronEditor());
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) closeCronEditor();
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && overlay && !overlay.hidden) closeCronEditor();
|
||
});
|
||
|
||
// 触发类型分段切换
|
||
overlay.querySelectorAll(".cron-trigger-type-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
overlay.querySelectorAll(".cron-trigger-type-btn").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
const t = btn.dataset.triggerType;
|
||
toggleTriggerFields(t);
|
||
});
|
||
});
|
||
|
||
// 构建闹钟式选择器(cron 类型)与主题化日期时间选择器(once 类型)
|
||
_alarmWeekdayPicker = buildWeekdaySelector($("cronWeekdayRow"), [1, 2, 3, 4, 5]);
|
||
_alarmTimePicker = buildTimePicker($("cronTimePicker"), 9, 0);
|
||
_onceDatePicker = buildDatePicker($("cronDatePicker"), null);
|
||
_onceTimePicker = buildTimePicker($("cronOnceTimePicker"), 9, 0);
|
||
|
||
// 高级模式切换:闹钟 ⇄ 手动 cron 输入
|
||
$("cronAdvancedToggle")?.addEventListener("click", () => {
|
||
const picker = $("cronAlarmPicker");
|
||
const raw = $("cronAdvancedRaw");
|
||
if (!picker || !raw) return;
|
||
const mode = picker.dataset.mode;
|
||
if (mode === "alarm") {
|
||
// 切到高级:把当前闹钟状态生成的 cron 填入 raw 输入框
|
||
if (_alarmWeekdayPicker && _alarmTimePicker) {
|
||
$("cronEditCron").value = alarmToCron(
|
||
_alarmWeekdayPicker.getWeekdays(),
|
||
_alarmTimePicker.getHour(),
|
||
_alarmTimePicker.getMinute(),
|
||
);
|
||
}
|
||
raw.hidden = false;
|
||
picker.dataset.mode = "advanced";
|
||
$("cronAdvancedToggle").textContent = "返回闹钟模式";
|
||
} else {
|
||
// 切回闹钟:尝试解析 raw cron,成功则同步到闹钟 UI
|
||
const cron = ($("cronEditCron").value || "").trim();
|
||
const alarm = cronToAlarm(cron);
|
||
if (alarm) {
|
||
_alarmWeekdayPicker?.setWeekdays(alarm.weekdays);
|
||
_alarmTimePicker?.setHour(alarm.hour);
|
||
_alarmTimePicker?.setMinute(alarm.minute);
|
||
}
|
||
raw.hidden = true;
|
||
picker.dataset.mode = "alarm";
|
||
$("cronAdvancedToggle").textContent = "高级:手动输入 cron 表达式";
|
||
}
|
||
});
|
||
|
||
// 启用开关
|
||
$("cronEnabledSwitch")?.addEventListener("click", () => {
|
||
const sw = $("cronEnabledSwitch");
|
||
const on = sw.dataset.on !== "true";
|
||
sw.dataset.on = on ? "true" : "false";
|
||
});
|
||
|
||
// 保存
|
||
$("cronEditSave")?.addEventListener("click", () => saveCronTask());
|
||
}
|
||
|
||
function toggleTriggerFields(type) {
|
||
const overlay = cronEditOverlayEl;
|
||
if (!overlay) return;
|
||
overlay.querySelector("[data-trigger-field='cron']").hidden = type !== "cron";
|
||
overlay.querySelector("[data-trigger-field='interval']").hidden = type !== "interval";
|
||
overlay.querySelector("[data-trigger-field='once']").hidden = type !== "once";
|
||
}
|
||
|
||
// 初始化 cron 闹钟选择器(编辑/新建共用)
|
||
// 能解析为闹钟式 → 闹钟模式;无法解析但有 cron → 高级模式;无 cron → 默认值
|
||
function initCronAlarmPicker(cronExpr, defaultWeekdays, defaultHour, defaultMinute) {
|
||
const picker = $("cronAlarmPicker");
|
||
const raw = $("cronAdvancedRaw");
|
||
const toggleBtn = $("cronAdvancedToggle");
|
||
const cronInput = $("cronEditCron");
|
||
if (!picker) return;
|
||
const defWd = defaultWeekdays || [1, 2, 3, 4, 5];
|
||
const defH = defaultHour ?? 9;
|
||
const defM = defaultMinute ?? 0;
|
||
const alarm = cronToAlarm(cronExpr);
|
||
if (alarm) {
|
||
_alarmWeekdayPicker?.setWeekdays(alarm.weekdays);
|
||
_alarmTimePicker?.setHour(alarm.hour);
|
||
_alarmTimePicker?.setMinute(alarm.minute);
|
||
if (cronInput) cronInput.value = cronExpr;
|
||
if (raw) raw.hidden = true;
|
||
picker.dataset.mode = "alarm";
|
||
if (toggleBtn) toggleBtn.textContent = "高级:手动输入 cron 表达式";
|
||
} else if (cronExpr) {
|
||
// 无法解析为闹钟式:进入高级模式,保留原始 cron
|
||
if (cronInput) cronInput.value = cronExpr;
|
||
if (raw) raw.hidden = false;
|
||
picker.dataset.mode = "advanced";
|
||
if (toggleBtn) toggleBtn.textContent = "返回闹钟模式";
|
||
_alarmWeekdayPicker?.setWeekdays(defWd);
|
||
_alarmTimePicker?.setHour(defH);
|
||
_alarmTimePicker?.setMinute(defM);
|
||
} else {
|
||
_alarmWeekdayPicker?.setWeekdays(defWd);
|
||
_alarmTimePicker?.setHour(defH);
|
||
_alarmTimePicker?.setMinute(defM);
|
||
if (cronInput) cronInput.value = alarmToCron(defWd, defH, defM);
|
||
if (raw) raw.hidden = true;
|
||
picker.dataset.mode = "alarm";
|
||
if (toggleBtn) toggleBtn.textContent = "高级:手动输入 cron 表达式";
|
||
}
|
||
}
|
||
|
||
// 初始化 once 日期+时间选择器
|
||
function initOncePicker(runAt) {
|
||
if (runAt) {
|
||
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/.exec(runAt);
|
||
if (m) {
|
||
_onceDatePicker?.setSelected(m[1]);
|
||
_onceTimePicker?.setHour(parseInt(m[2], 10));
|
||
_onceTimePicker?.setMinute(parseInt(m[3], 10));
|
||
return;
|
||
}
|
||
}
|
||
// 新建:默认 1 小时后(避免一创建即过期)
|
||
const future = new Date(Date.now() + 60 * 60 * 1000);
|
||
const dateStr = `${future.getFullYear()}-${String(future.getMonth() + 1).padStart(2, "0")}-${String(future.getDate()).padStart(2, "0")}`;
|
||
_onceDatePicker?.setSelected(dateStr);
|
||
_onceTimePicker?.setHour(future.getHours());
|
||
_onceTimePicker?.setMinute(0);
|
||
}
|
||
|
||
export function openCronEditor(taskId) {
|
||
const overlay = $("cronEditOverlay");
|
||
if (!overlay) return;
|
||
state._cronEditingTaskId = taskId || "";
|
||
$("cronEditTitle").textContent = taskId ? "编辑定时任务" : "新建定时任务";
|
||
|
||
// 重置表单
|
||
const setVal = (id, v) => { const el = $(id); if (el) el.value = v ?? ""; };
|
||
|
||
if (taskId) {
|
||
// 编辑模式:加载现有任务数据
|
||
const task = state.cronTasks.find((t) => t.task_id === taskId);
|
||
if (!task) { showToast("任务不存在"); return; }
|
||
setVal("cronEditName", task.name);
|
||
setVal("cronEditPrompt", task.prompt);
|
||
setVal("cronEditTimeout", task.timeout_seconds);
|
||
const trig = task.trigger || { type: "cron" };
|
||
setVal("cronEditInterval", trig.interval_seconds);
|
||
// 闹钟式选择器初始化(cron 类型)
|
||
initCronAlarmPicker(trig.cron, [1, 2, 3, 4, 5], 9, 0);
|
||
// 主题化日期时间选择器初始化(once 类型)
|
||
initOncePicker(trig.run_at);
|
||
// 触发类型按钮
|
||
overlay.querySelectorAll(".cron-trigger-type-btn").forEach((b) => {
|
||
b.classList.toggle("active", b.dataset.triggerType === trig.type);
|
||
});
|
||
toggleTriggerFields(trig.type);
|
||
// 启用开关
|
||
const sw = $("cronEnabledSwitch");
|
||
if (sw) sw.dataset.on = task.enabled ? "true" : "false";
|
||
} else {
|
||
// 新建模式:默认值
|
||
setVal("cronEditName", "");
|
||
setVal("cronEditPrompt", "");
|
||
setVal("cronEditTimeout", 300);
|
||
setVal("cronEditInterval", 3600);
|
||
// 闹钟式默认:周一至周五 09:00
|
||
initCronAlarmPicker("", [1, 2, 3, 4, 5], 9, 0);
|
||
// once 默认 1 小时后
|
||
initOncePicker("");
|
||
overlay.querySelectorAll(".cron-trigger-type-btn").forEach((b) => {
|
||
b.classList.toggle("active", b.dataset.triggerType === "cron");
|
||
});
|
||
toggleTriggerFields("cron");
|
||
const sw = $("cronEnabledSwitch");
|
||
if (sw) sw.dataset.on = "true";
|
||
}
|
||
|
||
overlay.hidden = false;
|
||
}
|
||
|
||
function closeCronEditor() {
|
||
const overlay = $("cronEditOverlay");
|
||
if (overlay) overlay.hidden = true;
|
||
state._cronEditingTaskId = "";
|
||
}
|
||
|
||
async function saveCronTask() {
|
||
const overlay = $("cronEditOverlay");
|
||
if (!overlay) return;
|
||
const getVal = (id) => { const el = $(id); return el ? el.value.trim() : ""; };
|
||
|
||
const name = getVal("cronEditName");
|
||
const prompt = getVal("cronEditPrompt");
|
||
if (!name) { showToast("请填写任务名称"); return; }
|
||
if (!prompt) { showToast("请填写任务提示词"); return; }
|
||
|
||
const activeBtn = overlay.querySelector(".cron-trigger-type-btn.active");
|
||
const triggerType = activeBtn ? activeBtn.dataset.triggerType : "cron";
|
||
const trigger = { type: triggerType };
|
||
if (triggerType === "cron") {
|
||
const picker = $("cronAlarmPicker");
|
||
const mode = picker?.dataset.mode;
|
||
if (mode === "alarm" && _alarmWeekdayPicker && _alarmTimePicker) {
|
||
const weekdays = _alarmWeekdayPicker.getWeekdays();
|
||
if (weekdays.length === 0) { showToast("请至少选择一个星期"); return; }
|
||
trigger.cron = alarmToCron(weekdays, _alarmTimePicker.getHour(), _alarmTimePicker.getMinute());
|
||
} else {
|
||
// 高级模式:读取手动输入的 cron 表达式
|
||
trigger.cron = getVal("cronEditCron");
|
||
if (!trigger.cron) { showToast("请填写 cron 表达式"); return; }
|
||
}
|
||
} else if (triggerType === "interval") {
|
||
trigger.interval_seconds = parseInt(getVal("cronEditInterval"), 10) || 0;
|
||
if (trigger.interval_seconds <= 0) { showToast("请填写大于 0 的间隔秒数"); return; }
|
||
} else if (triggerType === "once") {
|
||
// 从主题化日期 + 时间选择器组装 run_at(ISO 字符串)
|
||
const dateStr = _onceDatePicker ? _onceDatePicker.getSelected() : "";
|
||
if (!dateStr) { showToast("请选择触发日期"); return; }
|
||
const hour = _onceTimePicker ? _onceTimePicker.getHour() : 0;
|
||
const minute = _onceTimePicker ? _onceTimePicker.getMinute() : 0;
|
||
trigger.run_at = `${dateStr}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
|
||
// 同步到隐藏 input(便于其他逻辑读取)
|
||
const runAtInput = $("cronEditRunAt");
|
||
if (runAtInput) runAtInput.value = trigger.run_at;
|
||
}
|
||
|
||
const timeout_seconds = parseInt(getVal("cronEditTimeout"), 10) || 300;
|
||
const enabled = $("cronEnabledSwitch")?.dataset.on === "true";
|
||
|
||
const payload = { name, prompt, trigger, timeout_seconds, enabled };
|
||
|
||
// 提交前检测时段冲突:候选任务时间点前后一个时间段内是否已有其他任务
|
||
try {
|
||
await refreshPeriodMinutes();
|
||
const conflictBody = { trigger };
|
||
const editId = state._cronEditingTaskId;
|
||
if (editId) conflictBody.exclude_task_id = editId;
|
||
const conflictRes = await api("/api/cron/check-conflict", { method: "POST", body: JSON.stringify(conflictBody) });
|
||
if (conflictRes && conflictRes.conflict) {
|
||
const names = (conflictRes.conflict_with && conflictRes.conflict_with.length)
|
||
? conflictRes.conflict_with.join("、") : "其他任务";
|
||
await showAlert(`时间段内已有其他任务(${names}),需重新设置时间点。\n(当前时间段长度:${conflictRes.period_minutes} 分钟)`);
|
||
return;
|
||
}
|
||
} catch (_) {
|
||
// 冲突检测失败不阻断保存(降级为允许保存)
|
||
}
|
||
|
||
const saveBtn = $("cronEditSave");
|
||
if (saveBtn) saveBtn.disabled = true;
|
||
|
||
try {
|
||
const taskId = state._cronEditingTaskId;
|
||
if (taskId) {
|
||
await api(`/api/cron/${encodeURIComponent(taskId)}`, { method: "PUT", body: JSON.stringify(payload) });
|
||
showToast("已更新定时任务");
|
||
} else {
|
||
await api("/api/cron", { method: "POST", body: JSON.stringify(payload) });
|
||
showToast("已创建定时任务");
|
||
}
|
||
closeCronEditor();
|
||
await renderCronTasks();
|
||
// 若编辑的是当前选中任务,刷新 banner
|
||
if (taskId && taskId === state.activeCronTaskId) await refreshCronBanner();
|
||
} catch (e) {
|
||
showToast("保存失败: " + (e.message || e));
|
||
} finally {
|
||
if (saveBtn) saveBtn.disabled = false;
|
||
}
|
||
}
|