GeminiBalance / app /static /js /error_logs.js
CatPtain's picture
Upload 77 files
76b9762 verified
// 错误日志页面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,
"&laquo;",
errorLogState.currentPage > 1,
() => {
errorLogState.currentPage--;
loadErrorLogs();
}
);
addPaginationLink(
paginationElement,
errorLogState.currentPage.toString(),
true,
null,
true
); // Current page number (non-clickable)
addPaginationLink(
paginationElement,
"&raquo;",
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,
"&laquo;",
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,
"&raquo;",
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');