339 lines
14 KiB
JavaScript
339 lines
14 KiB
JavaScript
/**
|
|
* @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 = '<i class="fas fa-check-circle text-success-500"></i>';
|
|
iconElement.className = "text-6xl mb-3 text-success-500";
|
|
} else {
|
|
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
|
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");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|