394 lines
16 KiB
JavaScript
394 lines
16 KiB
JavaScript
// frontend/js/components/ui.js
|
|
var ModalManager = class {
|
|
/**
|
|
* 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;
|
|
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 ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25";
|
|
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;
|
|
messageElement.appendChild(messageDiv);
|
|
} else if (message instanceof Node) {
|
|
messageElement.appendChild(message);
|
|
} 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 = "\u51C6\u5907\u5F00\u59CB...";
|
|
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;
|
|
}
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|
|
};
|
|
var UIPatterns = class {
|
|
/**
|
|
* 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);
|
|
const currentValue = Math.floor(easeOutValue * finalValue);
|
|
valueElement.textContent = currentValue;
|
|
requestAnimationFrame(updateCounter);
|
|
} else {
|
|
valueElement.textContent = valueElement.dataset.originalValue;
|
|
}
|
|
};
|
|
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) {
|
|
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";
|
|
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 {
|
|
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;
|
|
if (!button.dataset.originalContent) {
|
|
button.dataset.originalContent = button.innerHTML;
|
|
}
|
|
button.disabled = true;
|
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
}
|
|
/**
|
|
* 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;
|
|
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 '<span class="streaming-cursor animate-pulse">\u258B</span>';
|
|
}
|
|
};
|
|
var modalManager = new ModalManager();
|
|
var uiPatterns = new UIPatterns();
|
|
|
|
// frontend/js/services/api.js
|
|
var APIClientError = class extends Error {
|
|
constructor(message, status, code, rawMessageFromServer) {
|
|
super(message);
|
|
this.name = "APIClientError";
|
|
this.status = status;
|
|
this.code = code;
|
|
this.rawMessageFromServer = rawMessageFromServer;
|
|
}
|
|
};
|
|
var apiPromiseCache = /* @__PURE__ */ new Map();
|
|
async function apiFetch(url, options = {}) {
|
|
const isGetRequest = !options.method || options.method.toUpperCase() === "GET";
|
|
const cacheKey = isGetRequest && !options.noCache ? url : null;
|
|
if (cacheKey && apiPromiseCache.has(cacheKey)) {
|
|
return apiPromiseCache.get(cacheKey);
|
|
}
|
|
const token = localStorage.getItem("bearerToken");
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...options.headers
|
|
};
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
const requestPromise = (async () => {
|
|
try {
|
|
const response = await fetch(url, { ...options, headers });
|
|
if (response.status === 401) {
|
|
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
|
localStorage.removeItem("bearerToken");
|
|
if (window.location.pathname !== "/login") {
|
|
window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002";
|
|
}
|
|
throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid.");
|
|
}
|
|
if (!response.ok) {
|
|
let errorData = null;
|
|
let rawMessage = "";
|
|
try {
|
|
rawMessage = await response.text();
|
|
if (rawMessage) {
|
|
errorData = JSON.parse(rawMessage);
|
|
}
|
|
} catch (e) {
|
|
errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } };
|
|
}
|
|
const code = errorData?.error?.code || "UNKNOWN_ERROR";
|
|
const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server.";
|
|
const error = new APIClientError(
|
|
`API request failed: ${response.status}`,
|
|
response.status,
|
|
code,
|
|
messageFromServer
|
|
);
|
|
throw error;
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
|
throw error;
|
|
}
|
|
})();
|
|
if (cacheKey) {
|
|
apiPromiseCache.set(cacheKey, requestPromise);
|
|
}
|
|
return requestPromise;
|
|
}
|
|
async function apiFetchJson(url, options = {}) {
|
|
try {
|
|
const response = await apiFetch(url, options);
|
|
const clonedResponse = response.clone();
|
|
const jsonData = await clonedResponse.json();
|
|
return jsonData;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export {
|
|
modalManager,
|
|
uiPatterns,
|
|
apiFetch,
|
|
apiFetchJson
|
|
};
|