Spaces:
Paused
Paused
| // 错误日志页面JavaScript (Updated for new structure, no Bootstrap) | |
| // 页面滚动功能 | |
| function scrollToTop() { | |
| window.scrollTo({ top: 0, behavior: "smooth" }); | |
| } | |
| function scrollToBottom() { | |
| window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); | |
| } | |
| // API 调用辅助函数 | |
| async function fetchAPI(url, options = {}) { | |
| try { | |
| const response = await fetch(url, options); | |
| // Handle cases where response might be empty but still ok (e.g., 204 No Content for DELETE) | |
| if (response.status === 204) { | |
| return null; // Indicate success with no content | |
| } | |
| let responseData; | |
| try { | |
| responseData = await response.json(); | |
| } catch (e) { | |
| // Handle non-JSON responses if necessary, or assume error if JSON expected | |
| if (!response.ok) { | |
| // If response is not ok and not JSON, use statusText | |
| throw new Error( | |
| `HTTP error! status: ${response.status} - ${response.statusText}` | |
| ); | |
| } | |
| // If response is ok but not JSON, maybe return raw text or handle differently | |
| // For now, let's assume successful non-JSON is not expected or handled later | |
| console.warn("Response was not JSON for URL:", url); | |
| return await response.text(); // Or handle as needed | |
| } | |
| if (!response.ok) { | |
| // Prefer error message from API response body if available | |
| const message = | |
| responseData?.detail || | |
| `HTTP error! status: ${response.status} - ${response.statusText}`; | |
| throw new Error(message); | |
| } | |
| return responseData; // Return parsed JSON data for successful responses | |
| } catch (error) { | |
| // Catch network errors or errors thrown from above | |
| console.error( | |
| "API Call Failed:", | |
| error.message, | |
| "URL:", | |
| url, | |
| "Options:", | |
| options | |
| ); | |
| // Re-throw the error so the calling function knows the operation failed | |
| throw error; | |
| } | |
| } | |
| // Refresh function removed as the buttons are gone. | |
| // If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs(). | |
| // 全局状态管理 | |
| let errorLogState = { | |
| currentPage: 1, | |
| pageSize: 10, | |
| logs: [], // 存储获取的日志 | |
| sort: { | |
| field: "id", // 默认按 ID 排序 | |
| order: "desc", // 默认降序 | |
| }, | |
| search: { | |
| key: "", | |
| error: "", | |
| errorCode: "", | |
| startDate: "", | |
| endDate: "", | |
| }, | |
| }; | |
| // DOM Elements Cache | |
| let pageSizeSelector; | |
| // let refreshBtn; // Removed, as the button is deleted | |
| let tableBody; | |
| let paginationElement; | |
| let loadingIndicator; | |
| let noDataMessage; | |
| let errorMessage; | |
| let logDetailModal; | |
| let modalCloseBtns; // Collection of close buttons for the modal | |
| let keySearchInput; | |
| let errorSearchInput; | |
| let errorCodeSearchInput; // Added error code input | |
| let startDateInput; | |
| let endDateInput; | |
| let searchBtn; | |
| let pageInput; | |
| let goToPageBtn; | |
| let selectAllCheckbox; // 新增:全选复选框 | |
| let copySelectedKeysBtn; // 新增:复制选中按钮 | |
| let deleteSelectedBtn; // 新增:批量删除按钮 | |
| let sortByIdHeader; // 新增:ID 排序表头 | |
| let sortIcon; // 新增:排序图标 | |
| let selectedCountSpan; // 新增:选中计数显示 | |
| let deleteConfirmModal; // 新增:删除确认模态框 | |
| let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮 | |
| let cancelDeleteBtn; // 新增:取消删除按钮 | |
| let confirmDeleteBtn; // 新增:确认删除按钮 | |
| let deleteConfirmMessage; // 新增:删除确认消息元素 | |
| let idsToDeleteGlobally = []; // 新增:存储待删除的ID | |
| let currentConfirmCallback = null; // 新增:存储当前的确认回调 | |
| let deleteAllLogsBtn; // 新增:清空全部按钮 | |
| // Helper functions for initialization | |
| function cacheDOMElements() { | |
| pageSizeSelector = document.getElementById("pageSize"); | |
| tableBody = document.getElementById("errorLogsTable"); | |
| paginationElement = document.getElementById("pagination"); | |
| loadingIndicator = document.getElementById("loadingIndicator"); | |
| noDataMessage = document.getElementById("noDataMessage"); | |
| errorMessage = document.getElementById("errorMessage"); | |
| logDetailModal = document.getElementById("logDetailModal"); | |
| modalCloseBtns = document.querySelectorAll( | |
| "#closeLogDetailModalBtn, #closeModalFooterBtn" | |
| ); | |
| keySearchInput = document.getElementById("keySearch"); | |
| errorSearchInput = document.getElementById("errorSearch"); | |
| errorCodeSearchInput = document.getElementById("errorCodeSearch"); | |
| startDateInput = document.getElementById("startDate"); | |
| endDateInput = document.getElementById("endDate"); | |
| searchBtn = document.getElementById("searchBtn"); | |
| pageInput = document.getElementById("pageInput"); | |
| goToPageBtn = document.getElementById("goToPageBtn"); | |
| selectAllCheckbox = document.getElementById("selectAllCheckbox"); | |
| copySelectedKeysBtn = document.getElementById("copySelectedKeysBtn"); | |
| deleteSelectedBtn = document.getElementById("deleteSelectedBtn"); | |
| sortByIdHeader = document.getElementById("sortById"); | |
| if (sortByIdHeader) { | |
| sortIcon = sortByIdHeader.querySelector("i"); | |
| } | |
| selectedCountSpan = document.getElementById("selectedCount"); | |
| deleteConfirmModal = document.getElementById("deleteConfirmModal"); | |
| closeDeleteConfirmModalBtn = document.getElementById( | |
| "closeDeleteConfirmModalBtn" | |
| ); | |
| cancelDeleteBtn = document.getElementById("cancelDeleteBtn"); | |
| confirmDeleteBtn = document.getElementById("confirmDeleteBtn"); | |
| deleteConfirmMessage = document.getElementById("deleteConfirmMessage"); | |
| deleteAllLogsBtn = document.getElementById("deleteAllLogsBtn"); // 缓存清空全部按钮 | |
| } | |
| function initializePageSizeControls() { | |
| if (pageSizeSelector) { | |
| pageSizeSelector.value = errorLogState.pageSize; | |
| pageSizeSelector.addEventListener("change", function () { | |
| errorLogState.pageSize = parseInt(this.value); | |
| errorLogState.currentPage = 1; // Reset to first page | |
| loadErrorLogs(); | |
| }); | |
| } | |
| } | |
| function initializeSearchControls() { | |
| if (searchBtn) { | |
| searchBtn.addEventListener("click", function () { | |
| errorLogState.search.key = keySearchInput | |
| ? keySearchInput.value.trim() | |
| : ""; | |
| errorLogState.search.error = errorSearchInput | |
| ? errorSearchInput.value.trim() | |
| : ""; | |
| errorLogState.search.errorCode = errorCodeSearchInput | |
| ? errorCodeSearchInput.value.trim() | |
| : ""; | |
| errorLogState.search.startDate = startDateInput | |
| ? startDateInput.value | |
| : ""; | |
| errorLogState.search.endDate = endDateInput ? endDateInput.value : ""; | |
| errorLogState.currentPage = 1; // Reset to first page on new search | |
| loadErrorLogs(); | |
| }); | |
| } | |
| } | |
| function initializeModalControls() { | |
| // Log Detail Modal | |
| if (logDetailModal && modalCloseBtns) { | |
| modalCloseBtns.forEach((btn) => { | |
| btn.addEventListener("click", closeLogDetailModal); | |
| }); | |
| logDetailModal.addEventListener("click", function (event) { | |
| if (event.target === logDetailModal) { | |
| closeLogDetailModal(); | |
| } | |
| }); | |
| } | |
| // Delete Confirm Modal | |
| if (closeDeleteConfirmModalBtn) { | |
| closeDeleteConfirmModalBtn.addEventListener( | |
| "click", | |
| hideDeleteConfirmModal | |
| ); | |
| } | |
| if (cancelDeleteBtn) { | |
| cancelDeleteBtn.addEventListener("click", hideDeleteConfirmModal); | |
| } | |
| if (confirmDeleteBtn) { | |
| confirmDeleteBtn.addEventListener("click", handleConfirmDelete); | |
| } | |
| if (deleteConfirmModal) { | |
| deleteConfirmModal.addEventListener("click", function (event) { | |
| if (event.target === deleteConfirmModal) { | |
| hideDeleteConfirmModal(); | |
| } | |
| }); | |
| } | |
| } | |
| function initializePaginationJumpControls() { | |
| if (goToPageBtn && pageInput) { | |
| goToPageBtn.addEventListener("click", function () { | |
| const targetPage = parseInt(pageInput.value); | |
| if (!isNaN(targetPage) && targetPage >= 1) { | |
| errorLogState.currentPage = targetPage; | |
| loadErrorLogs(); | |
| pageInput.value = ""; | |
| } else { | |
| showNotification("请输入有效的页码", "error", 2000); | |
| pageInput.value = ""; | |
| } | |
| }); | |
| pageInput.addEventListener("keypress", function (event) { | |
| if (event.key === "Enter") { | |
| goToPageBtn.click(); | |
| } | |
| }); | |
| } | |
| } | |
| function initializeActionControls() { | |
| if (deleteSelectedBtn) { | |
| deleteSelectedBtn.addEventListener("click", handleDeleteSelected); | |
| } | |
| if (sortByIdHeader) { | |
| sortByIdHeader.addEventListener("click", handleSortById); | |
| } | |
| // Bulk selection listeners are closely related to actions | |
| setupBulkSelectionListeners(); | |
| // 为 "清空全部" 按钮添加事件监听器 | |
| if (deleteAllLogsBtn) { | |
| deleteAllLogsBtn.addEventListener("click", function() { | |
| const message = "您确定要清空所有错误日志吗?此操作不可恢复!"; | |
| showDeleteConfirmModal(message, handleDeleteAllLogs); // 传入回调 | |
| }); | |
| } | |
| } | |
| // 新增:处理 "清空全部" 逻辑的函数 | |
| async function handleDeleteAllLogs() { | |
| const url = "/api/logs/errors/all"; | |
| const options = { | |
| method: "DELETE", | |
| }; | |
| try { | |
| await fetchAPI(url, options); | |
| showNotification("所有错误日志已成功清空", "success"); | |
| if (selectAllCheckbox) selectAllCheckbox.checked = false; // 取消全选 | |
| loadErrorLogs(); // 重新加载日志 | |
| } catch (error) { | |
| console.error("清空所有错误日志失败:", error); | |
| showNotification(`清空失败: ${error.message}`, "error", 5000); | |
| } | |
| } | |
| // 页面加载完成后执行 | |
| document.addEventListener("DOMContentLoaded", function () { | |
| cacheDOMElements(); | |
| initializePageSizeControls(); | |
| initializeSearchControls(); | |
| initializeModalControls(); | |
| initializePaginationJumpControls(); | |
| initializeActionControls(); | |
| // Initial load of error logs | |
| loadErrorLogs(); | |
| // Add event listeners for copy buttons inside the modal and table | |
| // This needs to be called after initial render and potentially after each render if content is dynamic | |
| setupCopyButtons(); | |
| }); | |
| // 新增:显示删除确认模态框 | |
| function showDeleteConfirmModal(message, confirmCallback) { | |
| if (deleteConfirmModal && deleteConfirmMessage) { | |
| deleteConfirmMessage.textContent = message; | |
| currentConfirmCallback = confirmCallback; // 存储回调 | |
| deleteConfirmModal.classList.add("show"); | |
| document.body.style.overflow = "hidden"; // Prevent body scrolling | |
| } | |
| } | |
| // 新增:隐藏删除确认模态框 | |
| function hideDeleteConfirmModal() { | |
| if (deleteConfirmModal) { | |
| deleteConfirmModal.classList.remove("show"); | |
| document.body.style.overflow = ""; // Restore body scrolling | |
| idsToDeleteGlobally = []; // 清空待删除ID | |
| currentConfirmCallback = null; // 清除回调 | |
| } | |
| } | |
| // 新增:处理确认删除按钮点击 | |
| function handleConfirmDelete() { | |
| if (typeof currentConfirmCallback === 'function') { | |
| currentConfirmCallback(); // 调用存储的回调 | |
| } | |
| hideDeleteConfirmModal(); // 关闭模态框 | |
| } | |
| // Fallback copy function using document.execCommand | |
| function fallbackCopyTextToClipboard(text) { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = text; | |
| // Avoid scrolling to bottom | |
| textArea.style.top = "0"; | |
| textArea.style.left = "0"; | |
| textArea.style.position = "fixed"; | |
| document.body.appendChild(textArea); | |
| textArea.focus(); | |
| textArea.select(); | |
| let successful = false; | |
| try { | |
| successful = document.execCommand("copy"); | |
| } catch (err) { | |
| console.error("Fallback copy failed:", err); | |
| successful = false; | |
| } | |
| document.body.removeChild(textArea); | |
| return successful; | |
| } | |
| // Helper function to handle feedback after copy attempt (both modern and fallback) | |
| function handleCopyResult(buttonElement, success) { | |
| const originalIcon = buttonElement.querySelector("i").className; // Store original icon class | |
| const iconElement = buttonElement.querySelector("i"); | |
| if (success) { | |
| iconElement.className = "fas fa-check text-success-500"; // Use checkmark icon class | |
| showNotification("已复制到剪贴板", "success", 2000); | |
| } else { | |
| iconElement.className = "fas fa-times text-danger-500"; // Use error icon class | |
| showNotification("复制失败", "error", 3000); | |
| } | |
| setTimeout( | |
| () => { | |
| iconElement.className = originalIcon; | |
| }, | |
| success ? 2000 : 3000 | |
| ); // Restore original icon class | |
| } | |
| // 新的内部辅助函数,封装实际的复制操作和反馈 | |
| function _performCopy(text, buttonElement) { | |
| let copySuccess = false; | |
| if (navigator.clipboard && window.isSecureContext) { | |
| navigator.clipboard | |
| .writeText(text) | |
| .then(() => { | |
| if (buttonElement) { | |
| handleCopyResult(buttonElement, true); | |
| } else { | |
| showNotification("已复制到剪贴板", "success"); | |
| } | |
| }) | |
| .catch((err) => { | |
| console.error("Clipboard API failed, attempting fallback:", err); | |
| copySuccess = fallbackCopyTextToClipboard(text); | |
| if (buttonElement) { | |
| handleCopyResult(buttonElement, copySuccess); | |
| } else { | |
| showNotification( | |
| copySuccess ? "已复制到剪贴板" : "复制失败", | |
| copySuccess ? "success" : "error" | |
| ); | |
| } | |
| }); | |
| } else { | |
| console.warn( | |
| "Clipboard API not available or context insecure. Using fallback copy method." | |
| ); | |
| copySuccess = fallbackCopyTextToClipboard(text); | |
| if (buttonElement) { | |
| handleCopyResult(buttonElement, copySuccess); | |
| } else { | |
| showNotification( | |
| copySuccess ? "已复制到剪贴板" : "复制失败", | |
| copySuccess ? "success" : "error" | |
| ); | |
| } | |
| } | |
| } | |
| // Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons | |
| function setupCopyButtons(containerSelector = "body") { | |
| // Find buttons within the specified container (defaults to body) | |
| const container = document.querySelector(containerSelector); | |
| if (!container) return; | |
| const copyButtons = container.querySelectorAll(".copy-btn"); | |
| copyButtons.forEach((button) => { | |
| // Remove existing listener to prevent duplicates if called multiple times | |
| button.removeEventListener("click", handleCopyButtonClick); | |
| // Add the listener | |
| button.addEventListener("click", handleCopyButtonClick); | |
| }); | |
| } | |
| // Extracted click handler logic for reusability and removing listeners | |
| function handleCopyButtonClick() { | |
| const button = this; // 'this' refers to the button clicked | |
| const targetId = button.getAttribute("data-target"); | |
| const textToCopyDirect = button.getAttribute("data-copy-text"); // For direct text copy (e.g., table key) | |
| let textToCopy = ""; | |
| if (textToCopyDirect) { | |
| textToCopy = textToCopyDirect; | |
| } else if (targetId) { | |
| const targetElement = document.getElementById(targetId); | |
| if (targetElement) { | |
| textToCopy = targetElement.textContent; | |
| } else { | |
| console.error("Target element not found:", targetId); | |
| showNotification("复制出错:找不到目标元素", "error"); | |
| return; // Exit if target element not found | |
| } | |
| } else { | |
| console.error( | |
| "No data-target or data-copy-text attribute found on button:", | |
| button | |
| ); | |
| showNotification("复制出错:未指定复制内容", "error"); | |
| return; // Exit if no source specified | |
| } | |
| if (textToCopy) { | |
| _performCopy(textToCopy, button); // 使用新的辅助函数 | |
| } else { | |
| console.warn( | |
| "No text found to copy for target:", | |
| targetId || "direct text" | |
| ); | |
| showNotification("没有内容可复制", "warning"); | |
| } | |
| } // End of handleCopyButtonClick function | |
| // 新增:设置批量选择相关的事件监听器 | |
| function setupBulkSelectionListeners() { | |
| if (selectAllCheckbox) { | |
| selectAllCheckbox.addEventListener("change", handleSelectAllChange); | |
| } | |
| if (tableBody) { | |
| // 使用事件委托处理行复选框的点击 | |
| tableBody.addEventListener("change", handleRowCheckboxChange); | |
| } | |
| if (copySelectedKeysBtn) { | |
| copySelectedKeysBtn.addEventListener("click", handleCopySelectedKeys); | |
| } | |
| // 新增:为批量删除按钮添加事件监听器 (如果尚未添加) | |
| // 通常在 DOMContentLoaded 中添加一次即可 | |
| // if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) { | |
| // deleteSelectedBtn.addEventListener('click', handleDeleteSelected); | |
| // deleteSelectedBtn.hasListener = true; // 标记已添加 | |
| // } | |
| } | |
| // 新增:处理"全选"复选框变化的函数 | |
| function handleSelectAllChange() { | |
| const isChecked = selectAllCheckbox.checked; | |
| const rowCheckboxes = tableBody.querySelectorAll(".row-checkbox"); | |
| rowCheckboxes.forEach((checkbox) => { | |
| checkbox.checked = isChecked; | |
| }); | |
| updateSelectedState(); | |
| } | |
| // 新增:处理行复选框变化的函数 (事件委托) | |
| function handleRowCheckboxChange(event) { | |
| if (event.target.classList.contains("row-checkbox")) { | |
| updateSelectedState(); | |
| } | |
| } | |
| // 新增:更新选中状态(计数、按钮状态、全选框状态) | |
| function updateSelectedState() { | |
| const rowCheckboxes = tableBody.querySelectorAll(".row-checkbox"); | |
| const selectedCheckboxes = tableBody.querySelectorAll( | |
| ".row-checkbox:checked" | |
| ); | |
| const selectedCount = selectedCheckboxes.length; | |
| // 移除了数字显示,不再更新selectedCountSpan | |
| // 仍然更新复制按钮的禁用状态 | |
| if (copySelectedKeysBtn) { | |
| copySelectedKeysBtn.disabled = selectedCount === 0; | |
| // 可选:根据选中项数量更新按钮标题属性 | |
| copySelectedKeysBtn.setAttribute("title", `复制${selectedCount}项选中密钥`); | |
| } | |
| // 新增:更新批量删除按钮的禁用状态 | |
| if (deleteSelectedBtn) { | |
| deleteSelectedBtn.disabled = selectedCount === 0; | |
| deleteSelectedBtn.setAttribute("title", `删除${selectedCount}项选中日志`); | |
| } | |
| // 更新"全选"复选框的状态 | |
| if (selectAllCheckbox) { | |
| if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) { | |
| selectAllCheckbox.checked = true; | |
| selectAllCheckbox.indeterminate = false; | |
| } else if (selectedCount > 0) { | |
| selectAllCheckbox.checked = false; | |
| selectAllCheckbox.indeterminate = true; // 部分选中状态 | |
| } else { | |
| selectAllCheckbox.checked = false; | |
| selectAllCheckbox.indeterminate = false; | |
| } | |
| } | |
| } | |
| // 新增:处理"复制选中密钥"按钮点击的函数 | |
| function handleCopySelectedKeys() { | |
| const selectedCheckboxes = tableBody.querySelectorAll( | |
| ".row-checkbox:checked" | |
| ); | |
| const keysToCopy = []; | |
| selectedCheckboxes.forEach((checkbox) => { | |
| const key = checkbox.getAttribute("data-key"); | |
| if (key) { | |
| keysToCopy.push(key); | |
| } | |
| }); | |
| if (keysToCopy.length > 0) { | |
| const textToCopy = keysToCopy.join("\n"); // 每行一个密钥 | |
| _performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数 | |
| } else { | |
| showNotification("没有选中的密钥可复制", "warning"); | |
| } | |
| } | |
| // 修改:处理批量删除按钮点击的函数 - 改为显示模态框 | |
| function handleDeleteSelected() { | |
| const selectedCheckboxes = tableBody.querySelectorAll( | |
| ".row-checkbox:checked" | |
| ); | |
| const logIdsToDelete = []; | |
| selectedCheckboxes.forEach((checkbox) => { | |
| const logId = checkbox.getAttribute("data-log-id"); // 需要在渲染时添加 data-log-id | |
| if (logId) { | |
| logIdsToDelete.push(parseInt(logId)); | |
| } | |
| }); | |
| if (logIdsToDelete.length === 0) { | |
| showNotification("没有选中的日志可删除", "warning"); | |
| return; | |
| } | |
| if (logIdsToDelete.length === 0) { | |
| showNotification("没有选中的日志可删除", "warning"); | |
| return; | |
| } | |
| // 存储待删除ID并显示模态框 | |
| idsToDeleteGlobally = logIdsToDelete; // 仍然需要设置,因为 performActualDelete 会用到 | |
| const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`; | |
| showDeleteConfirmModal(message, function() { // 传入匿名回调 | |
| performActualDelete(idsToDeleteGlobally); | |
| }); | |
| } | |
| // 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow) | |
| async function performActualDelete(logIds) { | |
| if (!logIds || logIds.length === 0) return; | |
| const isSingleDelete = logIds.length === 1; | |
| const url = isSingleDelete | |
| ? `/api/logs/errors/${logIds[0]}` | |
| : "/api/logs/errors"; | |
| const method = "DELETE"; | |
| const body = isSingleDelete ? null : JSON.stringify({ ids: logIds }); | |
| const headers = isSingleDelete ? {} : { "Content-Type": "application/json" }; | |
| const options = { | |
| method: method, | |
| headers: headers, | |
| body: body, // fetchAPI handles null body correctly | |
| }; | |
| try { | |
| // Use fetchAPI for the delete request | |
| await fetchAPI(url, options); // fetchAPI returns null for 204 No Content | |
| // If fetchAPI doesn't throw, the request was successful | |
| const successMessage = isSingleDelete | |
| ? `成功删除该日志` | |
| : `成功删除 ${logIds.length} 条日志`; | |
| showNotification(successMessage, "success"); | |
| // 取消全选 | |
| if (selectAllCheckbox) selectAllCheckbox.checked = false; | |
| // 重新加载当前页数据 | |
| loadErrorLogs(); | |
| } catch (error) { | |
| console.error("批量删除错误日志失败:", error); | |
| showNotification(`批量删除失败: ${error.message}`, "error", 5000); | |
| } | |
| } | |
| // 修改:处理单行删除按钮点击的函数 - 改为显示模态框 | |
| function handleDeleteLogRow(logId) { | |
| if (!logId) return; | |
| // 存储待删除ID并显示模态框 | |
| idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组 // 仍然需要设置,因为 performActualDelete 会用到 | |
| // 使用通用确认消息,不显示具体ID | |
| const message = `确定要删除这条日志吗?此操作不可恢复!`; | |
| showDeleteConfirmModal(message, function() { // 传入匿名回调 | |
| performActualDelete([parseInt(logId)]); // 确保传递的是数组 | |
| }); | |
| } | |
| // 新增:处理 ID 排序点击的函数 | |
| function handleSortById() { | |
| if (errorLogState.sort.field === "id") { | |
| // 如果当前是按 ID 排序,切换顺序 | |
| errorLogState.sort.order = | |
| errorLogState.sort.order === "asc" ? "desc" : "asc"; | |
| } else { | |
| // 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序 | |
| errorLogState.sort.field = "id"; | |
| errorLogState.sort.order = "desc"; | |
| } | |
| // 更新图标 | |
| updateSortIcon(); | |
| // 重新加载第一页数据 | |
| errorLogState.currentPage = 1; | |
| loadErrorLogs(); | |
| } | |
| // 新增:更新排序图标的函数 | |
| function updateSortIcon() { | |
| if (!sortIcon) return; | |
| // 移除所有可能的排序类 | |
| sortIcon.classList.remove( | |
| "fa-sort", | |
| "fa-sort-up", | |
| "fa-sort-down", | |
| "text-gray-400", | |
| "text-primary-600" | |
| ); | |
| if (errorLogState.sort.field === "id") { | |
| sortIcon.classList.add( | |
| errorLogState.sort.order === "asc" ? "fa-sort-up" : "fa-sort-down" | |
| ); | |
| sortIcon.classList.add("text-primary-600"); // 高亮显示 | |
| } else { | |
| // 如果不是按 ID 排序,显示默认图标 | |
| sortIcon.classList.add("fa-sort", "text-gray-400"); | |
| } | |
| } | |
| // 加载错误日志数据 | |
| async function loadErrorLogs() { | |
| // 重置选择状态 | |
| if (selectAllCheckbox) selectAllCheckbox.checked = false; | |
| if (selectAllCheckbox) selectAllCheckbox.indeterminate = false; | |
| updateSelectedState(); // 更新按钮状态和计数 | |
| showLoading(true); | |
| showError(false); | |
| showNoData(false); | |
| const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize; | |
| try { | |
| // Construct the API URL with search and sort parameters | |
| let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`; | |
| // 添加排序参数 | |
| apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`; | |
| // 添加搜索参数 | |
| if (errorLogState.search.key) { | |
| apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`; | |
| } | |
| if (errorLogState.search.error) { | |
| apiUrl += `&error_search=${encodeURIComponent( | |
| errorLogState.search.error | |
| )}`; | |
| } | |
| if (errorLogState.search.errorCode) { | |
| // Add error code to API request | |
| apiUrl += `&error_code_search=${encodeURIComponent( | |
| errorLogState.search.errorCode | |
| )}`; | |
| } | |
| if (errorLogState.search.startDate) { | |
| apiUrl += `&start_date=${encodeURIComponent( | |
| errorLogState.search.startDate | |
| )}`; | |
| } | |
| if (errorLogState.search.endDate) { | |
| apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`; | |
| } | |
| // Use fetchAPI to get logs | |
| const data = await fetchAPI(apiUrl); | |
| // API 现在返回 { logs: [], total: count } | |
| // fetchAPI already parsed JSON | |
| if (data && Array.isArray(data.logs)) { | |
| errorLogState.logs = data.logs; // Store the list data (contains error_code) | |
| renderErrorLogs(errorLogState.logs); | |
| updatePagination(errorLogState.logs.length, data.total || -1); // Use total from response | |
| } else { | |
| // Handle unexpected data format even after successful fetch | |
| console.error("Unexpected API response format:", data); | |
| throw new Error("无法识别的API响应格式"); | |
| } | |
| showLoading(false); | |
| if (errorLogState.logs.length === 0) { | |
| showNoData(true); | |
| } | |
| } catch (error) { | |
| console.error("获取错误日志失败:", error); | |
| showLoading(false); | |
| showError(true, error.message); // Show specific error message | |
| } | |
| } | |
| // Helper function to create HTML for a single log row | |
| function _createLogRowHtml(log, sequentialId) { | |
| // Format date | |
| let formattedTime = "N/A"; | |
| try { | |
| const requestTime = new Date(log.request_time); | |
| if (!isNaN(requestTime)) { | |
| formattedTime = requestTime.toLocaleString("zh-CN", { | |
| year: "numeric", | |
| month: "2-digit", | |
| day: "2-digit", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| hour12: false, | |
| }); | |
| } | |
| } catch (e) { | |
| console.error("Error formatting date:", e); | |
| } | |
| const errorCodeContent = log.error_code || "无"; | |
| const maskKey = (key) => { | |
| if (!key || key.length < 8) return key || "无"; | |
| return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; | |
| }; | |
| const maskedKey = maskKey(log.gemini_key); | |
| const fullKey = log.gemini_key || ""; | |
| return ` | |
| <td class="text-center px-3 py-3 text-gray-700"> | |
| <input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" data-key="${fullKey}" data-log-id="${ | |
| log.id | |
| }"> | |
| </td> | |
| <td class="text-gray-700">${sequentialId}</td> | |
| <td class="relative group text-gray-700" title="${fullKey}"> | |
| ${maskedKey} | |
| <button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${fullKey}" title="复制完整密钥"> | |
| <i class="far fa-copy"></i> | |
| </button> | |
| </td> | |
| <td class="text-gray-700">${log.error_type || "未知"}</td> | |
| <td class="error-code-content text-gray-700" title="${ | |
| log.error_code || "" | |
| }">${errorCodeContent}</td> | |
| <td class="text-gray-700">${log.model_name || "未知"}</td> | |
| <td class="text-gray-700">${formattedTime}</td> | |
| <td> | |
| <button class="btn-view-details mr-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm transition-all duration-200" data-log-id="${log.id}"> | |
| <i class="fas fa-eye mr-1"></i>详情 | |
| </button> | |
| <button class="btn-delete-row bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded text-sm transition-all duration-200" data-log-id="${ | |
| log.id | |
| }" title="删除此日志"> | |
| <i class="fas fa-trash-alt"></i> | |
| </button> | |
| </td> | |
| `; | |
| } | |
| // 渲染错误日志表格 | |
| function renderErrorLogs(logs) { | |
| if (!tableBody) return; | |
| tableBody.innerHTML = ""; // Clear previous entries | |
| // 重置全选复选框状态(在清空表格后) | |
| if (selectAllCheckbox) { | |
| selectAllCheckbox.checked = false; | |
| selectAllCheckbox.indeterminate = false; | |
| } | |
| if (!logs || logs.length === 0) { | |
| // Handled by showNoData | |
| return; | |
| } | |
| const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize; | |
| logs.forEach((log, index) => { | |
| const sequentialId = startIndex + index + 1; | |
| const row = document.createElement("tr"); | |
| row.innerHTML = _createLogRowHtml(log, sequentialId); | |
| tableBody.appendChild(row); | |
| }); | |
| // Add event listeners to new 'View Details' buttons | |
| document.querySelectorAll(".btn-view-details").forEach((button) => { | |
| button.addEventListener("click", function () { | |
| const logId = parseInt(this.getAttribute("data-log-id")); | |
| showLogDetails(logId); | |
| }); | |
| }); | |
| // 新增:为新渲染的删除按钮添加事件监听器 | |
| document.querySelectorAll(".btn-delete-row").forEach((button) => { | |
| button.addEventListener("click", function () { | |
| const logId = this.getAttribute("data-log-id"); | |
| handleDeleteLogRow(logId); | |
| }); | |
| }); | |
| // Re-initialize copy buttons specifically for the newly rendered table rows | |
| setupCopyButtons("#errorLogsTable"); | |
| // Update selected state after rendering | |
| updateSelectedState(); | |
| } | |
| // 显示错误日志详情 (从 API 获取) | |
| async function showLogDetails(logId) { | |
| if (!logDetailModal) return; | |
| // Show loading state in modal (optional) | |
| // Clear previous content and show a spinner or message | |
| document.getElementById("modalGeminiKey").textContent = "加载中..."; | |
| document.getElementById("modalErrorType").textContent = "加载中..."; | |
| document.getElementById("modalErrorLog").textContent = "加载中..."; | |
| document.getElementById("modalRequestMsg").textContent = "加载中..."; | |
| document.getElementById("modalModelName").textContent = "加载中..."; | |
| document.getElementById("modalRequestTime").textContent = "加载中..."; | |
| logDetailModal.classList.add("show"); | |
| document.body.style.overflow = "hidden"; // Prevent body scrolling | |
| try { | |
| // Use fetchAPI to get log details | |
| const logDetails = await fetchAPI(`/api/logs/errors/${logId}/details`); | |
| // fetchAPI handles response.ok check and JSON parsing | |
| if (!logDetails) { | |
| // Handle case where API returns success but no data (if possible) | |
| throw new Error("未找到日志详情"); | |
| } | |
| // Format date | |
| let formattedTime = "N/A"; | |
| try { | |
| const requestTime = new Date(logDetails.request_time); | |
| if (!isNaN(requestTime)) { | |
| formattedTime = requestTime.toLocaleString("zh-CN", { | |
| year: "numeric", | |
| month: "2-digit", | |
| day: "2-digit", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| hour12: false, | |
| }); | |
| } | |
| } catch (e) { | |
| console.error("Error formatting date:", e); | |
| } | |
| // Format request message (handle potential JSON) | |
| let formattedRequestMsg = "无"; | |
| if (logDetails.request_msg) { | |
| try { | |
| if ( | |
| typeof logDetails.request_msg === "object" && | |
| logDetails.request_msg !== null | |
| ) { | |
| formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2); | |
| } else if (typeof logDetails.request_msg === "string") { | |
| // Try parsing if it looks like JSON, otherwise display as string | |
| const trimmedMsg = logDetails.request_msg.trim(); | |
| if (trimmedMsg.startsWith("{") || trimmedMsg.startsWith("[")) { | |
| formattedRequestMsg = JSON.stringify( | |
| JSON.parse(logDetails.request_msg), | |
| null, | |
| 2 | |
| ); | |
| } else { | |
| formattedRequestMsg = logDetails.request_msg; | |
| } | |
| } else { | |
| formattedRequestMsg = String(logDetails.request_msg); | |
| } | |
| } catch (e) { | |
| formattedRequestMsg = String(logDetails.request_msg); // Fallback | |
| console.warn("Could not parse request_msg as JSON:", e); | |
| } | |
| } | |
| // Populate modal content with fetched details | |
| document.getElementById("modalGeminiKey").textContent = | |
| logDetails.gemini_key || "无"; | |
| document.getElementById("modalErrorType").textContent = | |
| logDetails.error_type || "未知"; | |
| document.getElementById("modalErrorLog").textContent = | |
| logDetails.error_log || "无"; // Full error log | |
| document.getElementById("modalRequestMsg").textContent = | |
| formattedRequestMsg; // Full request message | |
| document.getElementById("modalModelName").textContent = | |
| logDetails.model_name || "未知"; | |
| document.getElementById("modalRequestTime").textContent = formattedTime; | |
| // Re-initialize copy buttons specifically for the modal after content is loaded | |
| setupCopyButtons("#logDetailModal"); | |
| } catch (error) { | |
| console.error("获取日志详情失败:", error); | |
| // Show error in modal | |
| document.getElementById("modalGeminiKey").textContent = "错误"; | |
| document.getElementById("modalErrorType").textContent = "错误"; | |
| document.getElementById( | |
| "modalErrorLog" | |
| ).textContent = `加载失败: ${error.message}`; | |
| document.getElementById("modalRequestMsg").textContent = "错误"; | |
| document.getElementById("modalModelName").textContent = "错误"; | |
| document.getElementById("modalRequestTime").textContent = "错误"; | |
| // Optionally show a notification | |
| showNotification(`加载日志详情失败: ${error.message}`, "error", 5000); | |
| } | |
| } | |
| // Close Log Detail Modal | |
| function closeLogDetailModal() { | |
| if (logDetailModal) { | |
| logDetailModal.classList.remove("show"); | |
| // Optional: Restore body scrolling | |
| document.body.style.overflow = ""; | |
| } | |
| } | |
| // 更新分页控件 | |
| function updatePagination(currentItemCount, totalItems) { | |
| if (!paginationElement) return; | |
| paginationElement.innerHTML = ""; // Clear existing pagination | |
| // Calculate total pages only if totalItems is known and valid | |
| let totalPages = 1; | |
| if (totalItems >= 0) { | |
| totalPages = Math.max(1, Math.ceil(totalItems / errorLogState.pageSize)); | |
| } else if ( | |
| currentItemCount < errorLogState.pageSize && | |
| errorLogState.currentPage === 1 | |
| ) { | |
| // If less items than page size fetched on page 1, assume it's the only page | |
| totalPages = 1; | |
| } else { | |
| // If total is unknown and more items might exist, we can't build full pagination | |
| // We can show Prev/Next based on current page and if items were returned | |
| console.warn("Total item count unknown, pagination will be limited."); | |
| // Basic Prev/Next for unknown total | |
| addPaginationLink( | |
| paginationElement, | |
| "«", | |
| errorLogState.currentPage > 1, | |
| () => { | |
| errorLogState.currentPage--; | |
| loadErrorLogs(); | |
| } | |
| ); | |
| addPaginationLink( | |
| paginationElement, | |
| errorLogState.currentPage.toString(), | |
| true, | |
| null, | |
| true | |
| ); // Current page number (non-clickable) | |
| addPaginationLink( | |
| paginationElement, | |
| "»", | |
| currentItemCount === errorLogState.pageSize, | |
| () => { | |
| errorLogState.currentPage++; | |
| loadErrorLogs(); | |
| } | |
| ); // Next enabled if full page was returned | |
| return; // Exit here for limited pagination | |
| } | |
| const maxPagesToShow = 5; // Max number of page links to show | |
| let startPage = Math.max( | |
| 1, | |
| errorLogState.currentPage - Math.floor(maxPagesToShow / 2) | |
| ); | |
| let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); | |
| // Adjust startPage if endPage reaches the limit first | |
| if (endPage === totalPages) { | |
| startPage = Math.max(1, endPage - maxPagesToShow + 1); | |
| } | |
| // Previous Button | |
| addPaginationLink( | |
| paginationElement, | |
| "«", | |
| errorLogState.currentPage > 1, | |
| () => { | |
| errorLogState.currentPage--; | |
| loadErrorLogs(); | |
| } | |
| ); | |
| // First Page Button | |
| if (startPage > 1) { | |
| addPaginationLink(paginationElement, "1", true, () => { | |
| errorLogState.currentPage = 1; | |
| loadErrorLogs(); | |
| }); | |
| if (startPage > 2) { | |
| addPaginationLink(paginationElement, "...", false); // Ellipsis | |
| } | |
| } | |
| // Page Number Buttons | |
| for (let i = startPage; i <= endPage; i++) { | |
| addPaginationLink( | |
| paginationElement, | |
| i.toString(), | |
| true, | |
| () => { | |
| errorLogState.currentPage = i; | |
| loadErrorLogs(); | |
| }, | |
| i === errorLogState.currentPage | |
| ); | |
| } | |
| // Last Page Button | |
| if (endPage < totalPages) { | |
| if (endPage < totalPages - 1) { | |
| addPaginationLink(paginationElement, "...", false); // Ellipsis | |
| } | |
| addPaginationLink(paginationElement, totalPages.toString(), true, () => { | |
| errorLogState.currentPage = totalPages; | |
| loadErrorLogs(); | |
| }); | |
| } | |
| // Next Button | |
| addPaginationLink( | |
| paginationElement, | |
| "»", | |
| errorLogState.currentPage < totalPages, | |
| () => { | |
| errorLogState.currentPage++; | |
| loadErrorLogs(); | |
| } | |
| ); | |
| } | |
| // Helper function to add pagination links | |
| function addPaginationLink( | |
| parentElement, | |
| text, | |
| enabled, | |
| clickHandler, | |
| isActive = false | |
| ) { | |
| // const pageItem = document.createElement('li'); // We are not using <li> anymore | |
| const pageLink = document.createElement("a"); | |
| // Base Tailwind classes for layout, size, and transition. Colors/borders will come from CSS. | |
| let baseClasses = | |
| "px-3 py-1 rounded-md text-sm transition duration-150 ease-in-out"; // Common classes | |
| if (isActive) { | |
| pageLink.className = `${baseClasses} active`; // Add 'active' class for CSS | |
| } else if (enabled) { | |
| pageLink.className = baseClasses; // Just base classes, CSS handles the rest | |
| } else { | |
| // Disabled link (e.g., '...' or unavailable prev/next) | |
| pageLink.className = `${baseClasses} disabled`; // Add 'disabled' class for CSS | |
| } | |
| pageLink.href = "#"; // Prevent page jump | |
| pageLink.innerHTML = text; | |
| if (enabled && clickHandler) { | |
| pageLink.addEventListener("click", function (e) { | |
| e.preventDefault(); | |
| clickHandler(); | |
| }); | |
| } else { | |
| // Handles !enabled (includes isActive as clickHandler is null for it, and '...' which has no clickHandler) | |
| pageLink.addEventListener("click", (e) => e.preventDefault()); | |
| } | |
| parentElement.appendChild(pageLink); // Directly append <a> to the <ul> | |
| } | |
| // 显示/隐藏状态指示器 (using 'active' class) | |
| function showLoading(show) { | |
| if (loadingIndicator) | |
| loadingIndicator.style.display = show ? "block" : "none"; | |
| } | |
| function showNoData(show) { | |
| if (noDataMessage) noDataMessage.style.display = show ? "block" : "none"; | |
| } | |
| function showError(show, message = "加载错误日志失败,请稍后重试。") { | |
| if (errorMessage) { | |
| errorMessage.style.display = show ? "block" : "none"; | |
| if (show) { | |
| // Update the error message content | |
| const p = errorMessage.querySelector("p"); | |
| if (p) p.textContent = message; | |
| } | |
| } | |
| } | |
| // Function to show temporary status notifications (like copy success) | |
| function showNotification(message, type = "success", duration = 3000) { | |
| const notificationElement = document.getElementById("notification"); // Use the correct ID from base.html | |
| if (!notificationElement) { | |
| console.error("Notification element with ID 'notification' not found."); | |
| return; | |
| } | |
| // Set message and type class | |
| notificationElement.textContent = message; | |
| // Remove previous type classes before adding the new one | |
| notificationElement.classList.remove("success", "error", "warning", "info"); | |
| notificationElement.classList.add(type); // Add the type class for styling | |
| notificationElement.className = `notification ${type} show`; // Add 'show' class | |
| // Hide after duration | |
| setTimeout(() => { | |
| notificationElement.classList.remove("show"); | |
| }, duration); | |
| } | |
| // Example Usage (if copy functionality is added later): | |
| // showNotification('密钥已复制!', 'success'); | |
| // showNotification('复制失败!', 'error'); | |