/** * @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 `
${innerHtml}
`; } _updateTaskItemInDom(task) { const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`); if (item) { // [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器 item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo); } } // --- 核心面板开关逻辑 --- openPanel() { if (this.isAnimating || !this.panel.classList.contains('hidden')) return; this.isAnimating = true; this.panel.classList.remove('hidden'); this.panel.classList.add('animate-panel-in'); // 动画结束后,启动倒计时 setTimeout(() => { this.panel.classList.remove('animate-panel-in'); this.isAnimating = false; this._startCountdown(); // 启动倒计时 }, 150); } closePanel() { if (this.isAnimating || this.panel.classList.contains('hidden')) return; this._stopCountdown(); // [修改] 关闭前立即停止倒计时 this.isAnimating = true; this.panel.classList.add('animate-panel-out'); setTimeout(() => { this.panel.classList.remove('animate-panel-out'); this.panel.classList.add('hidden'); this.isAnimating = false; }, 50); } // --- [新增] 倒计时管理方法 --- /** * 启动或重启倒计时和进度条动画 * @private */ _startCountdown() { this._stopCountdown(); // 先重置 // 启动进度条动画 this.countdownBar.classList.add('w-full', 'duration-[4950ms]'); // 启动圆环动画 (通过可靠的JS强制重绘) this.countdownRing.style.transition = 'none'; // 1. 禁用动画 this.countdownRing.style.strokeDashoffset = '72.26'; // 2. 立即重置 void this.countdownRing.offsetHeight; // 3. 强制浏览器重排 this.countdownRing.style.transition = 'stroke-dashoffset 4.95s linear'; // 4. 重新启用动画 this.countdownRing.style.strokeDashoffset = '0'; // 5. 设置目标值,开始动画 // 启动关闭计时器 this.countdownTimer = setTimeout(() => { this.closePanel(); }, 4950); } /** * 停止倒计时并重置进度条 * @private */ _stopCountdown() { if (this.countdownTimer) { clearTimeout(this.countdownTimer); this.countdownTimer = null; } // 重置进度条的视觉状态 this.countdownBar.classList.remove('w-full'); this.countdownRing.style.transition = 'none'; this.countdownRing.style.strokeDashoffset = '72.26'; } // [NEW] A central handler for any action that confirms the user has seen the panel. _handleUserInteraction() { // 1. Stop the auto-close countdown because the user is now interacting. this._stopCountdown(); // 2. If there were unread tasks, mark them as read *now*. if (this.hasUnreadCompletedTasks) { this.hasUnreadCompletedTasks = false; this._updateIndicatorState(); // The indicator light turns off at this moment. } } _formatTimeAgo(date) { if (!date) return ''; const seconds = Math.floor((new Date() - new Date(date)) / 1000); if (seconds < 2) return "刚刚"; if (seconds < 60) return `${seconds}秒前`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}分钟前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); return `${days}天前`; } } // =================================================================== // [NEW] Toast 通知管理器 // =================================================================== class ToastManager { constructor() { this.container = document.getElementById('toast-container'); if (!this.container) { this.container = document.createElement('div'); this.container.id = 'toast-container'; this.container.className = 'fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3'; // 宽度可稍大 document.body.appendChild(this.container); } this.activeToasts = new Map(); // [NEW] 用于跟踪可更新的进度Toast } /** * 显示一个 Toast 通知 * @param {string} message - The message to display. * @param {string} [type='info'] - 'info', 'success', or 'error'. * @param {number} [duration=4000] - Duration in milliseconds. */ show(message, type = 'info', duration = 4000) { const toastElement = this._createToastHtml(message, type); this.container.appendChild(toastElement); // 强制重绘以触发入场动画 requestAnimationFrame(() => { toastElement.classList.remove('opacity-0', 'translate-y-2'); toastElement.classList.add('opacity-100', 'translate-y-0'); }); // 设置定时器以移除 Toast setTimeout(() => { toastElement.classList.remove('opacity-100', 'translate-y-0'); toastElement.classList.add('opacity-0', 'translate-y-2'); // 在动画结束后从 DOM 中移除 toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); }, duration); } // [NEW] 创建或更新一个带进度条的Toast showProgressToast(toastId, title, message, progress) { if (this.activeToasts.has(toastId)) { // --- 更新现有Toast --- const toastElement = this.activeToasts.get(toastId); const messageEl = toastElement.querySelector('.toast-message'); const progressBar = toastElement.querySelector('.toast-progress-bar'); messageEl.textContent = `${message} - ${Math.round(progress)}%`; anime({ targets: progressBar, width: `${progress}%`, duration: 400, easing: 'easeOutQuad' }); } else { // --- 创建新的Toast --- const toastElement = this._createProgressToastHtml(toastId, title, message, progress); this.container.appendChild(toastElement); this.activeToasts.set(toastId, toastElement); requestAnimationFrame(() => { toastElement.classList.remove('opacity-0', 'translate-x-full'); toastElement.classList.add('opacity-100', 'translate-x-0'); }); } } // [NEW] 移除一个进度Toast dismiss(toastId, success = null) { return new Promise((resolve) => { if (!this.activeToasts.has(toastId)) { resolve(); return; } const toastElement = this.activeToasts.get(toastId); const performFadeOut = () => { toastElement.classList.remove('opacity-100', 'translate-x-0'); toastElement.classList.add('opacity-0', 'translate-x-full'); toastElement.addEventListener('transitionend', () => { toastElement.remove(); this.activeToasts.delete(toastId); resolve(); // Resolve the promise ONLY when the element is fully gone. }, { once: true }); }; if (success === null) { // Immediate dismissal performFadeOut(); } else { // Graceful, animated dismissal const iconContainer = toastElement.querySelector('.toast-icon'); const messageEl = toastElement.querySelector('.toast-message'); if (success) { const progressBar = toastElement.querySelector('.toast-progress-bar'); messageEl.textContent = '已完成'; anime({ targets: progressBar, width: '100%', duration: 300, easing: 'easeOutQuad', complete: () => { iconContainer.innerHTML = ``; iconContainer.className = `toast-icon bg-green-500`; setTimeout(performFadeOut, 900); } }); } else { // Failure iconContainer.innerHTML = ``; iconContainer.className = `toast-icon bg-red-500`; messageEl.textContent = '失败'; setTimeout(performFadeOut, 1200); } } }); } _createToastHtml(message, type) { const icons = { info: { class: 'bg-blue-500', icon: 'fa-info-circle' }, success: { class: 'bg-green-500', icon: 'fa-check-circle' }, error: { class: 'bg-red-500', icon: 'fa-exclamation-triangle' } }; const typeInfo = icons[type] || icons.info; const toast = document.createElement('div'); toast.className = 'toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out'; // 初始状态为动画准备 toast.innerHTML = `

${this._capitalizeFirstLetter(type)}

${message}

`; return toast; } // [NEW] 创建带进度条Toast的HTML结构 _createProgressToastHtml(toastId, title, message, progress) { const toast = document.createElement('div'); toast.className = 'toast-item opacity-0 translate-x-full transition-all duration-300 ease-out'; toast.dataset.toastId = toastId; toast.innerHTML = `

${title}

${message} - ${Math.round(progress)}%

`; return toast; } _capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } } export const taskCenterManager = new TaskCenterManager(); export const toastManager = new ToastManager(); // [OPTIONAL] 为了向后兼容或简化调用,可以导出一个独立的 showToast 函数 export const showToast = (message, type, duration) => toastManager.show(message, type, duration);