Files
gemini-banlancer/frontend/js/components/ui.js
2025-11-20 12:24:05 +08:00

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();