541 lines
20 KiB
JavaScript
541 lines
20 KiB
JavaScript
// frontend/js/components/customSelect.js
|
|
var CustomSelect = class _CustomSelect {
|
|
constructor(container) {
|
|
this.container = container;
|
|
this.trigger = this.container.querySelector(".custom-select-trigger");
|
|
this.panel = this.container.querySelector(".custom-select-panel");
|
|
if (!this.trigger || !this.panel) {
|
|
console.warn("CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.", this.container);
|
|
return;
|
|
}
|
|
this.nativeSelect = this.container.querySelector("select");
|
|
this.triggerText = this.trigger.querySelector("span");
|
|
this.template = this.panel.querySelector(".custom-select-option-template");
|
|
if (typeof _CustomSelect.openInstance === "undefined") {
|
|
_CustomSelect.openInstance = null;
|
|
_CustomSelect.initGlobalListener();
|
|
}
|
|
if (this.nativeSelect) {
|
|
this.generateOptions();
|
|
this.updateTriggerText();
|
|
}
|
|
this.bindEvents();
|
|
}
|
|
static initGlobalListener() {
|
|
document.addEventListener("click", (event) => {
|
|
if (_CustomSelect.openInstance && !_CustomSelect.openInstance.container.contains(event.target)) {
|
|
_CustomSelect.openInstance.close();
|
|
}
|
|
});
|
|
}
|
|
generateOptions() {
|
|
this.panel.querySelectorAll(":scope > *:not(.custom-select-option-template)").forEach((child) => child.remove());
|
|
Array.from(this.nativeSelect.options).forEach((option) => {
|
|
let item;
|
|
if (this.template) {
|
|
item = this.template.cloneNode(true);
|
|
item.classList.remove("custom-select-option-template");
|
|
item.removeAttribute("hidden");
|
|
} else {
|
|
item = document.createElement("a");
|
|
item.href = "#";
|
|
item.className = "block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600";
|
|
}
|
|
item.classList.add("custom-select-option");
|
|
item.textContent = option.textContent;
|
|
item.dataset.value = option.value;
|
|
if (option.selected) {
|
|
item.classList.add("is-selected");
|
|
}
|
|
this.panel.appendChild(item);
|
|
});
|
|
}
|
|
bindEvents() {
|
|
this.trigger.addEventListener("click", (event) => {
|
|
if (this.trigger.classList.contains("is-disabled")) {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
if (_CustomSelect.openInstance && _CustomSelect.openInstance !== this) {
|
|
_CustomSelect.openInstance.close();
|
|
}
|
|
this.toggle();
|
|
});
|
|
if (this.nativeSelect) {
|
|
this.panel.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const option = event.target.closest(".custom-select-option");
|
|
if (option) {
|
|
this.selectOption(option);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
selectOption(optionEl) {
|
|
const selectedValue = optionEl.dataset.value;
|
|
if (this.nativeSelect.value !== selectedValue) {
|
|
this.nativeSelect.value = selectedValue;
|
|
this.nativeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
this.updateTriggerText();
|
|
this.panel.querySelectorAll(".custom-select-option").forEach((el) => el.classList.remove("is-selected"));
|
|
optionEl.classList.add("is-selected");
|
|
this.close();
|
|
}
|
|
updateTriggerText() {
|
|
if (!this.nativeSelect || !this.triggerText) return;
|
|
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
|
|
if (selectedOption) {
|
|
this.triggerText.textContent = selectedOption.textContent;
|
|
}
|
|
}
|
|
toggle() {
|
|
this.panel.classList.toggle("hidden");
|
|
if (this.panel.classList.contains("hidden")) {
|
|
if (_CustomSelect.openInstance === this) {
|
|
_CustomSelect.openInstance = null;
|
|
}
|
|
} else {
|
|
_CustomSelect.openInstance = this;
|
|
}
|
|
}
|
|
open() {
|
|
this.panel.classList.remove("hidden");
|
|
_CustomSelect.openInstance = this;
|
|
}
|
|
close() {
|
|
this.panel.classList.add("hidden");
|
|
if (_CustomSelect.openInstance === this) {
|
|
_CustomSelect.openInstance = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
// frontend/js/components/taskCenter.js
|
|
var TaskCenterManager = class {
|
|
constructor() {
|
|
this.tasks = [];
|
|
this.activePolls = /* @__PURE__ */ new Map();
|
|
this.heartbeatInterval = null;
|
|
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
|
|
this.hasUnreadCompletedTasks = false;
|
|
this.isAnimating = false;
|
|
this.countdownTimer = null;
|
|
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;
|
|
}
|
|
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();
|
|
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: /* @__PURE__ */ new Date(),
|
|
startTime: Date.now()
|
|
};
|
|
if (!initialTaskData.is_running) {
|
|
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
|
|
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
|
this.tasks.unshift(newTask);
|
|
this._render();
|
|
this._handleTaskCompletion(newTask);
|
|
return;
|
|
}
|
|
this.tasks.unshift(newTask);
|
|
this.activePolls.set(newTask.id, newTask);
|
|
this._render();
|
|
this.openPanel();
|
|
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
|
this._updateIndicatorState();
|
|
} catch (error) {
|
|
console.error("Failed to start task:", error);
|
|
toastManager.show(`\u4EFB\u52A1\u542F\u52A8\u5931\u8D25: ${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;
|
|
}
|
|
for (const taskId of [...this.activePolls.keys()]) {
|
|
const task = this.activePolls.get(taskId);
|
|
if (!task) continue;
|
|
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);
|
|
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();
|
|
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() {
|
|
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) {
|
|
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) {
|
|
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]");
|
|
this.countdownRing.style.transition = "none";
|
|
this.countdownRing.style.strokeDashoffset = "72.26";
|
|
void this.countdownRing.offsetHeight;
|
|
this.countdownRing.style.transition = "stroke-dashoffset 4.95s linear";
|
|
this.countdownRing.style.strokeDashoffset = "0";
|
|
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() {
|
|
this._stopCountdown();
|
|
if (this.hasUnreadCompletedTasks) {
|
|
this.hasUnreadCompletedTasks = false;
|
|
this._updateIndicatorState();
|
|
}
|
|
}
|
|
_formatTimeAgo(date) {
|
|
if (!date) return "";
|
|
const seconds = Math.floor((/* @__PURE__ */ new Date() - new Date(date)) / 1e3);
|
|
if (seconds < 2) return "\u521A\u521A";
|
|
if (seconds < 60) return `${seconds}\u79D2\u524D`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}\u5206\u949F\u524D`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}\u5C0F\u65F6\u524D`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}\u5929\u524D`;
|
|
}
|
|
};
|
|
var ToastManager = class {
|
|
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 = /* @__PURE__ */ new Map();
|
|
}
|
|
/**
|
|
* 显示一个 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 = 4e3) {
|
|
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");
|
|
});
|
|
setTimeout(() => {
|
|
toastElement.classList.remove("opacity-100", "translate-y-0");
|
|
toastElement.classList.add("opacity-0", "translate-y-2");
|
|
toastElement.addEventListener("transitionend", () => toastElement.remove(), { once: true });
|
|
}, duration);
|
|
}
|
|
// [NEW] 创建或更新一个带进度条的Toast
|
|
showProgressToast(toastId, title, message, progress) {
|
|
if (this.activeToasts.has(toastId)) {
|
|
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 {
|
|
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();
|
|
}, { once: true });
|
|
};
|
|
if (success === null) {
|
|
performFadeOut();
|
|
} else {
|
|
const iconContainer = toastElement.querySelector(".toast-icon");
|
|
const messageEl = toastElement.querySelector(".toast-message");
|
|
if (success) {
|
|
const progressBar = toastElement.querySelector(".toast-progress-bar");
|
|
messageEl.textContent = "\u5DF2\u5B8C\u6210";
|
|
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 {
|
|
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
|
|
iconContainer.className = `toast-icon bg-red-500`;
|
|
messageEl.textContent = "\u5931\u8D25";
|
|
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);
|
|
}
|
|
};
|
|
var taskCenterManager = new TaskCenterManager();
|
|
var toastManager = new ToastManager();
|
|
|
|
export {
|
|
CustomSelect,
|
|
taskCenterManager,
|
|
toastManager
|
|
};
|