/** * @file taskCenter.js * @description Centralizes Task component classes for global. * This module exports singleton instances of `TaskCenterManager` and `ToastManager` * to ensure consistent task service across the application. */ // =================================================================== // 任务中心UI管理器 // =================================================================== /** * Manages the UI and state for the global asynchronous task center. * It handles task rendering, state updates, and user interactions like * opening/closing the panel and clearing completed tasks. */ class TaskCenterManager { constructor() { // --- 核心状态 --- this.tasks = []; // A history of all tasks started in this session. this.activePolls = new Map(); this.heartbeatInterval = null; this.MINIMUM_TASK_DISPLAY_TIME_MS = 800; this.hasUnreadCompletedTasks = false; this.isAnimating = false; this.countdownTimer = null; // --- 核心DOM元素引用 --- this.trigger = document.getElementById('task-hub-trigger'); this.panel = document.getElementById('task-hub-panel'); this.countdownBar = document.getElementById('task-hub-countdown-bar'); this.countdownRing = document.getElementById('task-hub-countdown-ring'); this.indicator = document.getElementById('task-hub-indicator'); this.clearBtn = document.getElementById('task-hub-clear-btn'); this.taskListContainer = document.getElementById('task-list-container'); this.emptyState = document.getElementById('task-list-empty'); } // [THE FINAL, DEFINITIVE VERSION] init() { if (!this.trigger || !this.panel) { console.warn('Task Center UI core elements not found. Initialization skipped.'); return; } // --- UI Event Listeners (Corrected and final) --- this.trigger.addEventListener('click', (event) => { event.stopPropagation(); if (this.isAnimating) return; if (this.panel.classList.contains('hidden')) { this._handleUserInteraction(); this.openPanel(); } else { this.closePanel(); } }); document.addEventListener('click', (event) => { if (!this.panel.classList.contains('hidden') && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) { this.closePanel(); } }); this.trigger.addEventListener('mouseenter', this._stopCountdown.bind(this)); this.panel.addEventListener('mouseenter', this._handleUserInteraction.bind(this)); const handleMouseLeave = () => { if (!this.panel.classList.contains('hidden')) { this._startCountdown(); } }; this.panel.addEventListener('mouseleave', handleMouseLeave); this.trigger.addEventListener('mouseleave', handleMouseLeave); this.clearBtn?.addEventListener('click', this.clearCompletedTasks.bind(this)); this.taskListContainer.addEventListener('click', (event) => { const toggleHeader = event.target.closest('[data-task-toggle]'); if (!toggleHeader) return; this._handleUserInteraction(); const taskItem = toggleHeader.closest('.task-list-item'); const content = taskItem.querySelector('[data-task-content]'); if (!content) return; const isCollapsed = content.classList.contains('collapsed'); toggleHeader.classList.toggle('expanded', isCollapsed); if (isCollapsed) { content.classList.remove('collapsed'); content.style.maxHeight = `${content.scrollHeight}px`; content.style.opacity = '1'; content.addEventListener('transitionend', () => { if (!content.classList.contains('collapsed')) content.style.maxHeight = 'none'; }, { once: true }); } else { content.style.maxHeight = `${content.scrollHeight}px`; requestAnimationFrame(() => { content.style.maxHeight = '0px'; content.style.opacity = '0'; content.classList.add('collapsed'); }); } }); this._render(); // [CRITICAL FIX] IGNITION! START THE ENGINE! this._startHeartbeat(); console.log('Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].'); } async startTask(taskDefinition) { try { const initialTaskData = await taskDefinition.start(); if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object."); const newTask = { id: initialTaskData.id, definition: taskDefinition, data: initialTaskData, timestamp: new Date(), startTime: Date.now() }; if (!initialTaskData.is_running) { console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`); // We still show a brief toast for UX feedback. taskDefinition.renderToastNarrative(newTask.data, {}, toastManager); this.tasks.unshift(newTask); this._render(); this._handleTaskCompletion(newTask); return; // IMPORTANT: Exit here to avoid adding it to the polling queue. } this.tasks.unshift(newTask); this.activePolls.set(newTask.id, newTask); this._render(); this.openPanel(); taskDefinition.renderToastNarrative(newTask.data, {}, toastManager); this._updateIndicatorState(); // [SAFETY] Update indicator immediately on new task. } catch (error) { console.error("Failed to start task:", error); toastManager.show(`任务启动失败: ${error.message}`, 'error'); } } _startHeartbeat() { if (this.heartbeatInterval) return; this.heartbeatInterval = setInterval(this._tick.bind(this), 1500); } _stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } async _tick() { if (this.activePolls.size === 0) { return; } // Iterate over a copy of keys to safely remove items during iteration. for (const taskId of [...this.activePolls.keys()]) { const task = this.activePolls.get(taskId); if (!task) continue; // Safety check try { const response = await task.definition.poll(taskId); if (!response.success || !response.data) throw new Error(response.message || "Polling failed"); const oldData = { ...task.data }; task.data = response.data; this._updateTaskItemInHistory(task.id, task.data); // [SAFETY] Keep history in sync. task.definition.renderToastNarrative(task.data, oldData, toastManager); if (!task.data.is_running) { this._handleTaskCompletion(task); } } catch (error) { console.error(`Polling for task ${taskId} failed:`, error); task.data.error = error.message; this._updateTaskItemInHistory(task.id, task.data); this._handleTaskCompletion(task); } } } _handleTaskCompletion(task) { this.activePolls.delete(task.id); this._updateIndicatorState(); // [SAFETY] Update indicator as soon as a task is no longer active. const toastId = `task-${task.id}`; const finalize = async () => { await toastManager.dismiss(toastId, !task.data.error); this._updateTaskItemInDom(task); this.hasUnreadCompletedTasks = true; this._updateIndicatorState(); if (task.data.error) { if (task.definition.onError) task.definition.onError(task.data); } else { if (task.definition.onSuccess) task.definition.onSuccess(task.data); } }; const elapsedTime = Date.now() - task.startTime; const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime; if (remainingTime > 0) { setTimeout(finalize, remainingTime); } else { finalize(); } } // [REFACTORED for robustness] _updateIndicatorState() { const hasRunningTasks = this.activePolls.size > 0; const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks; this.indicator.classList.toggle('hidden', !shouldBeVisible); } // [REFACTORED for robustness] clearCompletedTasks() { // Only keep tasks that are still in the active polling map. this.tasks = this.tasks.filter(task => this.activePolls.has(task.id)); this.hasUnreadCompletedTasks = false; this._render(); } // [NEW SAFETY METHOD] _updateTaskItemInHistory(taskId, newData) { const taskInHistory = this.tasks.find(t => t.id === taskId); if (taskInHistory) { taskInHistory.data = newData; } } // --- 渲染与DOM操作 --- _render() { this.taskListContainer.innerHTML = this.tasks.map(task => this._createTaskItemHtml(task)).join(''); const hasTasks = this.tasks.length > 0; this.taskListContainer.classList.toggle('hidden', !hasTasks); this.emptyState.classList.toggle('hidden', hasTasks); this._updateIndicatorState(); } _createTaskItemHtml(task) { // [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器 const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo); return `
${this._capitalizeFirstLetter(type)}
${title}