mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-04-21 10:16:32 +02:00
2388 lines
100 KiB
JavaScript
2388 lines
100 KiB
JavaScript
// 任务管理页面功能
|
||
function _t(key, opts) {
|
||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||
}
|
||
|
||
/** 插值不转 HTML 实体(避免日期里的 / 变成 / 再被 escapeHtml 成乱码) */
|
||
function _tPlain(key, opts) {
|
||
if (typeof window.t !== 'function') return key;
|
||
const base = opts && typeof opts === 'object' ? opts : {};
|
||
const interp = base.interpolation && typeof base.interpolation === 'object' ? base.interpolation : {};
|
||
return window.t(key, {
|
||
...base,
|
||
interpolation: { escapeValue: false, ...interp }
|
||
});
|
||
}
|
||
|
||
/** 批量队列 agentMode 展示文案(与对话模式命名一致) */
|
||
function batchQueueAgentModeLabel(mode) {
|
||
const m = String(mode || 'single').toLowerCase();
|
||
if (m === 'single') return _t('batchImportModal.agentModeSingle');
|
||
if (m === 'multi' || m === 'deep') return _t('chat.agentModeDeep');
|
||
if (m === 'plan_execute') return _t('chat.agentModePlanExecuteLabel');
|
||
if (m === 'supervisor') return _t('chat.agentModeSupervisorLabel');
|
||
return _t('batchImportModal.agentModeSingle');
|
||
}
|
||
|
||
/** Cron 队列在「本轮 completed」等状态下的展示文案(底层 status 不变,仅 UI 强调循环调度) */
|
||
function getBatchQueueStatusPresentation(queue) {
|
||
const map = {
|
||
pending: { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||
running: { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||
paused: { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||
completed: { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||
cancelled: { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||
};
|
||
const base = map[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||
const cronOn = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||
const nextStr = queue.nextRunAt ? new Date(queue.nextRunAt).toLocaleString() : '';
|
||
const empty = { sublabel: null, progressNote: null, callout: null };
|
||
|
||
if (cronOn && queue.status === 'completed') {
|
||
return {
|
||
text: _t('tasks.statusCronCycleIdle'),
|
||
class: 'batch-queue-status-cron-cycle',
|
||
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||
progressNote: _t('tasks.cronRoundDoneProgressHint'),
|
||
callout: _t('tasks.cronRecurringCallout')
|
||
};
|
||
}
|
||
if (cronOn && queue.status === 'running') {
|
||
return {
|
||
text: _t('tasks.statusCronRunning'),
|
||
class: 'batch-queue-status-running batch-queue-cron-active',
|
||
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||
progressNote: _t('tasks.cronRunningProgressHint'),
|
||
callout: null
|
||
};
|
||
}
|
||
if (cronOn && queue.status === 'pending' && nextStr) {
|
||
return {
|
||
...base,
|
||
...empty,
|
||
sublabel: _tPlain('tasks.cronPendingScheduled', { time: nextStr }),
|
||
progressNote: _t('tasks.cronPendingProgressNote')
|
||
};
|
||
}
|
||
return { ...base, ...empty };
|
||
}
|
||
|
||
/** 队列是否处于「可改子任务列表/文案」的空闲态(与后端 batch_task_manager.queueAllowsTaskListMutationLocked 对齐) */
|
||
function batchQueueAllowsSubtaskMutation(queue) {
|
||
if (!queue) return false;
|
||
if (queue.status === 'running') return false;
|
||
const hasRunningSubtask = Array.isArray(queue.tasks) && queue.tasks.some(t => t && t.status === 'running');
|
||
if (hasRunningSubtask) return false;
|
||
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||
}
|
||
|
||
// HTML转义函数(如果未定义)
|
||
if (typeof escapeHtml === 'undefined') {
|
||
function escapeHtml(text) {
|
||
if (text == null) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
}
|
||
|
||
// 任务管理状态
|
||
const tasksState = {
|
||
allTasks: [],
|
||
filteredTasks: [],
|
||
selectedTasks: new Set(),
|
||
autoRefresh: true,
|
||
refreshInterval: null,
|
||
durationUpdateInterval: null,
|
||
completedTasksHistory: [], // 保存最近完成的任务历史
|
||
showHistory: true // 是否显示历史记录
|
||
};
|
||
|
||
// 从localStorage加载已完成任务历史
|
||
function loadCompletedTasksHistory() {
|
||
try {
|
||
const saved = localStorage.getItem('tasks-completed-history');
|
||
if (saved) {
|
||
const history = JSON.parse(saved);
|
||
// 只保留最近24小时内完成的任务
|
||
const now = Date.now();
|
||
const oneDayAgo = now - 24 * 60 * 60 * 1000;
|
||
tasksState.completedTasksHistory = history.filter(task => {
|
||
const completedTime = task.completedAt || task.startedAt;
|
||
return completedTime && new Date(completedTime).getTime() > oneDayAgo;
|
||
});
|
||
// 保存清理后的历史
|
||
saveCompletedTasksHistory();
|
||
}
|
||
} catch (error) {
|
||
console.error('加载已完成任务历史失败:', error);
|
||
tasksState.completedTasksHistory = [];
|
||
}
|
||
}
|
||
|
||
// 保存已完成任务历史到localStorage
|
||
function saveCompletedTasksHistory() {
|
||
try {
|
||
localStorage.setItem('tasks-completed-history', JSON.stringify(tasksState.completedTasksHistory));
|
||
} catch (error) {
|
||
console.error('保存已完成任务历史失败:', error);
|
||
}
|
||
}
|
||
|
||
// 更新已完成任务历史
|
||
function updateCompletedTasksHistory(currentTasks) {
|
||
// 保存当前所有任务作为快照(用于下次比较)
|
||
const currentTaskIds = new Set(currentTasks.map(t => t.conversationId));
|
||
|
||
// 如果是首次加载,只需要保存当前任务快照
|
||
if (tasksState.allTasks.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const previousTaskIds = new Set(tasksState.allTasks.map(t => t.conversationId));
|
||
|
||
// 找出刚完成的任务(之前存在但现在不存在的)
|
||
// 只要任务从列表中消失了,就认为它已完成
|
||
const justCompleted = tasksState.allTasks.filter(task => {
|
||
return previousTaskIds.has(task.conversationId) && !currentTaskIds.has(task.conversationId);
|
||
});
|
||
|
||
// 将刚完成的任务添加到历史中
|
||
justCompleted.forEach(task => {
|
||
// 检查是否已存在(避免重复添加)
|
||
const exists = tasksState.completedTasksHistory.some(t => t.conversationId === task.conversationId);
|
||
if (!exists) {
|
||
// 如果任务状态不是最终状态,标记为completed
|
||
const finalStatus = ['completed', 'failed', 'timeout', 'cancelled'].includes(task.status)
|
||
? task.status
|
||
: 'completed';
|
||
|
||
tasksState.completedTasksHistory.push({
|
||
conversationId: task.conversationId,
|
||
message: task.message || '未命名任务',
|
||
startedAt: task.startedAt,
|
||
status: finalStatus,
|
||
completedAt: new Date().toISOString()
|
||
});
|
||
}
|
||
});
|
||
|
||
// 限制历史记录数量(最多保留50条)
|
||
if (tasksState.completedTasksHistory.length > 50) {
|
||
tasksState.completedTasksHistory = tasksState.completedTasksHistory
|
||
.sort((a, b) => new Date(b.completedAt || b.startedAt) - new Date(a.completedAt || a.startedAt))
|
||
.slice(0, 50);
|
||
}
|
||
|
||
saveCompletedTasksHistory();
|
||
}
|
||
|
||
// 加载任务列表
|
||
async function loadTasks() {
|
||
const listContainer = document.getElementById('tasks-list');
|
||
if (!listContainer) return;
|
||
|
||
listContainer.innerHTML = '<div class="loading-spinner">' + _t('tasks.loadingTasks') + '</div>';
|
||
|
||
try {
|
||
// 并行加载运行中的任务和已完成的任务历史
|
||
const [activeResponse, completedResponse] = await Promise.allSettled([
|
||
apiFetch('/api/agent-loop/tasks'),
|
||
apiFetch('/api/agent-loop/tasks/completed').catch(() => null) // 如果API不存在,返回null
|
||
]);
|
||
|
||
// 处理运行中的任务
|
||
if (activeResponse.status === 'rejected' || !activeResponse.value || !activeResponse.value.ok) {
|
||
throw new Error(_t('tasks.loadTaskListFailed'));
|
||
}
|
||
|
||
const activeResult = await activeResponse.value.json();
|
||
const activeTasks = activeResult.tasks || [];
|
||
|
||
// 加载已完成任务历史(如果API可用)
|
||
let completedTasks = [];
|
||
if (completedResponse.status === 'fulfilled' && completedResponse.value && completedResponse.value.ok) {
|
||
try {
|
||
const completedResult = await completedResponse.value.json();
|
||
completedTasks = completedResult.tasks || [];
|
||
} catch (e) {
|
||
console.warn('解析已完成任务历史失败:', e);
|
||
}
|
||
}
|
||
|
||
// 保存所有任务
|
||
tasksState.allTasks = activeTasks;
|
||
|
||
// 更新已完成任务历史(从后端API获取)
|
||
if (completedTasks.length > 0) {
|
||
// 合并后端历史记录和本地历史记录(去重)
|
||
const backendTaskIds = new Set(completedTasks.map(t => t.conversationId));
|
||
const localHistory = tasksState.completedTasksHistory.filter(t =>
|
||
!backendTaskIds.has(t.conversationId)
|
||
);
|
||
|
||
// 后端的历史记录优先,然后添加本地独有的
|
||
tasksState.completedTasksHistory = [
|
||
...completedTasks.map(t => ({
|
||
conversationId: t.conversationId,
|
||
message: t.message || '未命名任务',
|
||
startedAt: t.startedAt,
|
||
status: t.status || 'completed',
|
||
completedAt: t.completedAt || new Date().toISOString()
|
||
})),
|
||
...localHistory
|
||
];
|
||
|
||
// 限制历史记录数量
|
||
if (tasksState.completedTasksHistory.length > 50) {
|
||
tasksState.completedTasksHistory = tasksState.completedTasksHistory
|
||
.sort((a, b) => new Date(b.completedAt || b.startedAt) - new Date(a.completedAt || a.startedAt))
|
||
.slice(0, 50);
|
||
}
|
||
|
||
saveCompletedTasksHistory();
|
||
} else {
|
||
// 如果后端API不可用,仍然使用前端逻辑更新历史
|
||
updateCompletedTasksHistory(activeTasks);
|
||
}
|
||
|
||
updateTaskStats(activeTasks);
|
||
filterAndSortTasks();
|
||
startDurationUpdates();
|
||
} catch (error) {
|
||
console.error('加载任务失败:', error);
|
||
listContainer.innerHTML = `
|
||
<div class="tasks-empty">
|
||
<p>${_t('tasks.loadFailedRetry')}: ${escapeHtml(error.message)}</p>
|
||
<button class="btn-secondary" onclick="loadTasks()">${_t('tasks.retry')}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 更新任务统计
|
||
function updateTaskStats(tasks) {
|
||
const stats = {
|
||
running: 0,
|
||
cancelling: 0,
|
||
completed: 0,
|
||
failed: 0,
|
||
timeout: 0,
|
||
cancelled: 0,
|
||
total: tasks.length
|
||
};
|
||
|
||
tasks.forEach(task => {
|
||
if (task.status === 'running') {
|
||
stats.running++;
|
||
} else if (task.status === 'cancelling') {
|
||
stats.cancelling++;
|
||
} else if (task.status === 'completed') {
|
||
stats.completed++;
|
||
} else if (task.status === 'failed') {
|
||
stats.failed++;
|
||
} else if (task.status === 'timeout') {
|
||
stats.timeout++;
|
||
} else if (task.status === 'cancelled') {
|
||
stats.cancelled++;
|
||
}
|
||
});
|
||
|
||
const statRunning = document.getElementById('stat-running');
|
||
const statCancelling = document.getElementById('stat-cancelling');
|
||
const statCompleted = document.getElementById('stat-completed');
|
||
const statTotal = document.getElementById('stat-total');
|
||
|
||
if (statRunning) statRunning.textContent = stats.running;
|
||
if (statCancelling) statCancelling.textContent = stats.cancelling;
|
||
if (statCompleted) statCompleted.textContent = stats.completed;
|
||
if (statTotal) statTotal.textContent = stats.total;
|
||
}
|
||
|
||
// 筛选任务
|
||
function filterTasks() {
|
||
filterAndSortTasks();
|
||
}
|
||
|
||
// 排序任务
|
||
function sortTasks() {
|
||
filterAndSortTasks();
|
||
}
|
||
|
||
// 筛选和排序任务
|
||
function filterAndSortTasks() {
|
||
const statusFilter = document.getElementById('tasks-status-filter')?.value || 'all';
|
||
const sortBy = document.getElementById('tasks-sort-by')?.value || 'time-desc';
|
||
|
||
// 合并当前任务和历史任务
|
||
let allTasks = [...tasksState.allTasks];
|
||
|
||
// 如果显示历史记录,添加历史任务
|
||
if (tasksState.showHistory) {
|
||
const historyTasks = tasksState.completedTasksHistory
|
||
.filter(ht => !tasksState.allTasks.some(t => t.conversationId === ht.conversationId))
|
||
.map(ht => ({ ...ht, isHistory: true }));
|
||
allTasks = [...allTasks, ...historyTasks];
|
||
}
|
||
|
||
// 筛选
|
||
let filtered = allTasks;
|
||
if (statusFilter === 'active') {
|
||
// 仅运行中的任务(不包括历史)
|
||
filtered = tasksState.allTasks.filter(task =>
|
||
task.status === 'running' || task.status === 'cancelling'
|
||
);
|
||
} else if (statusFilter === 'history') {
|
||
// 仅历史记录
|
||
filtered = allTasks.filter(task => task.isHistory);
|
||
} else if (statusFilter !== 'all') {
|
||
filtered = allTasks.filter(task => task.status === statusFilter);
|
||
}
|
||
|
||
// 排序
|
||
filtered.sort((a, b) => {
|
||
const aTime = new Date(a.completedAt || a.startedAt);
|
||
const bTime = new Date(b.completedAt || b.startedAt);
|
||
|
||
switch (sortBy) {
|
||
case 'time-asc':
|
||
return aTime - bTime;
|
||
case 'time-desc':
|
||
return bTime - aTime;
|
||
case 'status':
|
||
return (a.status || '').localeCompare(b.status || '');
|
||
case 'message':
|
||
return (a.message || '').localeCompare(b.message || '');
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
|
||
tasksState.filteredTasks = filtered;
|
||
renderTasks(filtered);
|
||
updateBatchActions();
|
||
}
|
||
|
||
// 切换显示历史记录
|
||
function toggleShowHistory(show) {
|
||
tasksState.showHistory = show;
|
||
localStorage.setItem('tasks-show-history', show ? 'true' : 'false');
|
||
filterAndSortTasks();
|
||
}
|
||
|
||
// 计算执行时长
|
||
function calculateDuration(startedAt) {
|
||
if (!startedAt) return _t('tasks.unknown');
|
||
const start = new Date(startedAt);
|
||
const now = new Date();
|
||
const diff = Math.floor((now - start) / 1000);
|
||
|
||
if (diff < 60) {
|
||
return diff + _t('tasks.durationSeconds');
|
||
} else if (diff < 3600) {
|
||
const minutes = Math.floor(diff / 60);
|
||
const seconds = diff % 60;
|
||
return minutes + _t('tasks.durationMinutes') + ' ' + seconds + _t('tasks.durationSeconds');
|
||
} else {
|
||
const hours = Math.floor(diff / 3600);
|
||
const minutes = Math.floor((diff % 3600) / 60);
|
||
return hours + _t('tasks.durationHours') + ' ' + minutes + _t('tasks.durationMinutes');
|
||
}
|
||
}
|
||
|
||
// 开始时长更新
|
||
function startDurationUpdates() {
|
||
// 清除旧的定时器
|
||
if (tasksState.durationUpdateInterval) {
|
||
clearInterval(tasksState.durationUpdateInterval);
|
||
}
|
||
|
||
// 每秒更新一次执行时长
|
||
tasksState.durationUpdateInterval = setInterval(() => {
|
||
updateTaskDurations();
|
||
}, 1000);
|
||
}
|
||
|
||
// 更新任务执行时长显示
|
||
function updateTaskDurations() {
|
||
const taskItems = document.querySelectorAll('.task-item[data-task-id]');
|
||
taskItems.forEach(item => {
|
||
const startedAt = item.dataset.startedAt;
|
||
const status = item.dataset.status;
|
||
const durationEl = item.querySelector('.task-duration');
|
||
|
||
if (durationEl && startedAt && (status === 'running' || status === 'cancelling')) {
|
||
durationEl.textContent = calculateDuration(startedAt);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 渲染任务列表
|
||
function renderTasks(tasks) {
|
||
const listContainer = document.getElementById('tasks-list');
|
||
if (!listContainer) return;
|
||
|
||
if (tasks.length === 0) {
|
||
listContainer.innerHTML = `
|
||
<div class="tasks-empty">
|
||
<p>${_t('tasks.noMatchingTasks')}</p>
|
||
${tasksState.allTasks.length === 0 && tasksState.completedTasksHistory.length > 0 ?
|
||
'<p style="margin-top: 8px; color: var(--text-muted); font-size: 0.875rem;">' + _t('tasks.historyHint') + '</p>' : ''}
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// 状态映射
|
||
const statusMap = {
|
||
'running': { text: _t('tasks.statusRunning'), class: 'task-status-running' },
|
||
'cancelling': { text: _t('tasks.statusCancelling'), class: 'task-status-cancelling' },
|
||
'failed': { text: _t('tasks.statusFailed'), class: 'task-status-failed' },
|
||
'timeout': { text: _t('tasks.statusTimeout'), class: 'task-status-timeout' },
|
||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'task-status-cancelled' },
|
||
'completed': { text: _t('tasks.statusCompleted'), class: 'task-status-completed' }
|
||
};
|
||
|
||
// 分离当前任务和历史任务
|
||
const activeTasks = tasks.filter(t => !t.isHistory);
|
||
const historyTasks = tasks.filter(t => t.isHistory);
|
||
|
||
let html = '';
|
||
|
||
// 渲染当前任务
|
||
if (activeTasks.length > 0) {
|
||
html += activeTasks.map(task => renderTaskItem(task, statusMap)).join('');
|
||
}
|
||
|
||
// 渲染历史任务
|
||
if (historyTasks.length > 0) {
|
||
html += `<div class="tasks-history-section">
|
||
<div class="tasks-history-header">
|
||
<span class="tasks-history-title">📜 ` + _t('tasks.recentCompletedTasks') + `</span>
|
||
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">` + _t('tasks.clearHistory') + `</button>
|
||
</div>
|
||
${historyTasks.map(task => renderTaskItem(task, statusMap, true)).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
listContainer.innerHTML = html;
|
||
}
|
||
|
||
// 渲染单个任务项
|
||
function renderTaskItem(task, statusMap, isHistory = false) {
|
||
const startedTime = task.startedAt ? new Date(task.startedAt) : null;
|
||
const completedTime = task.completedAt ? new Date(task.completedAt) : null;
|
||
|
||
const timeText = startedTime && !isNaN(startedTime.getTime())
|
||
? startedTime.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})
|
||
: _t('tasks.unknownTime');
|
||
|
||
const completedText = completedTime && !isNaN(completedTime.getTime())
|
||
? completedTime.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})
|
||
: '';
|
||
|
||
const status = statusMap[task.status] || { text: task.status, class: 'task-status-unknown' };
|
||
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
|
||
const canCancel = !isFinalStatus && task.status !== 'cancelling' && !isHistory;
|
||
const isSelected = tasksState.selectedTasks.has(task.conversationId);
|
||
const duration = (task.status === 'running' || task.status === 'cancelling')
|
||
? calculateDuration(task.startedAt)
|
||
: '';
|
||
|
||
return `
|
||
<div class="task-item ${isHistory ? 'task-item-history' : ''}" data-task-id="${task.conversationId}" data-started-at="${task.startedAt}" data-status="${task.status}">
|
||
<div class="task-header">
|
||
<div class="task-info">
|
||
${canCancel ? `
|
||
<label class="task-checkbox">
|
||
<input type="checkbox" ${isSelected ? 'checked' : ''}
|
||
onchange="toggleTaskSelection('${task.conversationId}', this.checked)">
|
||
</label>
|
||
` : '<div class="task-checkbox-placeholder"></div>'}
|
||
<span class="task-status ${status.class}">${status.text}</span>
|
||
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
||
<span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
|
||
</div>
|
||
<div class="task-actions">
|
||
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
||
<span class="task-time" title="${isHistory && completedText ? _t('tasks.completedAt') : _t('tasks.startedAt')}">
|
||
${isHistory && completedText ? completedText : timeText}
|
||
</span>
|
||
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">` + _t('tasks.cancelTask') + `</button>` : ''}
|
||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||
</div>
|
||
</div>
|
||
${task.conversationId ? `
|
||
<div class="task-details">
|
||
<span class="task-id-label">` + _t('tasks.conversationIdLabel') + `:</span>
|
||
<span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 清空任务历史
|
||
function clearTasksHistory() {
|
||
if (!confirm(_t('tasks.clearHistoryConfirm'))) {
|
||
return;
|
||
}
|
||
tasksState.completedTasksHistory = [];
|
||
saveCompletedTasksHistory();
|
||
filterAndSortTasks();
|
||
}
|
||
|
||
// 切换任务选择
|
||
function toggleTaskSelection(conversationId, selected) {
|
||
if (selected) {
|
||
tasksState.selectedTasks.add(conversationId);
|
||
} else {
|
||
tasksState.selectedTasks.delete(conversationId);
|
||
}
|
||
updateBatchActions();
|
||
}
|
||
|
||
// 更新批量操作UI
|
||
function updateBatchActions() {
|
||
const batchActions = document.getElementById('tasks-batch-actions');
|
||
const selectedCount = document.getElementById('tasks-selected-count');
|
||
|
||
if (!batchActions || !selectedCount) return;
|
||
|
||
const count = tasksState.selectedTasks.size;
|
||
if (count > 0) {
|
||
batchActions.style.display = 'flex';
|
||
selectedCount.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: count }) : `已选择 ${count} 项`;
|
||
} else {
|
||
batchActions.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 清除任务选择
|
||
function clearTaskSelection() {
|
||
tasksState.selectedTasks.clear();
|
||
updateBatchActions();
|
||
// 重新渲染以更新复选框状态
|
||
filterAndSortTasks();
|
||
}
|
||
|
||
// 批量取消任务
|
||
async function batchCancelTasks() {
|
||
const selected = Array.from(tasksState.selectedTasks);
|
||
if (selected.length === 0) return;
|
||
|
||
if (!confirm(_t('tasks.confirmCancelTasks', { n: selected.length }))) {
|
||
return;
|
||
}
|
||
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const conversationId of selected) {
|
||
try {
|
||
const response = await apiFetch('/api/agent-loop/cancel', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ conversationId }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
successCount++;
|
||
} else {
|
||
failCount++;
|
||
}
|
||
} catch (error) {
|
||
console.error('取消任务失败:', conversationId, error);
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
// 清除选择
|
||
clearTaskSelection();
|
||
|
||
// 刷新任务列表
|
||
await loadTasks();
|
||
|
||
// 显示结果
|
||
if (failCount > 0) {
|
||
alert(_t('tasks.batchCancelResultPartial', { success: successCount, fail: failCount }));
|
||
} else {
|
||
alert(_t('tasks.batchCancelResultSuccess', { n: successCount }));
|
||
}
|
||
}
|
||
|
||
// 复制任务ID
|
||
function copyTaskId(conversationId) {
|
||
navigator.clipboard.writeText(conversationId).then(() => {
|
||
// 显示复制成功提示
|
||
const tooltip = document.createElement('div');
|
||
tooltip.textContent = _t('tasks.copiedToast');
|
||
tooltip.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 8px 16px; border-radius: 4px; z-index: 10000;';
|
||
document.body.appendChild(tooltip);
|
||
setTimeout(() => tooltip.remove(), 1000);
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
});
|
||
}
|
||
|
||
// 取消任务
|
||
async function cancelTask(conversationId, button) {
|
||
if (!conversationId) return;
|
||
|
||
const originalText = button.textContent;
|
||
button.disabled = true;
|
||
button.textContent = _t('tasks.cancelling');
|
||
|
||
try {
|
||
const response = await apiFetch('/api/agent-loop/cancel', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ conversationId }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.cancelTaskFailed'));
|
||
}
|
||
|
||
// 从选择中移除
|
||
tasksState.selectedTasks.delete(conversationId);
|
||
updateBatchActions();
|
||
|
||
// 重新加载任务列表
|
||
await loadTasks();
|
||
} catch (error) {
|
||
console.error('取消任务失败:', error);
|
||
alert(_t('tasks.cancelTaskFailed') + ': ' + error.message);
|
||
button.disabled = false;
|
||
button.textContent = originalText;
|
||
}
|
||
}
|
||
|
||
// 查看对话
|
||
function viewConversation(conversationId) {
|
||
if (!conversationId) return;
|
||
|
||
// 切换到对话页面
|
||
if (typeof switchPage === 'function') {
|
||
switchPage('chat');
|
||
// 加载并选中该对话 - 使用全局函数
|
||
setTimeout(() => {
|
||
// 尝试多种方式加载对话
|
||
if (typeof loadConversation === 'function') {
|
||
loadConversation(conversationId);
|
||
} else if (typeof window.loadConversation === 'function') {
|
||
window.loadConversation(conversationId);
|
||
} else {
|
||
// 如果函数不存在,尝试通过URL跳转
|
||
window.location.hash = `chat?conversation=${conversationId}`;
|
||
console.log('切换到对话页面,对话ID:', conversationId);
|
||
}
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
// 刷新任务列表
|
||
async function refreshTasks() {
|
||
await loadTasks();
|
||
}
|
||
|
||
// 切换自动刷新
|
||
function toggleTasksAutoRefresh(enabled) {
|
||
tasksState.autoRefresh = enabled;
|
||
|
||
// 保存到localStorage
|
||
localStorage.setItem('tasks-auto-refresh', enabled ? 'true' : 'false');
|
||
|
||
if (enabled) {
|
||
// 启动自动刷新
|
||
if (!tasksState.refreshInterval) {
|
||
tasksState.refreshInterval = setInterval(() => {
|
||
loadBatchQueues();
|
||
}, 5000);
|
||
}
|
||
} else {
|
||
// 停止自动刷新
|
||
if (tasksState.refreshInterval) {
|
||
clearInterval(tasksState.refreshInterval);
|
||
tasksState.refreshInterval = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化任务管理页面
|
||
function initTasksPage() {
|
||
// 恢复自动刷新设置
|
||
const autoRefreshCheckbox = document.getElementById('tasks-auto-refresh');
|
||
if (autoRefreshCheckbox) {
|
||
const saved = localStorage.getItem('tasks-auto-refresh');
|
||
const enabled = saved !== null ? saved === 'true' : true;
|
||
autoRefreshCheckbox.checked = enabled;
|
||
toggleTasksAutoRefresh(enabled);
|
||
} else {
|
||
toggleTasksAutoRefresh(true);
|
||
}
|
||
|
||
// 只加载批量任务队列
|
||
loadBatchQueues();
|
||
}
|
||
|
||
// 清理定时器(页面切换时调用)
|
||
function cleanupTasksPage() {
|
||
if (tasksState.refreshInterval) {
|
||
clearInterval(tasksState.refreshInterval);
|
||
tasksState.refreshInterval = null;
|
||
}
|
||
if (tasksState.durationUpdateInterval) {
|
||
clearInterval(tasksState.durationUpdateInterval);
|
||
tasksState.durationUpdateInterval = null;
|
||
}
|
||
tasksState.selectedTasks.clear();
|
||
stopBatchQueueRefresh();
|
||
}
|
||
|
||
// 导出函数供全局使用
|
||
window.loadTasks = loadTasks;
|
||
window.cancelTask = cancelTask;
|
||
window.viewConversation = viewConversation;
|
||
window.refreshTasks = refreshTasks;
|
||
window.initTasksPage = initTasksPage;
|
||
window.cleanupTasksPage = cleanupTasksPage;
|
||
window.filterTasks = filterTasks;
|
||
window.sortTasks = sortTasks;
|
||
window.toggleTaskSelection = toggleTaskSelection;
|
||
window.clearTaskSelection = clearTaskSelection;
|
||
window.batchCancelTasks = batchCancelTasks;
|
||
window.copyTaskId = copyTaskId;
|
||
window.toggleTasksAutoRefresh = toggleTasksAutoRefresh;
|
||
window.toggleShowHistory = toggleShowHistory;
|
||
window.clearTasksHistory = clearTasksHistory;
|
||
|
||
// ==================== 批量任务功能 ====================
|
||
|
||
// 批量任务状态
|
||
const batchQueuesState = {
|
||
queues: [],
|
||
currentQueueId: null,
|
||
refreshInterval: null,
|
||
// 筛选和分页状态
|
||
filterStatus: 'all', // 'all', 'pending', 'running', 'paused', 'completed', 'cancelled'
|
||
searchKeyword: '',
|
||
currentPage: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
totalPages: 1
|
||
};
|
||
|
||
// 显示新建任务模态框
|
||
async function showBatchImportModal() {
|
||
const modal = document.getElementById('batch-import-modal');
|
||
const input = document.getElementById('batch-tasks-input');
|
||
const titleInput = document.getElementById('batch-queue-title');
|
||
const roleSelect = document.getElementById('batch-queue-role');
|
||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
|
||
if (modal && input) {
|
||
input.value = '';
|
||
if (titleInput) {
|
||
titleInput.value = '';
|
||
}
|
||
// 重置角色选择为默认
|
||
if (roleSelect) {
|
||
roleSelect.value = '';
|
||
}
|
||
if (agentModeSelect) {
|
||
agentModeSelect.value = 'single';
|
||
}
|
||
if (scheduleModeSelect) {
|
||
scheduleModeSelect.value = 'manual';
|
||
}
|
||
if (cronExprInput) {
|
||
cronExprInput.value = '';
|
||
}
|
||
if (executeNowCheckbox) {
|
||
executeNowCheckbox.checked = false;
|
||
}
|
||
handleBatchScheduleModeChange();
|
||
updateBatchImportStats('');
|
||
|
||
// 加载并填充角色列表
|
||
if (roleSelect && typeof loadRoles === 'function') {
|
||
try {
|
||
const loadedRoles = await loadRoles();
|
||
// 清空现有选项(除了默认选项)
|
||
roleSelect.innerHTML = '<option value="">' + _t('batchImportModal.defaultRole') + '</option>';
|
||
|
||
// 添加已启用的角色
|
||
const sortedRoles = loadedRoles.sort((a, b) => {
|
||
if (a.name === '默认') return -1;
|
||
if (b.name === '默认') return 1;
|
||
return (a.name || '').localeCompare(b.name || '', 'zh-CN');
|
||
});
|
||
|
||
sortedRoles.forEach(role => {
|
||
if (role.name !== '默认' && role.enabled !== false) {
|
||
const option = document.createElement('option');
|
||
option.value = role.name;
|
||
option.textContent = role.name;
|
||
roleSelect.appendChild(option);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('加载角色列表失败:', error);
|
||
}
|
||
}
|
||
|
||
modal.style.display = 'block';
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
// 关闭新建任务模态框
|
||
function closeBatchImportModal() {
|
||
const modal = document.getElementById('batch-import-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function handleBatchScheduleModeChange() {
|
||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||
const cronGroup = document.getElementById('batch-queue-cron-group');
|
||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||
const isCron = scheduleModeSelect && scheduleModeSelect.value === 'cron';
|
||
if (cronGroup) {
|
||
cronGroup.style.display = isCron ? 'block' : 'none';
|
||
}
|
||
if (cronExprInput) {
|
||
if (isCron) {
|
||
cronExprInput.setAttribute('required', 'required');
|
||
} else {
|
||
cronExprInput.removeAttribute('required');
|
||
cronExprInput.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新新建任务统计
|
||
function updateBatchImportStats(text) {
|
||
const statsEl = document.getElementById('batch-import-stats');
|
||
if (!statsEl) return;
|
||
|
||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||
const count = lines.length;
|
||
|
||
if (count > 0) {
|
||
statsEl.innerHTML = '<div class="batch-import-stat">' + _t('tasks.taskCount', { count: count }) + '</div>';
|
||
statsEl.style.display = 'block';
|
||
} else {
|
||
statsEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 监听批量任务输入
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const input = document.getElementById('batch-tasks-input');
|
||
if (input) {
|
||
input.addEventListener('input', function() {
|
||
updateBatchImportStats(this.value);
|
||
});
|
||
}
|
||
});
|
||
|
||
// 创建批量任务队列
|
||
async function createBatchQueue() {
|
||
const input = document.getElementById('batch-tasks-input');
|
||
const titleInput = document.getElementById('batch-queue-title');
|
||
const roleSelect = document.getElementById('batch-queue-role');
|
||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
|
||
if (!input) return;
|
||
|
||
const text = input.value.trim();
|
||
if (!text) {
|
||
alert(_t('tasks.enterTaskPrompt'));
|
||
return;
|
||
}
|
||
|
||
// 按行分割任务
|
||
const tasks = text.split('\n').map(line => line.trim()).filter(line => line !== '');
|
||
if (tasks.length === 0) {
|
||
alert(_t('tasks.noValidTask'));
|
||
return;
|
||
}
|
||
|
||
// 获取标题(可选)
|
||
const title = titleInput ? titleInput.value.trim() : '';
|
||
|
||
// 获取角色(可选,空字符串表示默认角色)
|
||
const role = roleSelect ? roleSelect.value || '' : '';
|
||
const rawMode = agentModeSelect ? agentModeSelect.value : 'single';
|
||
const agentMode = ['single', 'deep', 'plan_execute', 'supervisor'].indexOf(rawMode) >= 0 ? rawMode : 'single';
|
||
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
|
||
const cronExpr = cronExprInput ? cronExprInput.value.trim() : '';
|
||
const executeNow = executeNowCheckbox ? !!executeNowCheckbox.checked : false;
|
||
if (scheduleMode === 'cron' && !cronExpr) {
|
||
alert(_t('batchImportModal.cronExprRequired'));
|
||
return;
|
||
}
|
||
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
|
||
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch('/api/batch-tasks', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.createBatchQueueFailed'));
|
||
}
|
||
|
||
const result = await response.json();
|
||
closeBatchImportModal();
|
||
|
||
// 显示队列详情
|
||
showBatchQueueDetail(result.queueId);
|
||
|
||
// 刷新批量队列列表
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('创建批量任务队列失败:', error);
|
||
alert(_t('tasks.createBatchQueueFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 获取角色图标(辅助函数)
|
||
function getRoleIconForDisplay(roleName, rolesList) {
|
||
if (!roleName || roleName === '') {
|
||
return '🔵'; // 默认角色图标
|
||
}
|
||
|
||
if (Array.isArray(rolesList) && rolesList.length > 0) {
|
||
const role = rolesList.find(r => r.name === roleName);
|
||
if (role && role.icon) {
|
||
let icon = role.icon;
|
||
// 检查是否是 Unicode 转义格式(可能包含引号)
|
||
const unicodeMatch = icon.match(/^"?\\U([0-9A-F]{8})"?$/i);
|
||
if (unicodeMatch) {
|
||
try {
|
||
const codePoint = parseInt(unicodeMatch[1], 16);
|
||
icon = String.fromCodePoint(codePoint);
|
||
} catch (e) {
|
||
// 转换失败,使用默认图标
|
||
console.warn('转换 icon Unicode 转义失败:', icon, e);
|
||
return '👤';
|
||
}
|
||
}
|
||
return icon;
|
||
}
|
||
}
|
||
return '👤'; // 默认图标
|
||
}
|
||
|
||
// 加载批量任务队列列表
|
||
async function loadBatchQueues(page) {
|
||
const section = document.getElementById('batch-queues-section');
|
||
if (!section) return;
|
||
|
||
// 如果指定了page,使用它;否则使用当前页
|
||
if (page !== undefined) {
|
||
batchQueuesState.currentPage = page;
|
||
}
|
||
|
||
// 加载角色列表(用于显示正确的角色图标)
|
||
let loadedRoles = [];
|
||
if (typeof loadRoles === 'function') {
|
||
try {
|
||
loadedRoles = await loadRoles();
|
||
} catch (error) {
|
||
console.warn('加载角色列表失败,将使用默认图标:', error);
|
||
}
|
||
}
|
||
batchQueuesState.loadedRoles = loadedRoles; // 保存到状态中供渲染使用
|
||
|
||
// 构建查询参数
|
||
const params = new URLSearchParams();
|
||
params.append('page', batchQueuesState.currentPage.toString());
|
||
params.append('limit', batchQueuesState.pageSize.toString());
|
||
if (batchQueuesState.filterStatus && batchQueuesState.filterStatus !== 'all') {
|
||
params.append('status', batchQueuesState.filterStatus);
|
||
}
|
||
if (batchQueuesState.searchKeyword) {
|
||
params.append('keyword', batchQueuesState.searchKeyword);
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks?${params.toString()}`);
|
||
if (!response.ok) {
|
||
throw new Error(_t('tasks.loadFailedRetry'));
|
||
}
|
||
|
||
const result = await response.json();
|
||
batchQueuesState.queues = result.queues || [];
|
||
batchQueuesState.total = result.total || 0;
|
||
batchQueuesState.totalPages = result.total_pages || 1;
|
||
renderBatchQueues();
|
||
} catch (error) {
|
||
console.error('加载批量任务队列失败:', error);
|
||
section.style.display = 'block';
|
||
const list = document.getElementById('batch-queues-list');
|
||
if (list) {
|
||
list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.loadFailedRetry') + ': ' + escapeHtml(error.message) + '</p><button class="btn-secondary" onclick="refreshBatchQueues()">' + _t('tasks.retry') + '</button></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 筛选批量任务队列
|
||
function filterBatchQueues() {
|
||
const statusFilter = document.getElementById('batch-queues-status-filter');
|
||
const searchInput = document.getElementById('batch-queues-search');
|
||
|
||
if (statusFilter) {
|
||
batchQueuesState.filterStatus = statusFilter.value;
|
||
}
|
||
if (searchInput) {
|
||
batchQueuesState.searchKeyword = searchInput.value.trim();
|
||
}
|
||
|
||
// 重置到第一页并重新加载
|
||
batchQueuesState.currentPage = 1;
|
||
loadBatchQueues(1);
|
||
}
|
||
|
||
// 渲染批量任务队列列表
|
||
function renderBatchQueues() {
|
||
const section = document.getElementById('batch-queues-section');
|
||
const list = document.getElementById('batch-queues-list');
|
||
const pagination = document.getElementById('batch-queues-pagination');
|
||
|
||
if (!section || !list) return;
|
||
|
||
section.style.display = 'block';
|
||
|
||
const queues = batchQueuesState.queues;
|
||
|
||
if (queues.length === 0) {
|
||
list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.noBatchQueues') + '</p></div>';
|
||
if (pagination) pagination.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// 确保分页控件可见(重置之前可能设置的 display: none)
|
||
if (pagination) {
|
||
pagination.style.display = '';
|
||
}
|
||
|
||
list.innerHTML = queues.map(queue => {
|
||
const pres = getBatchQueueStatusPresentation(queue);
|
||
|
||
// 统计任务状态
|
||
const stats = {
|
||
total: queue.tasks.length,
|
||
pending: 0,
|
||
running: 0,
|
||
completed: 0,
|
||
failed: 0,
|
||
cancelled: 0
|
||
};
|
||
|
||
queue.tasks.forEach(task => {
|
||
if (task.status === 'pending') stats.pending++;
|
||
else if (task.status === 'running') stats.running++;
|
||
else if (task.status === 'completed') stats.completed++;
|
||
else if (task.status === 'failed') stats.failed++;
|
||
else if (task.status === 'cancelled') stats.cancelled++;
|
||
});
|
||
|
||
const progress = stats.total > 0 ? Math.round((stats.completed + stats.failed + stats.cancelled) / stats.total * 100) : 0;
|
||
// 允许删除待执行、已完成或已取消状态的队列
|
||
const canDelete = queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled';
|
||
|
||
const loadedRoles = batchQueuesState.loadedRoles || [];
|
||
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
|
||
const roleName = queue.role && queue.role !== '' ? queue.role : _t('batchQueueDetailModal.defaultRole');
|
||
const isCronCycleIdle = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false && queue.status === 'completed';
|
||
const cardMod = isCronCycleIdle ? ' batch-queue-item--cron-wait' : '';
|
||
const progressFillMod = isCronCycleIdle ? ' batch-queue-progress-fill--cron-wait' : '';
|
||
|
||
const agentLabel = batchQueueAgentModeLabel(queue.agentMode);
|
||
let scheduleLabel = queue.scheduleMode === 'cron' ? _t('batchImportModal.scheduleModeCron') : _t('batchImportModal.scheduleModeManual');
|
||
if (queue.scheduleMode === 'cron' && queue.cronExpr) {
|
||
scheduleLabel += ` (${queue.cronExpr})`;
|
||
}
|
||
const configLine = [roleName, agentLabel, scheduleLabel].map(s => escapeHtml(s)).join(' · ');
|
||
const cronPausedNote = queue.scheduleMode === 'cron' && queue.scheduleEnabled === false
|
||
? ` <span class="batch-queue-inline-warn" title="${escapeHtml(_t('batchQueueDetailModal.scheduleCronAutoHint'))}">(${escapeHtml(_t('batchQueueDetailModal.cronSchedulePausedBadge'))})</span>`
|
||
: '';
|
||
const shortId = queue.id.length > 14 ? escapeHtml(queue.id.slice(0, 12)) + '\u2026' : escapeHtml(queue.id);
|
||
const titleBlock = queue.title
|
||
? `<h4 class="batch-queue-card-title">${escapeHtml(queue.title)}</h4>`
|
||
: `<h4 class="batch-queue-card-title batch-queue-card-title--muted">${escapeHtml(_t('tasks.batchQueueUntitled'))}</h4>`;
|
||
const doneCount = stats.completed + stats.failed + stats.cancelled;
|
||
|
||
const noActionsClass = canDelete ? '' : ' batch-queue-item--no-actions';
|
||
return `
|
||
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
|
||
<div class="batch-queue-item__lead">
|
||
<div class="batch-queue-item__title-row">
|
||
<span class="batch-queue-item__role-icon" aria-hidden="true">${escapeHtml(roleIcon)}</span>
|
||
<div class="batch-queue-item__titles">${titleBlock}</div>
|
||
</div>
|
||
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
|
||
<p class="batch-queue-item__idline batch-queue-item__idline--lead"><code title="${escapeHtml(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
|
||
</div>
|
||
<div class="batch-queue-item__cluster">
|
||
<div class="batch-queue-item__status-inline">
|
||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||
<span class="batch-queue-item__pct">${progress}%\u00a0<span class="batch-queue-item__pct-frac">(${doneCount}/${stats.total})</span></span>
|
||
</div>
|
||
${pres.sublabel ? `<span class="batch-queue-item__sublabel">${escapeHtml(pres.sublabel)}</span>` : ''}
|
||
</div>
|
||
<div class="batch-queue-item__progress-col">
|
||
<div class="batch-queue-progress-bar batch-queue-progress-bar--card batch-queue-progress-bar--list batch-queue-progress-bar--card-row">
|
||
<div class="batch-queue-progress-fill${progressFillMod}" style="width: ${progress}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="batch-queue-item__actions-col" onclick="event.stopPropagation();">
|
||
${canDelete ? `<button type="button" class="batch-queue-icon-btn" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
}).join('');
|
||
|
||
// 渲染分页控件
|
||
renderBatchQueuesPagination();
|
||
}
|
||
|
||
// 渲染批量任务队列分页控件(结构与样式对齐 MCP 监控 .monitor-pagination)
|
||
function renderBatchQueuesPagination() {
|
||
const paginationContainer = document.getElementById('batch-queues-pagination');
|
||
if (!paginationContainer) return;
|
||
|
||
const { currentPage, pageSize, total, totalPages } = batchQueuesState;
|
||
|
||
// 即使只有一页也显示分页信息(与 MCP 监控一致)
|
||
if (total === 0) {
|
||
paginationContainer.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// 计算显示范围
|
||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||
|
||
let paginationHTML = '<div class="monitor-pagination">';
|
||
|
||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||
paginationHTML += `
|
||
<div class="pagination-info">
|
||
<span>` + _t('tasks.paginationShow', { start: start, end: end, total: total }) + `</span>
|
||
<label class="pagination-page-size">
|
||
` + _t('tasks.paginationPerPage') + `
|
||
<select id="batch-queues-page-size-pagination" onchange="changeBatchQueuesPageSize()">
|
||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
`;
|
||
|
||
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
|
||
paginationHTML += `
|
||
<div class="pagination-controls">
|
||
<button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationFirst') + `</button>
|
||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationPrev') + `</button>
|
||
<span class="pagination-page">` + _t('tasks.paginationPage', { current: currentPage, total: totalPages || 1 }) + `</span>
|
||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationNext') + `</button>
|
||
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationLast') + `</button>
|
||
</div>
|
||
`;
|
||
|
||
paginationHTML += '</div>';
|
||
|
||
paginationContainer.innerHTML = paginationHTML;
|
||
}
|
||
|
||
// 跳转到指定页面
|
||
function goBatchQueuesPage(page) {
|
||
const { totalPages } = batchQueuesState;
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
loadBatchQueues(page);
|
||
|
||
// 滚动到列表顶部
|
||
const list = document.getElementById('batch-queues-list');
|
||
if (list) {
|
||
list.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
// 改变每页显示数量
|
||
function changeBatchQueuesPageSize() {
|
||
const pageSizeSelect = document.getElementById('batch-queues-page-size-pagination');
|
||
if (!pageSizeSelect) return;
|
||
|
||
const newPageSize = parseInt(pageSizeSelect.value, 10);
|
||
if (newPageSize && newPageSize > 0) {
|
||
batchQueuesState.pageSize = newPageSize;
|
||
batchQueuesState.currentPage = 1; // 重置到第一页
|
||
loadBatchQueues(1);
|
||
}
|
||
}
|
||
|
||
// 显示批量任务队列详情
|
||
async function showBatchQueueDetail(queueId) {
|
||
const modal = document.getElementById('batch-queue-detail-modal');
|
||
const title = document.getElementById('batch-queue-detail-title');
|
||
const content = document.getElementById('batch-queue-detail-content');
|
||
const startBtn = document.getElementById('batch-queue-start-btn');
|
||
const cancelBtn = document.getElementById('batch-queue-cancel-btn');
|
||
const deleteBtn = document.getElementById('batch-queue-delete-btn');
|
||
const addTaskBtn = document.getElementById('batch-queue-add-task-btn');
|
||
|
||
if (!modal || !content) return;
|
||
|
||
try {
|
||
// 加载角色列表(如果还未加载)
|
||
let loadedRoles = [];
|
||
if (typeof loadRoles === 'function') {
|
||
try {
|
||
loadedRoles = await loadRoles();
|
||
} catch (error) {
|
||
console.warn('加载角色列表失败,将使用默认图标:', error);
|
||
}
|
||
}
|
||
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
if (!response.ok) {
|
||
throw new Error(_t('tasks.getQueueDetailFailed'));
|
||
}
|
||
|
||
const result = await response.json();
|
||
const queue = result.queue;
|
||
batchQueuesState.currentQueueId = queueId;
|
||
const pres = getBatchQueueStatusPresentation(queue);
|
||
const allowSubtaskMutation = batchQueueAllowsSubtaskMutation(queue);
|
||
|
||
if (title) {
|
||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
|
||
}
|
||
|
||
// 更新按钮显示
|
||
const pauseBtn = document.getElementById('batch-queue-pause-btn');
|
||
if (addTaskBtn) {
|
||
addTaskBtn.style.display = allowSubtaskMutation ? 'inline-block' : 'none';
|
||
}
|
||
if (startBtn) {
|
||
// pending状态显示"开始执行",paused状态显示"继续执行"
|
||
startBtn.style.display = (queue.status === 'pending' || queue.status === 'paused') ? 'inline-block' : 'none';
|
||
if (startBtn && queue.status === 'paused') {
|
||
startBtn.textContent = _t('tasks.resumeExecute');
|
||
} else if (startBtn && queue.status === 'pending') {
|
||
const isCronPending = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||
startBtn.textContent = isCronPending
|
||
? _t('batchQueueDetailModal.startExecuteNow')
|
||
: _t('batchQueueDetailModal.startExecute');
|
||
}
|
||
}
|
||
const rerunBtn = document.getElementById('batch-queue-rerun-btn');
|
||
if (rerunBtn) {
|
||
// 已完成或已取消状态显示"重跑一轮"
|
||
rerunBtn.style.display = (queue.status === 'completed' || queue.status === 'cancelled') ? 'inline-block' : 'none';
|
||
}
|
||
if (pauseBtn) {
|
||
// running状态显示"暂停队列"
|
||
pauseBtn.style.display = queue.status === 'running' ? 'inline-block' : 'none';
|
||
}
|
||
if (deleteBtn) {
|
||
// 允许删除待执行、已完成或已取消状态的队列
|
||
deleteBtn.style.display = (queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled' || queue.status === 'paused') ? 'inline-block' : 'none';
|
||
}
|
||
|
||
// 任务状态映射
|
||
const taskStatusMap = {
|
||
'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
|
||
'running': { text: _t('tasks.statusRunning'), class: 'batch-task-status-running' },
|
||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-task-status-completed' },
|
||
'failed': { text: _t('tasks.failedLabel'), class: 'batch-task-status-failed' },
|
||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
|
||
};
|
||
|
||
let roleLineVal = '';
|
||
if (queue.role && queue.role !== '') {
|
||
let roleName = queue.role;
|
||
let roleIcon = '\uD83D\uDC64';
|
||
if (Array.isArray(loadedRoles) && loadedRoles.length > 0) {
|
||
const role = loadedRoles.find(r => r.name === roleName);
|
||
if (role && role.icon) {
|
||
let icon = role.icon;
|
||
const unicodeMatch = icon.match(/^"?\\U([0-9A-F]{8})"?$/i);
|
||
if (unicodeMatch) {
|
||
try {
|
||
const codePoint = parseInt(unicodeMatch[1], 16);
|
||
icon = String.fromCodePoint(codePoint);
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
roleIcon = icon;
|
||
}
|
||
}
|
||
roleLineVal = roleIcon + ' ' + escapeHtml(roleName);
|
||
} else {
|
||
roleLineVal = '\uD83D\uDD35 ' + escapeHtml(_t('batchQueueDetailModal.defaultRole'));
|
||
}
|
||
const agentModeText = batchQueueAgentModeLabel(queue.agentMode);
|
||
const scheduleModeText = queue.scheduleMode === 'cron' ? _t('batchImportModal.scheduleModeCron') : _t('batchImportModal.scheduleModeManual');
|
||
const scheduleDetail = escapeHtml(scheduleModeText) + (queue.scheduleMode === 'cron' && queue.cronExpr ? `(${escapeHtml(queue.cronExpr)})` : '');
|
||
const showProgressNoteInModal = !!(pres.progressNote && !pres.callout);
|
||
|
||
|
||
// 保存滚动位置,防止刷新时滚动条弹回顶部
|
||
const modalBody = content.closest('.modal-body');
|
||
const tasksList = content.querySelector('.batch-queue-tasks-list');
|
||
const savedModalBodyScrollTop = modalBody ? modalBody.scrollTop : 0;
|
||
const savedTasksListScrollTop = tasksList ? tasksList.scrollTop : 0;
|
||
const prevTechDetails = content.querySelector('details.batch-queue-detail-tech');
|
||
const prevLayout = content.querySelector('.batch-queue-detail-layout[data-bq-detail-for]');
|
||
const prevDetailFor = prevLayout ? prevLayout.getAttribute('data-bq-detail-for') : null;
|
||
const sameQueueAsBefore = prevDetailFor === queue.id;
|
||
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
|
||
|
||
content.innerHTML = `
|
||
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
|
||
<section class="batch-queue-detail-hero">
|
||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||
${pres.sublabel ? `<p class="batch-queue-detail-hero__sub">${escapeHtml(pres.sublabel)}</p>` : ''}
|
||
${showProgressNoteInModal ? `<p class="batch-queue-detail-hero__note">${escapeHtml(pres.progressNote)}</p>` : ''}
|
||
</section>
|
||
<section class="batch-queue-detail-kv">
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</span><span class="bq-kv__v" id="bq-title-val">${allowSubtaskMutation ? `<span class="bq-inline-editable" onclick="startInlineEditTitle()" title="${escapeHtml(_t('common.edit'))}">${escapeHtml(queue.title || _t('tasks.batchQueueUntitled'))}</span>` : escapeHtml(queue.title || _t('tasks.batchQueueUntitled'))}</span></div>
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.role'))}</span><span class="bq-kv__v" id="bq-role-val">${allowSubtaskMutation ? `<span class="bq-inline-editable" onclick="startInlineEditRole()" title="${escapeHtml(_t('common.edit'))}">${roleLineVal}</span>` : roleLineVal}</span></div>
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.agentMode'))}</span><span class="bq-kv__v" id="bq-agentmode-val">${allowSubtaskMutation ? `<span class="bq-inline-editable" onclick="startInlineEditAgentMode()" title="${escapeHtml(_t('common.edit'))}">${escapeHtml(agentModeText)}</span>` : escapeHtml(agentModeText)}</span></div>
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.scheduleMode'))}</span><span class="bq-kv__v" id="bq-schedule-val">${allowSubtaskMutation ? `<span class="bq-inline-editable" onclick="startInlineEditSchedule()" title="${escapeHtml(_t('common.edit'))}">${scheduleDetail}</span>` : scheduleDetail}</span></div>
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.taskTotal'))}</span><span class="bq-kv__v">${queue.tasks.length}</span></div>
|
||
${queue.scheduleMode === 'cron' ? `<div class="bq-kv bq-kv--block"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAuto'))}</span><span class="bq-kv__v bq-kv__v--control"><label class="bq-cron-toggle"><input type="checkbox" ${queue.scheduleEnabled !== false ? 'checked' : ''} onchange="updateBatchQueueScheduleEnabled(this.checked)" /><span class="bq-cron-toggle__hint">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAutoHint'))}</span></label></span></div>` : ''}
|
||
</section>
|
||
${queue.lastScheduleError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastScheduleError'))}</strong><p>${escapeHtml(queue.lastScheduleError)}</p></div>` : ''}
|
||
${queue.lastRunError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastRunError'))}</strong><p>${escapeHtml(queue.lastRunError)}</p></div>` : ''}
|
||
${pres.callout ? `<div class="batch-queue-cron-callout batch-queue-cron-callout--compact"><span class="batch-queue-cron-callout-icon" aria-hidden="true">\u21BB</span><p>${escapeHtml(pres.callout)}</p></div>` : ''}
|
||
<details class="batch-queue-detail-tech">
|
||
<summary class="batch-queue-detail-tech__sum">${escapeHtml(_t('batchQueueDetailModal.technicalDetails'))}</summary>
|
||
<div class="batch-queue-detail-tech__body">
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueId'))}</span><span class="bq-kv__v"><code>${escapeHtml(queue.id)}</code></span></div>
|
||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.createdAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></div>
|
||
${queue.startedAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.startedAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.startedAt).toLocaleString())}</span></div>` : ''}
|
||
${queue.completedAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.completedAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.completedAt).toLocaleString())}</span></div>` : ''}
|
||
${queue.scheduleMode === 'cron' && queue.nextRunAt && !pres.sublabel ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.nextRunAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.nextRunAt).toLocaleString())}</span></div>` : ''}
|
||
${queue.lastScheduleTriggerAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.lastScheduleTriggerAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.lastScheduleTriggerAt).toLocaleString())}</span></div>` : ''}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="batch-queue-tasks-list">
|
||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||
${queue.tasks.map((task, index) => {
|
||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||
const canEdit = allowSubtaskMutation && task.status !== 'running';
|
||
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
||
return `
|
||
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
|
||
<div class="batch-task-header">
|
||
<span class="batch-task-index">#${index + 1}</span>
|
||
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
|
||
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
||
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
|
||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||
</div>
|
||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
||
${task.error ? `<div class="batch-task-error">` + _t('batchQueueDetailModal.errorLabel') + `: ${escapeHtml(task.error)}</div>` : ''}
|
||
${task.result ? `<div class="batch-task-result">` + _t('batchQueueDetailModal.resultLabel') + `: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 恢复滚动位置
|
||
if (savedModalBodyScrollTop > 0 && modalBody) {
|
||
modalBody.scrollTop = savedModalBodyScrollTop;
|
||
}
|
||
const newTasksList = content.querySelector('.batch-queue-tasks-list');
|
||
if (savedTasksListScrollTop > 0 && newTasksList) {
|
||
newTasksList.scrollTop = savedTasksListScrollTop;
|
||
}
|
||
|
||
const newTechDetails = content.querySelector('details.batch-queue-detail-tech');
|
||
if (newTechDetails && savedTechDetailsOpen) {
|
||
newTechDetails.open = true;
|
||
}
|
||
|
||
modal.style.display = 'block';
|
||
|
||
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
|
||
if (queue.status === 'running') {
|
||
startBatchQueueRefresh(queueId);
|
||
} else {
|
||
stopBatchQueueRefresh();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取队列详情失败:', error);
|
||
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 开始批量任务队列
|
||
async function startBatchQueue() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
const btn = document.getElementById('batch-queue-start-btn');
|
||
if (btn) { btn.disabled = true; }
|
||
try {
|
||
// Cron 队列点击“开始执行”会立即运行一轮,这里二次确认避免误触
|
||
const queueResponse = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
if (!queueResponse.ok) {
|
||
throw new Error(_t('tasks.getQueueDetailFailed'));
|
||
}
|
||
const queueResult = await queueResponse.json();
|
||
const queue = queueResult && queueResult.queue ? queueResult.queue : null;
|
||
const isCronPending = queue && queue.status === 'pending' && queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||
if (isCronPending) {
|
||
const okNow = confirm(_t('batchQueueDetailModal.startExecuteNowConfirm'));
|
||
if (!okNow) return;
|
||
}
|
||
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/start`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.startBatchQueueFailed'));
|
||
}
|
||
|
||
// 刷新详情
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('启动批量任务失败:', error);
|
||
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; }
|
||
}
|
||
}
|
||
|
||
// 暂停批量任务队列
|
||
async function pauseBatchQueue() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
|
||
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('batch-queue-pause-btn');
|
||
if (btn) { btn.disabled = true; }
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/pause`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.pauseQueueFailed'));
|
||
}
|
||
|
||
// 刷新详情
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('暂停批量任务失败:', error);
|
||
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; }
|
||
}
|
||
}
|
||
|
||
// 重跑批量任务队列
|
||
async function rerunBatchQueue() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
|
||
if (!confirm(_t('tasks.rerunQueueConfirm'))) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('batch-queue-rerun-btn');
|
||
if (btn) { btn.disabled = true; }
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/rerun`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.rerunQueueFailed'));
|
||
}
|
||
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('重跑批量任务失败:', error);
|
||
alert(_t('tasks.rerunQueueFailed') + ': ' + error.message);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; }
|
||
}
|
||
}
|
||
|
||
// 删除批量任务队列(从详情模态框)
|
||
async function deleteBatchQueue() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
|
||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('batch-queue-delete-btn');
|
||
if (btn) { btn.disabled = true; }
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||
}
|
||
|
||
closeBatchQueueDetailModal();
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('删除批量任务队列失败:', error);
|
||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; }
|
||
}
|
||
}
|
||
|
||
// 从列表删除批量任务队列
|
||
async function deleteBatchQueueFromList(queueId) {
|
||
if (!queueId) return;
|
||
|
||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||
}
|
||
|
||
// 如果当前正在查看这个队列的详情,关闭详情模态框
|
||
if (batchQueuesState.currentQueueId === queueId) {
|
||
closeBatchQueueDetailModal();
|
||
}
|
||
|
||
// 刷新队列列表
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('删除批量任务队列失败:', error);
|
||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 关闭批量任务队列详情模态框
|
||
function closeBatchQueueDetailModal() {
|
||
const modal = document.getElementById('batch-queue-detail-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
batchQueuesState.currentQueueId = null;
|
||
stopBatchQueueRefresh();
|
||
}
|
||
|
||
// 开始批量队列刷新
|
||
function startBatchQueueRefresh(queueId) {
|
||
if (batchQueuesState.refreshInterval) {
|
||
clearInterval(batchQueuesState.refreshInterval);
|
||
}
|
||
|
||
batchQueuesState.refreshInterval = setInterval(() => {
|
||
// 如果有内联编辑或添加任务模态框正在打开,跳过本次刷新防止丢失编辑内容
|
||
const addModal = document.getElementById('add-batch-task-modal');
|
||
const content = document.getElementById('batch-queue-detail-content');
|
||
const hasInlineEdit = content && (
|
||
content.querySelector('.bq-inline-edit-controls') ||
|
||
content.querySelector('.batch-task-inline-edit')
|
||
);
|
||
if ((addModal && addModal.style.display === 'block') || hasInlineEdit) {
|
||
return;
|
||
}
|
||
if (batchQueuesState.currentQueueId === queueId) {
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} else {
|
||
stopBatchQueueRefresh();
|
||
}
|
||
}, 3000); // 每3秒刷新一次
|
||
}
|
||
|
||
// 停止批量队列刷新
|
||
function stopBatchQueueRefresh() {
|
||
if (batchQueuesState.refreshInterval) {
|
||
clearInterval(batchQueuesState.refreshInterval);
|
||
batchQueuesState.refreshInterval = null;
|
||
}
|
||
}
|
||
|
||
// 刷新批量任务队列列表
|
||
async function refreshBatchQueues() {
|
||
await loadBatchQueues(batchQueuesState.currentPage);
|
||
}
|
||
|
||
// 查看批量任务的对话
|
||
function viewBatchTaskConversation(conversationId) {
|
||
if (!conversationId) return;
|
||
|
||
// 关闭批量任务详情模态框
|
||
closeBatchQueueDetailModal();
|
||
|
||
// 直接使用URL hash跳转,让router处理页面切换和对话加载
|
||
// 这样更可靠,因为router会确保页面切换完成后再加载对话
|
||
window.location.hash = `chat?conversation=${conversationId}`;
|
||
}
|
||
|
||
// --- 内联编辑:任务消息 ---
|
||
// 从元素获取任务信息并启动内联编辑
|
||
function editBatchTaskFromElement(button) {
|
||
const taskItem = button.closest('.batch-task-item');
|
||
if (!taskItem) return;
|
||
|
||
const queueId = taskItem.getAttribute('data-queue-id');
|
||
const taskId = taskItem.getAttribute('data-task-id');
|
||
const taskMessage = taskItem.getAttribute('data-task-message');
|
||
if (!queueId || !taskId) return;
|
||
|
||
// 解码HTML实体
|
||
const decodedMessage = taskMessage
|
||
.replace(/'/g, "'")
|
||
.replace(/"/g, '"')
|
||
.replace(/\\n/g, '\n');
|
||
|
||
// 找到 .batch-task-message 和 header 中的按钮
|
||
const msgSpan = taskItem.querySelector('.batch-task-message');
|
||
const header = taskItem.querySelector('.batch-task-header');
|
||
if (!msgSpan || !header) return;
|
||
|
||
// 隐藏编辑/删除按钮
|
||
header.querySelectorAll('.batch-task-edit-btn, .batch-task-delete-btn').forEach(b => b.style.display = 'none');
|
||
|
||
// 替换消息为内联编辑区域
|
||
const editDiv = document.createElement('div');
|
||
editDiv.className = 'batch-task-inline-edit';
|
||
editDiv.innerHTML = `<textarea id="bq-task-edit-${escapeHtml(taskId)}">${escapeHtml(decodedMessage)}</textarea>`;
|
||
msgSpan.style.display = 'none';
|
||
msgSpan.parentNode.insertBefore(editDiv, msgSpan.nextSibling);
|
||
|
||
const textarea = editDiv.querySelector('textarea');
|
||
if (textarea) {
|
||
let taskCancelled = false;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||
textarea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
taskCancelled = true;
|
||
cancelInlineTask();
|
||
}
|
||
});
|
||
textarea.addEventListener('blur', () => {
|
||
if (!taskCancelled) saveInlineTask(queueId, taskId);
|
||
});
|
||
}
|
||
}
|
||
|
||
function cancelInlineTask() {
|
||
// 刷新整个详情来还原
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (queueId) showBatchQueueDetail(queueId);
|
||
}
|
||
|
||
async function saveInlineTask(queueId, taskId) {
|
||
if (_bqInlineSaving) return;
|
||
_bqInlineSaving = true;
|
||
const textarea = document.getElementById(`bq-task-edit-${taskId}`);
|
||
if (!textarea) { _bqInlineSaving = false; return; }
|
||
|
||
const message = textarea.value.trim();
|
||
if (!message) {
|
||
_bqInlineSaving = false;
|
||
alert(_t('tasks.taskMessageRequired'));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||
}
|
||
|
||
_bqInlineSaving = false;
|
||
// 刷新队列详情
|
||
if (batchQueuesState.currentQueueId === queueId) {
|
||
showBatchQueueDetail(queueId);
|
||
}
|
||
|
||
// 刷新队列列表
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
_bqInlineSaving = false;
|
||
console.error('保存任务失败:', error);
|
||
alert(_t('tasks.saveTaskFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示添加批量任务模态框
|
||
function showAddBatchTaskModal() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) {
|
||
alert(_t('tasks.queueInfoMissing'));
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('add-batch-task-modal');
|
||
const messageInput = document.getElementById('add-task-message');
|
||
|
||
if (!modal || !messageInput) {
|
||
console.error('添加任务模态框元素不存在');
|
||
return;
|
||
}
|
||
|
||
messageInput.value = '';
|
||
modal.style.display = 'block';
|
||
|
||
// 聚焦到输入框
|
||
setTimeout(() => {
|
||
messageInput.focus();
|
||
}, 100);
|
||
|
||
// 清理旧的事件监听器
|
||
if (showAddBatchTaskModal._escHandler) {
|
||
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||
}
|
||
if (showAddBatchTaskModal._saveHandler && messageInput) {
|
||
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||
}
|
||
|
||
// 添加ESC键监听
|
||
showAddBatchTaskModal._escHandler = (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeAddBatchTaskModal();
|
||
}
|
||
};
|
||
document.addEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||
|
||
// 添加Enter+Ctrl/Cmd保存功能
|
||
showAddBatchTaskModal._saveHandler = (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
saveAddBatchTask();
|
||
}
|
||
};
|
||
messageInput.addEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||
}
|
||
|
||
// 关闭添加批量任务模态框
|
||
function closeAddBatchTaskModal() {
|
||
// 清理事件监听器
|
||
if (showAddBatchTaskModal._escHandler) {
|
||
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||
showAddBatchTaskModal._escHandler = null;
|
||
}
|
||
if (showAddBatchTaskModal._saveHandler) {
|
||
const messageInput = document.getElementById('add-task-message');
|
||
if (messageInput) {
|
||
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||
}
|
||
showAddBatchTaskModal._saveHandler = null;
|
||
}
|
||
const modal = document.getElementById('add-batch-task-modal');
|
||
const messageInput = document.getElementById('add-task-message');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
if (messageInput) {
|
||
messageInput.value = '';
|
||
}
|
||
}
|
||
|
||
// 保存添加的批量任务
|
||
async function saveAddBatchTask() {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
const messageInput = document.getElementById('add-task-message');
|
||
|
||
if (!queueId) {
|
||
alert(_t('tasks.queueInfoMissing'));
|
||
return;
|
||
}
|
||
|
||
if (!messageInput) {
|
||
alert(_t('tasks.cannotGetTaskMessageInput'));
|
||
return;
|
||
}
|
||
|
||
const message = messageInput.value.trim();
|
||
if (!message) {
|
||
alert(_t('tasks.taskMessageRequired'));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ message: message }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.addTaskFailed'));
|
||
}
|
||
|
||
// 关闭添加任务模态框
|
||
closeAddBatchTaskModal();
|
||
|
||
// 刷新队列详情
|
||
if (batchQueuesState.currentQueueId === queueId) {
|
||
showBatchQueueDetail(queueId);
|
||
}
|
||
|
||
// 刷新队列列表
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('添加任务失败:', error);
|
||
alert(_t('tasks.addTaskFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 从元素获取任务信息并删除任务
|
||
function deleteBatchTaskFromElement(button) {
|
||
const taskItem = button.closest('.batch-task-item');
|
||
if (!taskItem) {
|
||
console.error('无法找到任务项元素');
|
||
return;
|
||
}
|
||
|
||
const queueId = taskItem.getAttribute('data-queue-id');
|
||
const taskId = taskItem.getAttribute('data-task-id');
|
||
const taskMessage = taskItem.getAttribute('data-task-message');
|
||
|
||
if (!queueId || !taskId) {
|
||
console.error('任务信息不完整');
|
||
return;
|
||
}
|
||
|
||
// 解码HTML实体以显示消息
|
||
const decodedMessage = taskMessage
|
||
.replace(/'/g, "'")
|
||
.replace(/"/g, '"')
|
||
.replace(/\\n/g, '\n');
|
||
|
||
// 截断长消息用于确认对话框
|
||
const displayMessage = decodedMessage.length > 50
|
||
? decodedMessage.substring(0, 50) + '...'
|
||
: decodedMessage;
|
||
|
||
if (!confirm(_t('tasks.confirmDeleteTask', { message: displayMessage }))) {
|
||
return;
|
||
}
|
||
|
||
deleteBatchTask(queueId, taskId);
|
||
}
|
||
|
||
// 删除批量任务
|
||
async function deleteBatchTask(queueId, taskId) {
|
||
if (!queueId || !taskId) {
|
||
alert(_t('tasks.taskIncomplete'));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.deleteTaskFailed'));
|
||
}
|
||
|
||
// 刷新队列详情
|
||
if (batchQueuesState.currentQueueId === queueId) {
|
||
showBatchQueueDetail(queueId);
|
||
}
|
||
|
||
// 刷新队列列表
|
||
refreshBatchQueues();
|
||
} catch (error) {
|
||
console.error('删除任务失败:', error);
|
||
alert(_t('tasks.deleteTaskFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function updateBatchQueueScheduleEnabled(enabled) {
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/schedule-enabled`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scheduleEnabled: enabled }),
|
||
});
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('batchQueueDetailModal.scheduleToggleFailed'));
|
||
}
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert(_t('batchQueueDetailModal.scheduleToggleFailed') + ': ' + e.message);
|
||
showBatchQueueDetail(queueId);
|
||
}
|
||
}
|
||
|
||
// --- 内联编辑:取消所有正在编辑的内联区域 ---
|
||
function cancelAllInlineEdits() {
|
||
_bqInlineSaving = true; // 防止 blur 触发保存
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (queueId) showBatchQueueDetail(queueId);
|
||
_bqInlineSaving = false;
|
||
}
|
||
|
||
// --- 内联编辑:标题 ---
|
||
let _bqInlineSaving = false;
|
||
function startInlineEditTitle() {
|
||
const container = document.getElementById('bq-title-val');
|
||
if (!container) return;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
const currentTitle = (container.querySelector('.bq-inline-editable') || container).textContent.trim();
|
||
const untitledText = _t('tasks.batchQueueUntitled');
|
||
const val = currentTitle === untitledText ? '' : currentTitle;
|
||
container.innerHTML = `<span class="bq-inline-edit-controls">
|
||
<input type="text" id="bq-edit-title" value="${escapeHtml(val)}" placeholder="${escapeHtml(_t('batchImportModal.queueTitleHint') || '')}" style="width:180px;" />
|
||
</span>`;
|
||
const inp = document.getElementById('bq-edit-title');
|
||
if (inp) {
|
||
inp.focus();
|
||
inp.select();
|
||
let cancelled = false;
|
||
inp.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
||
if (e.key === 'Escape') { cancelled = true; cancelAllInlineEdits(); }
|
||
});
|
||
inp.addEventListener('blur', () => {
|
||
if (cancelled) return;
|
||
saveInlineTitle();
|
||
});
|
||
}
|
||
}
|
||
async function saveInlineTitle() {
|
||
if (_bqInlineSaving) return;
|
||
_bqInlineSaving = true;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) { _bqInlineSaving = false; return; }
|
||
const inp = document.getElementById('bq-edit-title');
|
||
const title = inp ? inp.value.trim() : '';
|
||
try {
|
||
// 获取当前角色(保持不变)
|
||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
const detail = await detailResp.json();
|
||
const role = detail.queue ? (detail.queue.role || '') : '';
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/metadata`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, role }),
|
||
});
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||
}
|
||
_bqInlineSaving = false;
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
_bqInlineSaving = false;
|
||
console.error(e);
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
// --- 内联编辑:角色 ---
|
||
function startInlineEditRole() {
|
||
const container = document.getElementById('bq-role-val');
|
||
if (!container) return;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
// 获取当前详情中角色名 — 从 layout 的 data 中无法获取,故使用 API 拉取
|
||
apiFetch(`/api/batch-tasks/${queueId}`).then(r => r.json()).then(detail => {
|
||
const queue = detail.queue;
|
||
const currentRole = queue.role || '';
|
||
const roles = (Array.isArray(batchQueuesState.loadedRoles) ? batchQueuesState.loadedRoles : []).filter(r => r.name !== '默认' && r.enabled !== false).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'zh-CN'));
|
||
const currentInList = !currentRole || roles.some(r => r.name === currentRole);
|
||
const orphanOpt = !currentInList ? `<option value="${escapeHtml(currentRole)}" selected>${escapeHtml(currentRole)} (${escapeHtml(_t('batchQueueDetailModal.roleNotFound') || '已移除')})</option>` : '';
|
||
const opts = roles.map(r => `<option value="${escapeHtml(r.name)}" ${r.name === currentRole ? 'selected' : ''}>${escapeHtml(r.name)}</option>`).join('');
|
||
container.innerHTML = `<span class="bq-inline-edit-controls">
|
||
<select id="bq-edit-role">
|
||
<option value="">${escapeHtml(_t('batchImportModal.defaultRole'))}</option>
|
||
${orphanOpt}${opts}
|
||
</select>
|
||
</span>`;
|
||
const sel = document.getElementById('bq-edit-role');
|
||
if (sel) {
|
||
sel.focus();
|
||
let cancelled = false;
|
||
sel.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { cancelled = true; cancelAllInlineEdits(); }
|
||
});
|
||
sel.addEventListener('change', () => { if (!cancelled) saveInlineRole(); });
|
||
sel.addEventListener('blur', () => { if (!cancelled) saveInlineRole(); });
|
||
}
|
||
});
|
||
}
|
||
async function saveInlineRole() {
|
||
if (_bqInlineSaving) return;
|
||
_bqInlineSaving = true;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) { _bqInlineSaving = false; return; }
|
||
const sel = document.getElementById('bq-edit-role');
|
||
const role = sel ? sel.value.trim() : '';
|
||
try {
|
||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
const detail = await detailResp.json();
|
||
const title = detail.queue ? (detail.queue.title || '') : '';
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/metadata`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, role }),
|
||
});
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||
}
|
||
_bqInlineSaving = false;
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
_bqInlineSaving = false;
|
||
console.error(e);
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
// --- 内联编辑:代理模式 ---
|
||
function startInlineEditAgentMode() {
|
||
const container = document.getElementById('bq-agentmode-val');
|
||
if (!container) return;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
apiFetch(`/api/batch-tasks/${queueId}`).then(r => r.json()).then(detail => {
|
||
const queue = detail.queue;
|
||
let currentMode = (queue.agentMode || 'single').toLowerCase();
|
||
if (currentMode === 'multi') currentMode = 'deep';
|
||
if (['single', 'deep', 'plan_execute', 'supervisor'].indexOf(currentMode) < 0) currentMode = 'single';
|
||
container.innerHTML = `<span class="bq-inline-edit-controls">
|
||
<select id="bq-edit-agentmode">
|
||
<option value="single" ${currentMode === 'single' ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.agentModeSingle'))}</option>
|
||
<option value="deep" ${currentMode === 'deep' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModeDeep'))}</option>
|
||
<option value="plan_execute" ${currentMode === 'plan_execute' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModePlanExecuteLabel'))}</option>
|
||
<option value="supervisor" ${currentMode === 'supervisor' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModeSupervisorLabel'))}</option>
|
||
</select>
|
||
</span>`;
|
||
const sel = document.getElementById('bq-edit-agentmode');
|
||
if (sel) {
|
||
sel.focus();
|
||
let cancelled = false;
|
||
sel.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { cancelled = true; cancelAllInlineEdits(); }
|
||
});
|
||
sel.addEventListener('change', () => { if (!cancelled) saveInlineAgentMode(); });
|
||
sel.addEventListener('blur', () => { if (!cancelled) saveInlineAgentMode(); });
|
||
}
|
||
});
|
||
}
|
||
async function saveInlineAgentMode() {
|
||
if (_bqInlineSaving) return;
|
||
_bqInlineSaving = true;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) { _bqInlineSaving = false; return; }
|
||
const sel = document.getElementById('bq-edit-agentmode');
|
||
const raw = sel ? sel.value : 'single';
|
||
const agentMode = ['single', 'deep', 'plan_execute', 'supervisor'].indexOf(raw) >= 0 ? raw : 'single';
|
||
try {
|
||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
const detail = await detailResp.json();
|
||
const title = detail.queue ? (detail.queue.title || '') : '';
|
||
const role = detail.queue ? (detail.queue.role || '') : '';
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/metadata`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, role, agentMode }),
|
||
});
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||
}
|
||
_bqInlineSaving = false;
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
_bqInlineSaving = false;
|
||
console.error(e);
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
// --- 重试失败任务 ---
|
||
async function retryBatchTask(queueId, taskId) {
|
||
if (!queueId || !taskId) return;
|
||
try {
|
||
// 获取任务消息
|
||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
|
||
const detail = await detailResp.json();
|
||
const task = detail.queue.tasks.find(t => t.id === taskId);
|
||
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
|
||
const message = task.message;
|
||
|
||
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
|
||
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message }),
|
||
});
|
||
if (!addResp.ok) {
|
||
const r = await addResp.json().catch(() => ({}));
|
||
throw new Error(r.error || _t('tasks.addTaskFailed'));
|
||
}
|
||
// 新任务添加成功后才删除旧任务
|
||
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
|
||
if (!delResp.ok) {
|
||
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
|
||
console.warn('删除旧任务失败,但新任务已添加');
|
||
}
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
console.error('重试任务失败:', e);
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
// --- 内联编辑:调度配置 ---
|
||
function startInlineEditSchedule() {
|
||
const container = document.getElementById('bq-schedule-val');
|
||
if (!container) return;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) return;
|
||
apiFetch(`/api/batch-tasks/${queueId}`).then(r => r.json()).then(detail => {
|
||
const queue = detail.queue;
|
||
const isCron = queue.scheduleMode === 'cron';
|
||
container.innerHTML = `<span class="bq-inline-edit-controls">
|
||
<select id="bq-edit-schedule-mode" onchange="toggleInlineScheduleCron()">
|
||
<option value="manual" ${!isCron ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeManual'))}</option>
|
||
<option value="cron" ${isCron ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeCron'))}</option>
|
||
</select>
|
||
<input type="text" id="bq-edit-cron-expr" value="${escapeHtml(queue.cronExpr || '')}" placeholder="${_t('batchImportModal.cronExprPlaceholder', { interpolation: { escapeValue: false } })}" style="width:200px;${!isCron ? 'display:none;' : ''}" />
|
||
</span>`;
|
||
let schedCancelled = false;
|
||
const sel = document.getElementById('bq-edit-schedule-mode');
|
||
const cronInp = document.getElementById('bq-edit-cron-expr');
|
||
if (sel) {
|
||
sel.focus();
|
||
sel.addEventListener('keydown', (e) => { if (e.key === 'Escape') { schedCancelled = true; cancelAllInlineEdits(); } });
|
||
sel.addEventListener('change', () => {
|
||
toggleInlineScheduleCron();
|
||
// 切到 manual 时直接保存;切到 cron 时等用户输入表达式后 blur 保存
|
||
if (sel.value !== 'cron' && !schedCancelled) saveInlineSchedule();
|
||
});
|
||
sel.addEventListener('blur', (e) => {
|
||
// 如果焦点移到了 cron 输入框,不触发保存
|
||
setTimeout(() => {
|
||
const active = document.activeElement;
|
||
if (active && (active.id === 'bq-edit-cron-expr' || active.id === 'bq-edit-schedule-mode')) return;
|
||
if (!schedCancelled) saveInlineSchedule();
|
||
}, 100);
|
||
});
|
||
}
|
||
if (cronInp) {
|
||
cronInp.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); cronInp.blur(); }
|
||
if (e.key === 'Escape') { schedCancelled = true; cancelAllInlineEdits(); }
|
||
});
|
||
cronInp.addEventListener('blur', () => {
|
||
setTimeout(() => {
|
||
const active = document.activeElement;
|
||
if (active && (active.id === 'bq-edit-cron-expr' || active.id === 'bq-edit-schedule-mode')) return;
|
||
if (!schedCancelled) saveInlineSchedule();
|
||
}, 100);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
function toggleInlineScheduleCron() {
|
||
const modeSelect = document.getElementById('bq-edit-schedule-mode');
|
||
const cronInput = document.getElementById('bq-edit-cron-expr');
|
||
if (modeSelect && cronInput) {
|
||
cronInput.style.display = modeSelect.value === 'cron' ? '' : 'none';
|
||
if (modeSelect.value === 'cron') cronInput.focus();
|
||
}
|
||
}
|
||
async function saveInlineSchedule() {
|
||
if (_bqInlineSaving) return;
|
||
_bqInlineSaving = true;
|
||
const queueId = batchQueuesState.currentQueueId;
|
||
if (!queueId) { _bqInlineSaving = false; return; }
|
||
const modeSelect = document.getElementById('bq-edit-schedule-mode');
|
||
const cronInput = document.getElementById('bq-edit-cron-expr');
|
||
if (!modeSelect) { _bqInlineSaving = false; return; }
|
||
const scheduleMode = modeSelect.value;
|
||
const cronExpr = cronInput ? cronInput.value.trim() : '';
|
||
if (scheduleMode === 'cron' && !cronExpr) {
|
||
_bqInlineSaving = false;
|
||
alert(_t('batchImportModal.cronExprRequired'));
|
||
return;
|
||
}
|
||
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
|
||
_bqInlineSaving = false;
|
||
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await apiFetch(`/api/batch-tasks/${queueId}/schedule`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scheduleMode, cronExpr }),
|
||
});
|
||
if (!response.ok) {
|
||
const result = await response.json().catch(() => ({}));
|
||
throw new Error(result.error || _t('batchQueueDetailModal.editScheduleError'));
|
||
}
|
||
_bqInlineSaving = false;
|
||
showBatchQueueDetail(queueId);
|
||
refreshBatchQueues();
|
||
} catch (e) {
|
||
_bqInlineSaving = false;
|
||
console.error(e);
|
||
alert(_t('batchQueueDetailModal.editScheduleError') + ': ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 导出函数
|
||
window.showBatchImportModal = showBatchImportModal;
|
||
window.closeBatchImportModal = closeBatchImportModal;
|
||
window.createBatchQueue = createBatchQueue;
|
||
window.showBatchQueueDetail = showBatchQueueDetail;
|
||
window.startBatchQueue = startBatchQueue;
|
||
window.pauseBatchQueue = pauseBatchQueue;
|
||
window.rerunBatchQueue = rerunBatchQueue;
|
||
window.deleteBatchQueue = deleteBatchQueue;
|
||
window.closeBatchQueueDetailModal = closeBatchQueueDetailModal;
|
||
window.refreshBatchQueues = refreshBatchQueues;
|
||
window.viewBatchTaskConversation = viewBatchTaskConversation;
|
||
window.editBatchTaskFromElement = editBatchTaskFromElement;
|
||
window.cancelInlineTask = cancelInlineTask;
|
||
window.saveInlineTask = saveInlineTask;
|
||
window.filterBatchQueues = filterBatchQueues;
|
||
window.goBatchQueuesPage = goBatchQueuesPage;
|
||
window.changeBatchQueuesPageSize = changeBatchQueuesPageSize;
|
||
window.showAddBatchTaskModal = showAddBatchTaskModal;
|
||
window.closeAddBatchTaskModal = closeAddBatchTaskModal;
|
||
window.saveAddBatchTask = saveAddBatchTask;
|
||
window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
|
||
window.deleteBatchQueueFromList = deleteBatchQueueFromList;
|
||
window.handleBatchScheduleModeChange = handleBatchScheduleModeChange;
|
||
window.updateBatchQueueScheduleEnabled = updateBatchQueueScheduleEnabled;
|
||
window.cancelAllInlineEdits = cancelAllInlineEdits;
|
||
window.startInlineEditTitle = startInlineEditTitle;
|
||
window.saveInlineTitle = saveInlineTitle;
|
||
window.startInlineEditRole = startInlineEditRole;
|
||
window.saveInlineRole = saveInlineRole;
|
||
window.startInlineEditAgentMode = startInlineEditAgentMode;
|
||
window.saveInlineAgentMode = saveInlineAgentMode;
|
||
window.retryBatchTask = retryBatchTask;
|
||
window.startInlineEditSchedule = startInlineEditSchedule;
|
||
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
|
||
window.saveInlineSchedule = saveInlineSchedule;
|
||
|
||
// 语言切换后,列表/分页/详情弹窗由 JS 渲染的文案需用当前语言重绘(applyTranslations 不会处理 innerHTML 内容)
|
||
document.addEventListener('languagechange', function () {
|
||
try {
|
||
const tasksPage = document.getElementById('page-tasks');
|
||
if (!tasksPage || !tasksPage.classList.contains('active')) {
|
||
return;
|
||
}
|
||
if (document.getElementById('batch-queues-list')) {
|
||
renderBatchQueues();
|
||
}
|
||
const detailModal = document.getElementById('batch-queue-detail-modal');
|
||
if (
|
||
detailModal &&
|
||
detailModal.style.display === 'block' &&
|
||
batchQueuesState.currentQueueId
|
||
) {
|
||
showBatchQueueDetail(batchQueuesState.currentQueueId);
|
||
}
|
||
} catch (e) {
|
||
console.warn('languagechange tasks refresh failed', e);
|
||
}
|
||
});
|