502 lines
21 KiB
JavaScript
502 lines
21 KiB
JavaScript
/**
|
|
* @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 `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
|
|
}
|
|
_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 = `<i class="fas fa-check-circle text-white"></i>`;
|
|
iconContainer.className = `toast-icon bg-green-500`;
|
|
setTimeout(performFadeOut, 900);
|
|
}
|
|
});
|
|
} else { // Failure
|
|
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
|
|
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 = `
|
|
<div class="toast-icon ${typeInfo.class}">
|
|
<i class="fas ${typeInfo.icon}"></i>
|
|
</div>
|
|
<div class="toast-content">
|
|
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
|
|
<p class="toast-message">${message}</p>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="toast-icon bg-blue-500">
|
|
<i class="fas fa-spinner animate-spin"></i>
|
|
</div>
|
|
<div class="toast-content">
|
|
<p class="toast-title">${title}</p>
|
|
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
|
|
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
|
|
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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); |