/** * @file ui.js * @description Centralizes UI component classes for modals and common UI patterns. * This module exports singleton instances of `ModalManager` and `UIPatterns` * to ensure consistent UI behavior across the application. */ /** * Manages the display and interaction of various modals across the application. * This class centralizes modal logic to ensure consistency and ease of use. * It assumes specific HTML structures for modals (e.g., resultModal, progressModal). */ class ModalManager { /** * Shows a generic modal by its ID. * @param {string} modalId The ID of the modal element to show. */ show(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.classList.remove("hidden"); } else { console.error(`Modal with ID "${modalId}" not found.`); } } /** * Hides a generic modal by its ID. * @param {string} modalId The ID of the modal element to hide. */ hide(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.classList.add("hidden"); } else { console.error(`Modal with ID "${modalId}" not found.`); } } /** * Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts. * It dynamically sets the title, message, and confirm action for a generic confirmation modal. * @param {object} options - The options for the confirmation modal. * @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal'). * @param {string} options.title - The title to display in the modal header. * @param {string} options.message - The message to display in the modal body. Can contain HTML. * @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked. * @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled. */ showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) { const modalElement = document.getElementById(modalId); if (!modalElement) { console.error(`Confirmation modal with ID "${modalId}" not found.`); return; } const titleElement = modalElement.querySelector('[id$="ModalTitle"]'); const messageElement = modalElement.querySelector('[id$="ModalMessage"]'); const confirmButton = modalElement.querySelector('[id^="confirm"]'); if (!titleElement || !messageElement || !confirmButton) { console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`); return; } titleElement.textContent = title; messageElement.innerHTML = message; confirmButton.disabled = disableConfirm; // Re-clone the button to remove old event listeners and attach the new one. const newConfirmButton = confirmButton.cloneNode(true); confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton); newConfirmButton.onclick = () => onConfirm(); this.show(modalId); } /** * Shows a result modal to indicate the outcome of an operation (success or failure). * @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators. * @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content. * @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed. */ showResult(success, message, autoReload = false) { const modalElement = document.getElementById("resultModal"); if (!modalElement) { console.error("Result modal with ID 'resultModal' not found."); return; } const titleElement = document.getElementById("resultModalTitle"); const messageElement = document.getElementById("resultModalMessage"); const iconElement = document.getElementById("resultIcon"); const confirmButton = document.getElementById("resultModalConfirmBtn"); if (!titleElement || !messageElement || !iconElement || !confirmButton) { console.error("Result modal is missing required child elements."); return; } titleElement.textContent = success ? "操作成功" : "操作失败"; if (success) { iconElement.innerHTML = ''; iconElement.className = "text-6xl mb-3 text-success-500"; } else { iconElement.innerHTML = ''; iconElement.className = "text-6xl mb-3 text-danger-500"; } messageElement.innerHTML = ""; if (typeof message === "string") { const messageDiv = document.createElement("div"); messageDiv.innerText = message; // Use innerText for security with plain strings messageElement.appendChild(messageDiv); } else if (message instanceof Node) { messageElement.appendChild(message); // Append if it's already a DOM node } else { const messageDiv = document.createElement("div"); messageDiv.innerText = String(message); messageElement.appendChild(messageDiv); } confirmButton.onclick = () => this.closeResult(autoReload); this.show("resultModal"); } /** * Closes the result modal. * @param {boolean} [reload=false] - If true, reloads the page after closing the modal. */ closeResult(reload = false) { this.hide("resultModal"); if (reload) { location.reload(); } } /** * Shows and initializes the progress modal for long-running operations. * @param {string} title - The title to display for the progress modal. */ showProgress(title) { const modal = document.getElementById("progressModal"); if (!modal) { console.error("Progress modal with ID 'progressModal' not found."); return; } const titleElement = document.getElementById("progressModalTitle"); const statusText = document.getElementById("progressStatusText"); const progressBar = document.getElementById("progressBar"); const progressPercentage = document.getElementById("progressPercentage"); const progressLog = document.getElementById("progressLog"); const closeButton = document.getElementById("progressModalCloseBtn"); const closeIcon = document.getElementById("closeProgressModalBtn"); if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) { console.error("Progress modal is missing required child elements."); return; } titleElement.textContent = title; statusText.textContent = "准备开始..."; progressBar.style.width = "0%"; progressPercentage.textContent = "0%"; progressLog.innerHTML = ""; closeButton.disabled = true; closeIcon.disabled = true; this.show("progressModal"); } /** * Updates the progress bar and status text within the progress modal. * @param {number} processed - The number of items that have been processed. * @param {number} total - The total number of items to process. * @param {string} status - The current status message to display. */ updateProgress(processed, total, status) { const modal = document.getElementById("progressModal"); if (!modal || modal.classList.contains('hidden')) return; const progressBar = document.getElementById("progressBar"); const progressPercentage = document.getElementById("progressPercentage"); const statusText = document.getElementById("progressStatusText"); const closeButton = document.getElementById("progressModalCloseBtn"); const closeIcon = document.getElementById("closeProgressModalBtn"); const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; progressBar.style.width = `${percentage}%`; progressPercentage.textContent = `${percentage}%`; statusText.textContent = status; if (processed === total) { closeButton.disabled = false; closeIcon.disabled = false; } } /** * Adds a log entry to the progress modal's log area. * @param {string} message - The log message to append. * @param {boolean} [isError=false] - If true, styles the log entry as an error. */ addProgressLog(message, isError = false) { const progressLog = document.getElementById("progressLog"); if (!progressLog) return; const logEntry = document.createElement("div"); logEntry.textContent = message; logEntry.className = isError ? "text-danger-600" : "text-gray-700"; progressLog.appendChild(logEntry); progressLog.scrollTop = progressLog.scrollHeight; // Auto-scroll to the latest log } /** * Closes the progress modal. * @param {boolean} [reload=false] - If true, reloads the page after closing. */ closeProgress(reload = false) { this.hide("progressModal"); if (reload) { location.reload(); } } } /** * Provides a collection of common UI patterns and animations. * This class includes helpers for creating engaging and consistent user experiences, * such as animated counters and collapsible sections. */ class UIPatterns { /** * Animates numerical values in elements from 0 to their target number. * The target number is read from the element's text content. * @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value'). * @param {number} [duration=1500] - The duration of the animation in milliseconds. */ animateCounters(selector = ".stat-value", duration = 1500) { const statValues = document.querySelectorAll(selector); statValues.forEach((valueElement) => { const finalValue = parseInt(valueElement.textContent, 10); if (isNaN(finalValue)) return; if (!valueElement.dataset.originalValue) { valueElement.dataset.originalValue = valueElement.textContent; } let startValue = 0; const startTime = performance.now(); const updateCounter = (currentTime) => { const elapsedTime = currentTime - startTime; if (elapsedTime < duration) { const progress = elapsedTime / duration; const easeOutValue = 1 - Math.pow(1 - progress, 3); // Ease-out cubic const currentValue = Math.floor(easeOutValue * finalValue); valueElement.textContent = currentValue; requestAnimationFrame(updateCounter); } else { valueElement.textContent = valueElement.dataset.originalValue; // Ensure final value is accurate } }; requestAnimationFrame(updateCounter); }); } /** * Toggles the visibility of a content section with a smooth height animation. * It expects a specific HTML structure where the header and content are within a common parent (e.g., a card). * The content element should have a `collapsed` class when hidden. * @param {HTMLElement} header - The header element that was clicked to trigger the toggle. */ toggleSection(header) { const card = header.closest(".stats-card"); if (!card) return; const content = card.querySelector(".key-content"); const toggleIcon = header.querySelector(".toggle-icon"); if (!content || !toggleIcon) { console.error("Toggle section failed: Content or icon element not found.", { header }); return; } const isCollapsed = content.classList.contains("collapsed"); toggleIcon.classList.toggle("collapsed", !isCollapsed); if (isCollapsed) { // Expand content.classList.remove("collapsed"); content.style.maxHeight = null; content.style.opacity = null; content.style.paddingTop = null; content.style.paddingBottom = null; content.style.overflow = "hidden"; requestAnimationFrame(() => { const targetHeight = content.scrollHeight; content.style.maxHeight = `${targetHeight}px`; content.style.opacity = "1"; content.style.paddingTop = "1rem"; // Assumes p-4, adjust if needed content.style.paddingBottom = "1rem"; content.addEventListener("transitionend", function onExpansionEnd() { content.removeEventListener("transitionend", onExpansionEnd); if (!content.classList.contains("collapsed")) { content.style.maxHeight = ""; content.style.overflow = "visible"; } }, { once: true }); }); } else { // Collapse const currentHeight = content.scrollHeight; content.style.maxHeight = `${currentHeight}px`; content.style.overflow = "hidden"; requestAnimationFrame(() => { content.style.maxHeight = "0px"; content.style.opacity = "0"; content.style.paddingTop = "0"; content.style.paddingBottom = "0"; content.classList.add("collapsed"); }); } } /** * Sets a button to a loading state by disabling it and showing a spinner. * It stores the button's original content to be restored later. * @param {HTMLButtonElement} button - The button element to modify. */ setButtonLoading(button) { if (!button) return; // Store original content if it hasn't been stored already if (!button.dataset.originalContent) { button.dataset.originalContent = button.innerHTML; } button.disabled = true; button.innerHTML = ''; } /** * Restores a button from its loading state to its original content and enables it. * @param {HTMLButtonElement} button - The button element to restore. */ clearButtonLoading(button) { if (!button) return; if (button.dataset.originalContent) { button.innerHTML = button.dataset.originalContent; // Clean up the data attribute delete button.dataset.originalContent; } button.disabled = false; } /** * Returns the HTML for a streaming text cursor animation. * This is used as a placeholder in the chat UI while waiting for an assistant's response. * @returns {string} The HTML string for the loader. */ renderStreamingLoader() { return ''; } } /** * Exports singleton instances of the UI component classes for easy import and use elsewhere. * This allows any part of the application to access the same instance of ModalManager and UIPatterns, * ensuring a single source of truth for UI component management. */ export const modalManager = new ModalManager(); export const uiPatterns = new UIPatterns();