1183 lines
39 KiB
JavaScript
1183 lines
39 KiB
JavaScript
// 错误日志页面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');
|