| |
|
|
| function copyToClipboard(text) { |
| if (navigator.clipboard && navigator.clipboard.writeText) { |
| return navigator.clipboard.writeText(text); |
| } else { |
| return new Promise((resolve, reject) => { |
| const textArea = document.createElement("textarea"); |
| textArea.value = text; |
| textArea.style.position = "fixed"; |
| document.body.appendChild(textArea); |
| textArea.focus(); |
| textArea.select(); |
| try { |
| const successful = document.execCommand("copy"); |
| document.body.removeChild(textArea); |
| if (successful) { |
| resolve(); |
| } else { |
| reject(new Error("复制失败")); |
| } |
| } catch (err) { |
| document.body.removeChild(textArea); |
| reject(err); |
| } |
| }); |
| } |
| } |
|
|
| |
| async function fetchAPI(url, options = {}) { |
| try { |
| const response = await fetch(url, options); |
|
|
| if (response.status === 204) { |
| return null; |
| } |
|
|
| let responseData; |
| try { |
| |
| const clonedResponse = response.clone(); |
| responseData = await response.json(); |
| } catch (e) { |
| |
| if (!response.ok) { |
| const textResponse = await response.text(); |
| throw new Error( |
| textResponse || |
| `HTTP error! status: ${response.status} - ${response.statusText}` |
| ); |
| } |
| |
| console.warn("Response was not JSON for URL:", url); |
| |
| return await response.text(); |
| } |
|
|
| if (!response.ok) { |
| |
| const message = |
| responseData?.detail || |
| responseData?.message || |
| responseData?.error || |
| `HTTP error! status: ${response.status}`; |
| throw new Error(message); |
| } |
|
|
| return responseData; |
| } catch (error) { |
| console.error( |
| "API Call Failed:", |
| error.message, |
| "URL:", |
| url, |
| "Options:", |
| options |
| ); |
| |
| |
| throw new Error(`API请求失败: ${error.message}`); |
| } |
| } |
|
|
| |
| function initStatItemAnimations() { |
| const statItems = document.querySelectorAll(".stat-item"); |
| statItems.forEach((item) => { |
| item.addEventListener("mouseenter", () => { |
| item.style.transform = "scale(1.05)"; |
| const icon = item.querySelector(".stat-icon"); |
| if (icon) { |
| icon.style.opacity = "0.2"; |
| icon.style.transform = "scale(1.1) rotate(0deg)"; |
| } |
| }); |
|
|
| item.addEventListener("mouseleave", () => { |
| item.style.transform = ""; |
| const icon = item.querySelector(".stat-icon"); |
| if (icon) { |
| icon.style.opacity = ""; |
| icon.style.transform = ""; |
| } |
| }); |
| }); |
| } |
|
|
| |
| function getSelectedKeys(type) { |
| let selectorRoot; |
| if (type === 'attention') { |
| selectorRoot = '#attentionKeysList'; |
| } else { |
| selectorRoot = `#${type}Keys`; |
| } |
| const checkboxes = document.querySelectorAll( |
| `${selectorRoot} .key-checkbox:checked` |
| ); |
| return Array.from(checkboxes).map((cb) => cb.value); |
| } |
|
|
| |
| function updateBatchActions(type) { |
| const selectedKeys = getSelectedKeys(type); |
| const count = selectedKeys.length; |
| const batchActionsDiv = document.getElementById(`${type}BatchActions`); |
| if (!batchActionsDiv) return; |
| const selectedCountSpan = document.getElementById(`${type}SelectedCount`); |
| const buttons = batchActionsDiv.querySelectorAll("button"); |
|
|
| if (count > 0) { |
| batchActionsDiv.classList.remove("hidden"); |
| if (selectedCountSpan) selectedCountSpan.textContent = count; |
| buttons.forEach((button) => (button.disabled = false)); |
| } else { |
| batchActionsDiv.classList.add("hidden"); |
| if (selectedCountSpan) selectedCountSpan.textContent = "0"; |
| buttons.forEach((button) => (button.disabled = true)); |
| } |
|
|
| |
| const selectAllId = `selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`; |
| const selectAllCheckbox = document.getElementById(selectAllId); |
| const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`; |
| |
| const visibleCheckboxes = document.querySelectorAll( |
| `#${rootId} li:not([style*="display: none"]) .key-checkbox` |
| ); |
| if (selectAllCheckbox && visibleCheckboxes.length > 0) { |
| selectAllCheckbox.checked = count === visibleCheckboxes.length; |
| selectAllCheckbox.indeterminate = |
| count > 0 && count < visibleCheckboxes.length; |
| } else if (selectAllCheckbox) { |
| selectAllCheckbox.checked = false; |
| selectAllCheckbox.indeterminate = false; |
| } |
| } |
|
|
| |
| function toggleSelectAll(type, isChecked) { |
| const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`; |
| const listElement = document.getElementById(rootId); |
| if (!listElement) return; |
| const visibleCheckboxes = listElement.querySelectorAll( |
| `li:not([style*="display: none"]) .key-checkbox` |
| ); |
|
|
| visibleCheckboxes.forEach((checkbox) => { |
| checkbox.checked = isChecked; |
| const listItem = checkbox.closest("li[data-key]"); |
| if (listItem) { |
| listItem.classList.toggle("selected", isChecked); |
| if (type !== 'attention') { |
| const key = listItem.dataset.key; |
| const masterList = type === "valid" ? allValidKeys : allInvalidKeys; |
| if (masterList) { |
| const masterListItem = masterList.find((li) => li.dataset.key === key); |
| if (masterListItem) { |
| const masterCheckbox = masterListItem.querySelector(".key-checkbox"); |
| if (masterCheckbox) { |
| masterCheckbox.checked = isChecked; |
| } |
| } |
| } |
| } |
| } |
| }); |
| updateBatchActions(type); |
| } |
|
|
| |
| function copySelectedKeys(type) { |
| const selectedKeys = getSelectedKeys(type); |
|
|
| if (selectedKeys.length === 0) { |
| showNotification("没有选中的密钥可复制", "warning"); |
| return; |
| } |
|
|
| const keysText = selectedKeys.join("\n"); |
|
|
| copyToClipboard(keysText) |
| .then(() => { |
| showNotification( |
| `已成功复制 ${selectedKeys.length} 个选中的${ |
| type === "valid" ? "有效" : "无效" |
| }密钥` |
| ); |
| }) |
| .catch((err) => { |
| console.error("无法复制文本: ", err); |
| showNotification("复制失败,请重试", "error"); |
| }); |
| } |
|
|
| |
| function copyKey(key) { |
| copyToClipboard(key) |
| .then(() => { |
| showNotification(`已成功复制密钥`); |
| }) |
| .catch((err) => { |
| console.error("无法复制文本: ", err); |
| showNotification("复制失败,请重试", "error"); |
| }); |
| } |
|
|
| |
|
|
| async function verifyKey(key, button) { |
| try { |
| |
| button.disabled = true; |
| const originalHtml = button.innerHTML; |
| button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中'; |
|
|
| try { |
| const data = await fetchAPI(`/gemini/v1beta/verify-key/${key}`, { |
| method: "POST", |
| }); |
|
|
| |
| if (data && (data.success || data.status === "valid")) { |
| |
| button.style.backgroundColor = "#27ae60"; |
| |
| showResultModal(true, "密钥验证成功"); |
| |
| } else { |
| |
| const errorMsg = data.error || "密钥无效"; |
| button.style.backgroundColor = "#e74c3c"; |
| |
| showResultModal(false, "密钥验证失败: " + errorMsg, true); |
| } |
| } catch (apiError) { |
| console.error("密钥验证 API 请求失败:", apiError); |
| showResultModal(false, `验证请求失败: ${apiError.message}`, true); |
| } finally { |
| |
| |
| |
| setTimeout(() => { |
| if ( |
| !document.getElementById("resultModal") || |
| document.getElementById("resultModal").classList.contains("hidden") |
| ) { |
| button.innerHTML = originalHtml; |
| button.disabled = false; |
| button.style.backgroundColor = ""; |
| } |
| }, 1000); |
| } |
| } catch (error) { |
| console.error("验证失败:", error); |
| |
| |
| |
| showResultModal(false, "验证处理失败: " + error.message, true); |
| } |
| } |
|
|
| async function resetKeyFailCount(key, button) { |
| try { |
| |
| button.disabled = true; |
| const originalHtml = button.innerHTML; |
| button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中'; |
|
|
| const data = await fetchAPI(`/gemini/v1beta/reset-fail-count/${key}`, { |
| method: "POST", |
| }); |
|
|
| |
| if (data.success) { |
| showNotification("失败计数重置成功"); |
| |
| button.style.backgroundColor = "#27ae60"; |
| |
| setTimeout(() => location.reload(), 1000); |
| } else { |
| const errorMsg = data.message || "重置失败"; |
| showNotification("重置失败: " + errorMsg, "error"); |
| |
| button.style.backgroundColor = "#e74c3c"; |
| |
| setTimeout(() => { |
| button.innerHTML = originalHtml; |
| button.disabled = false; |
| button.style.backgroundColor = ""; |
| }, 1000); |
| } |
|
|
| |
| } catch (apiError) { |
| console.error("重置失败:", apiError); |
| showNotification(`重置请求失败: ${apiError.message}`, "error"); |
| |
| button.disabled = false; |
| button.innerHTML = '<i class="fas fa-redo-alt"></i> 重置'; |
| button.style.backgroundColor = ""; |
| } |
| } |
|
|
| |
| function showResetModal(type) { |
| const modalElement = document.getElementById("resetModal"); |
| const titleElement = document.getElementById("resetModalTitle"); |
| const messageElement = document.getElementById("resetModalMessage"); |
| const confirmButton = document.getElementById("confirmResetBtn"); |
|
|
| const selectedKeys = getSelectedKeys(type); |
| const count = selectedKeys.length; |
|
|
| |
| titleElement.textContent = "批量重置失败次数"; |
| if (count > 0) { |
| messageElement.textContent = `确定要批量重置选中的 ${count} 个${ |
| type === "valid" ? "有效" : "无效" |
| }密钥的失败次数吗?`; |
| confirmButton.disabled = false; |
| } else { |
| |
| messageElement.textContent = `请先选择要重置的${ |
| type === "valid" ? "有效" : "无效" |
| }密钥。`; |
| confirmButton.disabled = true; |
| } |
|
|
| |
| confirmButton.onclick = () => executeResetAll(type); |
|
|
| |
| modalElement.style.zIndex = '1001'; |
| modalElement.classList.remove("hidden"); |
| } |
|
|
| function closeResetModal() { |
| document.getElementById("resetModal").classList.add("hidden"); |
| } |
|
|
| |
| function resetAllKeysFailCount(type, event) { |
| |
| if (event) { |
| event.stopPropagation(); |
| } |
|
|
| |
| showResetModal(type); |
| } |
|
|
| |
| function closeResultModal(reload = true) { |
| document.getElementById("resultModal").classList.add("hidden"); |
| if (reload) { |
| location.reload(); |
| } |
| } |
|
|
| |
| function showResultModal(success, message, autoReload = true) { |
| const modalElement = document.getElementById("resultModal"); |
| const titleElement = document.getElementById("resultModalTitle"); |
| const messageElement = document.getElementById("resultModalMessage"); |
| const iconElement = document.getElementById("resultIcon"); |
| const confirmButton = document.getElementById("resultModalConfirmBtn"); |
|
|
| |
| titleElement.textContent = success ? "操作成功" : "操作失败"; |
|
|
| |
| if (success) { |
| iconElement.innerHTML = |
| '<i class="fas fa-check-circle text-success-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-success-500"; |
| } else { |
| iconElement.innerHTML = |
| '<i class="fas fa-times-circle text-danger-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-danger-500"; |
| } |
|
|
| |
| messageElement.innerHTML = ""; |
| if (typeof message === "string") { |
| |
| const messageDiv = document.createElement("div"); |
| messageDiv.innerText = message; |
| messageElement.appendChild(messageDiv); |
| } else if (message instanceof Node) { |
| |
| messageElement.appendChild(message); |
| } else { |
| |
| const messageDiv = document.createElement("div"); |
| messageDiv.innerText = String(message); |
| messageElement.appendChild(messageDiv); |
| } |
|
|
| |
| confirmButton.onclick = () => closeResultModal(autoReload); |
|
|
| |
| modalElement.classList.remove("hidden"); |
| } |
|
|
| |
| function showVerificationResultModal(data) { |
| const modalElement = document.getElementById("resultModal"); |
| const titleElement = document.getElementById("resultModalTitle"); |
| const messageElement = document.getElementById("resultModalMessage"); |
| const iconElement = document.getElementById("resultIcon"); |
| const confirmButton = document.getElementById("resultModalConfirmBtn"); |
|
|
| const successfulKeys = data.successful_keys || []; |
| const failedKeys = data.failed_keys || {}; |
| const validCount = data.valid_count || 0; |
| const invalidCount = data.invalid_count || 0; |
|
|
| |
| titleElement.textContent = "批量验证结果"; |
| if (invalidCount === 0 && validCount > 0) { |
| iconElement.innerHTML = |
| '<i class="fas fa-check-double text-success-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-success-500"; |
| } else if (invalidCount > 0 && validCount > 0) { |
| iconElement.innerHTML = |
| '<i class="fas fa-exclamation-triangle text-warning-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-warning-500"; |
| } else if (invalidCount > 0 && validCount === 0) { |
| iconElement.innerHTML = |
| '<i class="fas fa-times-circle text-danger-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-danger-500"; |
| } else { |
| |
| iconElement.innerHTML = '<i class="fas fa-info-circle text-gray-500"></i>'; |
| iconElement.className = "text-6xl mb-3 text-gray-500"; |
| } |
|
|
| |
| messageElement.innerHTML = ""; |
|
|
| const summaryDiv = document.createElement("div"); |
| summaryDiv.className = "text-center mb-4 text-lg"; |
| summaryDiv.innerHTML = `验证完成:<span class="font-semibold text-success-600">${validCount}</span> 个成功,<span class="font-semibold text-danger-600">${invalidCount}</span> 个失败。`; |
| messageElement.appendChild(summaryDiv); |
|
|
| |
| if (successfulKeys.length > 0) { |
| const successDiv = document.createElement("div"); |
| successDiv.className = "mb-3"; |
| const successHeader = document.createElement("div"); |
| successHeader.className = "flex justify-between items-center mb-1"; |
| successHeader.innerHTML = `<h4 class="font-semibold text-success-700">成功密钥 (${successfulKeys.length}):</h4>`; |
|
|
| const copySuccessBtn = document.createElement("button"); |
| copySuccessBtn.className = |
| "px-2 py-0.5 bg-green-100 hover:bg-green-200 text-green-700 text-xs rounded transition-colors"; |
| copySuccessBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>复制全部'; |
| copySuccessBtn.onclick = (e) => { |
| e.stopPropagation(); |
| copyToClipboard(successfulKeys.join("\n")) |
| .then(() => |
| showNotification( |
| `已复制 ${successfulKeys.length} 个成功密钥`, |
| "success" |
| ) |
| ) |
| .catch(() => showNotification("复制失败", "error")); |
| }; |
| successHeader.appendChild(copySuccessBtn); |
| successDiv.appendChild(successHeader); |
|
|
| const successList = document.createElement("ul"); |
| successList.className = |
| "list-disc list-inside text-sm text-gray-600 max-h-20 overflow-y-auto bg-gray-50 p-2 rounded border border-gray-200"; |
| successfulKeys.forEach((key) => { |
| const li = document.createElement("li"); |
| li.className = "font-mono"; |
| |
| li.dataset.fullKey = key; |
| li.textContent = |
| key.substring(0, 4) + "..." + key.substring(key.length - 4); |
| successList.appendChild(li); |
| }); |
| successDiv.appendChild(successList); |
| messageElement.appendChild(successDiv); |
| } |
|
|
| |
| if (Object.keys(failedKeys).length > 0) { |
| const failDiv = document.createElement("div"); |
| failDiv.className = "mb-1"; |
| const failHeader = document.createElement("div"); |
| failHeader.className = "flex justify-between items-center mb-1"; |
| failHeader.innerHTML = `<h4 class="font-semibold text-danger-700">失败密钥 (${ |
| Object.keys(failedKeys).length |
| }):</h4>`; |
|
|
| const copyFailBtn = document.createElement("button"); |
| copyFailBtn.className = |
| "px-2 py-0.5 bg-red-100 hover:bg-red-200 text-red-700 text-xs rounded transition-colors"; |
| copyFailBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>复制全部'; |
| const failedKeysArray = Object.keys(failedKeys); |
| copyFailBtn.onclick = (e) => { |
| e.stopPropagation(); |
| copyToClipboard(failedKeysArray.join("\n")) |
| .then(() => |
| showNotification( |
| `已复制 ${failedKeysArray.length} 个失败密钥`, |
| "success" |
| ) |
| ) |
| .catch(() => showNotification("复制失败", "error")); |
| }; |
| failHeader.appendChild(copyFailBtn); |
| failDiv.appendChild(failHeader); |
|
|
| |
| const errorGroups = {}; |
| Object.entries(failedKeys).forEach(([key, error]) => { |
| |
| let errorCode = error; |
| |
| |
| const errorCodePatterns = [ |
| /status code (\d+)/, |
| ]; |
| |
| for (const pattern of errorCodePatterns) { |
| const match = error.match(pattern); |
| if (match) { |
| errorCode = match[1] || match[0]; |
| break; |
| } |
| } |
| |
| |
| if (errorCode === error) { |
| errorCode = 500; |
| } |
| |
| if (!errorGroups[errorCode]) { |
| errorGroups[errorCode] = []; |
| } |
| errorGroups[errorCode].push({ key, error }); |
| }); |
|
|
| |
| const groupsContainer = document.createElement("div"); |
| groupsContainer.className = "space-y-3 max-h-64 overflow-y-auto bg-red-50 p-2 rounded border border-red-200"; |
|
|
| |
| Object.entries(errorGroups).forEach(([errorCode, keyErrorPairs]) => { |
| const groupDiv = document.createElement("div"); |
| groupDiv.className = "border border-red-300 rounded-lg bg-white p-2"; |
|
|
| |
| const groupHeader = document.createElement("div"); |
| groupHeader.className = "flex justify-between items-center mb-2 cursor-pointer"; |
| groupHeader.innerHTML = ` |
| <div class="flex items-center gap-2"> |
| <i class="fas fa-chevron-down group-toggle-icon text-red-600 transition-transform duration-200"></i> |
| <h5 class="font-semibold text-red-700 text-sm">错误码: ${errorCode}</h5> |
| <span class="bg-red-100 text-red-600 px-2 py-0.5 rounded-full text-xs font-medium">${keyErrorPairs.length} 个密钥</span> |
| </div> |
| <button class="px-2 py-0.5 bg-red-200 hover:bg-red-300 text-red-700 text-xs rounded transition-colors group-copy-btn"> |
| <i class="fas fa-copy mr-1"></i>复制组内密钥 |
| </button> |
| `; |
|
|
| |
| const groupCopyBtn = groupHeader.querySelector('.group-copy-btn'); |
| groupCopyBtn.onclick = (e) => { |
| e.stopPropagation(); |
| const groupKeys = keyErrorPairs.map(pair => pair.key); |
| copyToClipboard(groupKeys.join("\n")) |
| .then(() => |
| showNotification( |
| `已复制 ${groupKeys.length} 个密钥 (错误码: ${errorCode})`, |
| "success" |
| ) |
| ) |
| .catch(() => showNotification("复制失败", "error")); |
| }; |
|
|
| |
| const keysList = document.createElement("div"); |
| keysList.className = "group-keys-list space-y-1"; |
|
|
| keyErrorPairs.forEach(({ key, error }) => { |
| const keyItem = document.createElement("div"); |
| keyItem.className = "flex flex-col items-start bg-gray-50 p-2 rounded border"; |
|
|
| const keySpanContainer = document.createElement("div"); |
| keySpanContainer.className = "flex justify-between items-center w-full"; |
|
|
| const keySpan = document.createElement("span"); |
| keySpan.className = "font-mono text-sm"; |
| keySpan.dataset.fullKey = key; |
| keySpan.textContent = key.substring(0, 4) + "..." + key.substring(key.length - 4); |
|
|
| const detailsButton = document.createElement("button"); |
| detailsButton.className = "ml-2 px-2 py-0.5 bg-red-200 hover:bg-red-300 text-red-700 text-xs rounded transition-colors"; |
| detailsButton.innerHTML = '<i class="fas fa-info-circle mr-1"></i>详情'; |
| detailsButton.dataset.error = error; |
| detailsButton.onclick = (e) => { |
| e.stopPropagation(); |
| const button = e.currentTarget; |
| const keyItem = button.closest(".bg-gray-50"); |
| const errorMsg = button.dataset.error; |
| const errorDetailsId = `error-details-${key.replace(/[^a-zA-Z0-9]/g, "")}`; |
| let errorDiv = keyItem.querySelector(`#${errorDetailsId}`); |
|
|
| if (errorDiv) { |
| errorDiv.remove(); |
| button.innerHTML = '<i class="fas fa-info-circle mr-1"></i>详情'; |
| } else { |
| errorDiv = document.createElement("div"); |
| errorDiv.id = errorDetailsId; |
| errorDiv.className = "w-full mt-2 text-xs text-red-600 bg-red-50 p-2 rounded border border-red-100 whitespace-pre-wrap break-words"; |
| errorDiv.textContent = errorMsg; |
| keyItem.appendChild(errorDiv); |
| button.innerHTML = '<i class="fas fa-chevron-up mr-1"></i>收起'; |
| } |
| }; |
|
|
| keySpanContainer.appendChild(keySpan); |
| keySpanContainer.appendChild(detailsButton); |
| keyItem.appendChild(keySpanContainer); |
| keysList.appendChild(keyItem); |
| }); |
|
|
| |
| groupHeader.onclick = (e) => { |
| if (e.target.closest('.group-copy-btn')) return; |
| |
| const toggleIcon = groupHeader.querySelector('.group-toggle-icon'); |
| const isCollapsed = keysList.style.display === 'none'; |
| |
| if (isCollapsed) { |
| keysList.style.display = 'block'; |
| toggleIcon.style.transform = 'rotate(0deg)'; |
| } else { |
| keysList.style.display = 'none'; |
| toggleIcon.style.transform = 'rotate(-90deg)'; |
| } |
| }; |
|
|
| groupDiv.appendChild(groupHeader); |
| groupDiv.appendChild(keysList); |
| groupsContainer.appendChild(groupDiv); |
| }); |
|
|
| failDiv.appendChild(groupsContainer); |
| messageElement.appendChild(failDiv); |
| } |
|
|
| |
| confirmButton.onclick = () => closeResultModal(true); |
|
|
| |
| modalElement.classList.remove("hidden"); |
| } |
|
|
| async function executeResetAll(type) { |
| closeResetModal(); |
| const keysToReset = getSelectedKeys(type); |
| if (keysToReset.length === 0) { |
| showNotification("没有选中的密钥可重置", "warning"); |
| return; |
| } |
|
|
| showProgressModal(`批量重置 ${keysToReset.length} 个密钥的失败计数`); |
|
|
| let successCount = 0; |
| let failCount = 0; |
|
|
| for (let i = 0; i < keysToReset.length; i++) { |
| const key = keysToReset[i]; |
| const keyDisplay = `${key.substring(0, 4)}...${key.substring( |
| key.length - 4 |
| )}`; |
| updateProgress(i, keysToReset.length, `正在重置: ${keyDisplay}`); |
|
|
| try { |
| const data = await fetchAPI(`/gemini/v1beta/reset-fail-count/${key}`, { |
| method: "POST", |
| }); |
| if (data.success) { |
| successCount++; |
| addProgressLog(`✅ ${keyDisplay}: 重置成功`); |
| } else { |
| failCount++; |
| addProgressLog( |
| `❌ ${keyDisplay}: 重置失败 - ${data.message || "未知错误"}`, |
| true |
| ); |
| } |
| } catch (apiError) { |
| failCount++; |
| addProgressLog(`❌ ${keyDisplay}: 请求失败 - ${apiError.message}`, true); |
| } |
| } |
|
|
| updateProgress( |
| keysToReset.length, |
| keysToReset.length, |
| `重置完成!成功: ${successCount}, 失败: ${failCount}` |
| ); |
| } |
|
|
| function scrollToTop() { |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| } |
|
|
| function scrollToBottom() { |
| window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| function refreshPage(button) { |
| button.classList.add("loading"); |
| button.disabled = true; |
| const icon = button.querySelector("i"); |
| if (icon) icon.classList.add("fa-spin"); |
|
|
| setTimeout(() => { |
| window.location.reload(); |
| |
| }, 300); |
| } |
|
|
| |
| |
| |
| function toggleSection(header, sectionId) { |
| const toggleIcon = header.querySelector(".toggle-icon"); |
| |
| const card = header.closest(".stats-card"); |
| const content = card ? card.querySelector(".key-content") : null; |
|
|
| |
| const batchActions = card ? card.querySelector('[id$="BatchActions"]') : null; |
| const pagination = card |
| ? card.querySelector('[id$="PaginationControls"]') |
| : null; |
|
|
| if (!toggleIcon || !content) { |
| console.error( |
| "Toggle section failed: Icon or content element not found. Header:", |
| header, |
| "SectionId:", |
| sectionId |
| ); |
| return; |
| } |
|
|
| const isCollapsed = content.classList.contains("collapsed"); |
| toggleIcon.classList.toggle("collapsed", !isCollapsed); |
|
|
| if (isCollapsed) { |
| |
| content.classList.remove("collapsed"); |
|
|
| |
| |
| content.style.maxHeight = ""; |
| content.style.opacity = ""; |
| content.style.paddingTop = ""; |
| content.style.paddingBottom = ""; |
| content.style.overflow = "hidden"; |
|
|
| |
| |
| requestAnimationFrame(() => { |
| |
| |
| let targetHeight = content.scrollHeight; |
|
|
| if (batchActions && !batchActions.classList.contains("hidden")) { |
| targetHeight += batchActions.offsetHeight; |
| } |
| if (pagination && pagination.offsetHeight > 0) { |
| |
| const paginationStyle = getComputedStyle(pagination); |
| const paginationMarginTop = parseFloat(paginationStyle.marginTop) || 0; |
| targetHeight += pagination.offsetHeight + paginationMarginTop; |
| } |
|
|
| |
| content.style.maxHeight = targetHeight + "px"; |
| content.style.opacity = "1"; |
| |
| content.style.paddingTop = "1rem"; |
| content.style.paddingBottom = "1rem"; |
|
|
| |
| |
| content.addEventListener( |
| "transitionend", |
| function onExpansionEnd() { |
| content.removeEventListener("transitionend", onExpansionEnd); |
| |
| if (!content.classList.contains("collapsed")) { |
| content.style.maxHeight = ""; |
| content.style.overflow = "visible"; |
| } |
| }, |
| { once: true } |
| ); |
| }); |
| } else { |
| |
| |
| |
| let currentVisibleHeight = content.scrollHeight; |
| if (batchActions && !batchActions.classList.contains("hidden")) { |
| currentVisibleHeight += batchActions.offsetHeight; |
| } |
| if (pagination && pagination.offsetHeight > 0) { |
| const paginationStyle = getComputedStyle(pagination); |
| const paginationMarginTop = parseFloat(paginationStyle.marginTop) || 0; |
| currentVisibleHeight += pagination.offsetHeight + paginationMarginTop; |
| } |
|
|
| |
| |
| content.style.maxHeight = currentVisibleHeight + "px"; |
| content.style.overflow = "hidden"; |
|
|
| |
| requestAnimationFrame(() => { |
| |
| content.style.maxHeight = "0px"; |
| content.style.opacity = "0"; |
| content.style.paddingTop = "0"; |
| content.style.paddingBottom = "0"; |
| |
| content.classList.add("collapsed"); |
| }); |
| } |
| } |
|
|
| |
| function filterValidKeys() { |
| |
| |
| filterAndSearchValidKeys(); |
| } |
|
|
| |
| function initializePageAnimationsAndEffects() { |
| initStatItemAnimations(); |
|
|
| const animateCounters = () => { |
| const statValues = document.querySelectorAll(".stat-value"); |
| statValues.forEach((valueElement) => { |
| const finalValue = parseInt(valueElement.textContent, 10); |
| if (!isNaN(finalValue)) { |
| if (!valueElement.dataset.originalValue) { |
| valueElement.dataset.originalValue = valueElement.textContent; |
| } |
| let startValue = 0; |
| const duration = 1500; |
| const startTime = performance.now(); |
| const updateCounter = (currentTime) => { |
| const elapsedTime = currentTime - startTime; |
| if (elapsedTime < duration) { |
| const progress = elapsedTime / duration; |
| const easeOutValue = 1 - Math.pow(1 - progress, 3); |
| const currentValue = Math.floor(easeOutValue * finalValue); |
| valueElement.textContent = currentValue; |
| requestAnimationFrame(updateCounter); |
| } else { |
| valueElement.textContent = valueElement.dataset.originalValue; |
| } |
| }; |
| requestAnimationFrame(updateCounter); |
| } |
| }); |
| }; |
| setTimeout(animateCounters, 300); |
|
|
| document.querySelectorAll(".stats-card").forEach((card) => { |
| card.addEventListener("mouseenter", () => { |
| card.classList.add("shadow-lg"); |
| card.style.transform = "translateY(-2px)"; |
| }); |
| card.addEventListener("mouseleave", () => { |
| card.classList.remove("shadow-lg"); |
| card.style.transform = ""; |
| }); |
| }); |
| } |
|
|
| function initializeSectionToggleListeners() { |
| document.querySelectorAll(".stats-card-header").forEach((header) => { |
| if (header.querySelector(".toggle-icon")) { |
| header.addEventListener("click", (event) => { |
| if (event.target.closest("input, label, button, select")) { |
| return; |
| } |
| const card = header.closest(".stats-card"); |
| const content = card ? card.querySelector(".key-content") : null; |
| const sectionId = content ? content.id : null; |
| if (sectionId) { |
| toggleSection(header, sectionId); |
| } else { |
| console.warn("Could not determine sectionId for toggle."); |
| } |
| }); |
| } |
| }); |
| } |
|
|
| function initializeKeyFilterControls() { |
| const thresholdInput = document.getElementById("failCountThreshold"); |
| if (thresholdInput) { |
| thresholdInput.addEventListener("input", filterValidKeys); |
| } |
| |
| |
| const invalidThresholdInput = document.getElementById("invalidFailCountThreshold"); |
| if (invalidThresholdInput) { |
| invalidThresholdInput.addEventListener("input", () => fetchAndDisplayKeys('invalid', 1)); |
| } |
| } |
|
|
| function initializeGlobalBatchVerificationHandlers() { |
| window.showVerifyModal = function (type, event) { |
| if (event) { |
| event.stopPropagation(); |
| } |
| const modalElement = document.getElementById("verifyModal"); |
| const titleElement = document.getElementById("verifyModalTitle"); |
| const messageElement = document.getElementById("verifyModalMessage"); |
| const confirmButton = document.getElementById("confirmVerifyBtn"); |
| const selectedKeys = getSelectedKeys(type); |
| const count = selectedKeys.length; |
| titleElement.textContent = "批量验证密钥"; |
| if (count > 0) { |
| messageElement.textContent = `确定要批量验证选中的 ${count} 个${ |
| type === "valid" ? "有效" : "无效" |
| }密钥吗?此操作可能需要一些时间。`; |
| confirmButton.disabled = false; |
| } else { |
| messageElement.textContent = `请先选择要验证的${ |
| type === "valid" ? "有效" : "无效" |
| }密钥。`; |
| confirmButton.disabled = true; |
| } |
| confirmButton.onclick = () => executeVerifyAll(type); |
| modalElement.classList.remove("hidden"); |
| }; |
|
|
| window.closeVerifyModal = function () { |
| document.getElementById("verifyModal").classList.add("hidden"); |
| }; |
|
|
| |
| async function executeVerifyAll(type) { |
| closeVerifyModal(); |
| const keysToVerify = getSelectedKeys(type); |
| if (keysToVerify.length === 0) { |
| showNotification("没有选中的密钥可验证", "warning"); |
| return; |
| } |
|
|
| const batchSizeInput = document.getElementById("batchSize"); |
| const batchSize = parseInt(batchSizeInput.value, 10) || 10; |
|
|
| showProgressModal(`批量验证 ${keysToVerify.length} 个密钥`); |
|
|
| let allSuccessfulKeys = []; |
| let allFailedKeys = {}; |
| let processedCount = 0; |
|
|
| for (let i = 0; i < keysToVerify.length; i += batchSize) { |
| const batch = keysToVerify.slice(i, i + batchSize); |
| const progressText = `正在验证批次 ${Math.floor(i / batchSize) + 1} / ${Math.ceil(keysToVerify.length / batchSize)} (密钥 ${i + 1}-${Math.min(i + batchSize, keysToVerify.length)})`; |
| |
| updateProgress(i, keysToVerify.length, progressText); |
| addProgressLog(`处理批次: ${batch.length}个密钥...`); |
|
|
| try { |
| const options = { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ keys: batch }), |
| }; |
| const data = await fetchAPI(`/gemini/v1beta/verify-selected-keys`, options); |
|
|
| if (data) { |
| if (data.successful_keys && data.successful_keys.length > 0) { |
| allSuccessfulKeys = allSuccessfulKeys.concat(data.successful_keys); |
| addProgressLog(`✅ 批次成功: ${data.successful_keys.length} 个`); |
| } |
| if (data.failed_keys && Object.keys(data.failed_keys).length > 0) { |
| Object.assign(allFailedKeys, data.failed_keys); |
| addProgressLog(`❌ 批次失败: ${Object.keys(data.failed_keys).length} 个`, true); |
| } |
| } else { |
| addProgressLog(`- 批次返回空数据`, true); |
| } |
| } catch (apiError) { |
| addProgressLog(`❌ 批次请求失败: ${apiError.message}`, true); |
| |
| batch.forEach(key => { |
| allFailedKeys[key] = apiError.message; |
| }); |
| } |
| processedCount += batch.length; |
| updateProgress(processedCount, keysToVerify.length, progressText); |
| } |
|
|
| updateProgress( |
| keysToVerify.length, |
| keysToVerify.length, |
| `所有批次验证完成!` |
| ); |
| |
| |
| closeProgressModal(false); |
| showVerificationResultModal({ |
| successful_keys: allSuccessfulKeys, |
| failed_keys: allFailedKeys, |
| valid_count: allSuccessfulKeys.length, |
| invalid_count: Object.keys(allFailedKeys).length |
| }); |
| } |
| |
| |
| } |
|
|
| |
| function showProgressModal(title) { |
| const modal = document.getElementById("progressModal"); |
| const titleElement = document.getElementById("progressModalTitle"); |
| const statusText = document.getElementById("progressStatusText"); |
| const progressBar = document.getElementById("progressBar"); |
| const progressPercentage = document.getElementById("progressPercentage"); |
| const progressLog = document.getElementById("progressLog"); |
| const closeButton = document.getElementById("progressModalCloseBtn"); |
| const closeIcon = document.getElementById("closeProgressModalBtn"); |
|
|
| titleElement.textContent = title; |
| statusText.textContent = "准备开始..."; |
| progressBar.style.width = "0%"; |
| progressPercentage.textContent = "0%"; |
| progressLog.innerHTML = ""; |
| closeButton.disabled = true; |
| closeIcon.disabled = true; |
|
|
| modal.classList.remove("hidden"); |
| } |
|
|
| function updateProgress(processed, total, status) { |
| const progressBar = document.getElementById("progressBar"); |
| const progressPercentage = document.getElementById("progressPercentage"); |
| const statusText = document.getElementById("progressStatusText"); |
| const closeButton = document.getElementById("progressModalCloseBtn"); |
| const closeIcon = document.getElementById("closeProgressModalBtn"); |
|
|
| const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; |
| progressBar.style.width = `${percentage}%`; |
| progressPercentage.textContent = `${percentage}%`; |
| statusText.textContent = status; |
|
|
| if (processed === total) { |
| closeButton.disabled = false; |
| closeIcon.disabled = false; |
| } |
| } |
|
|
| function addProgressLog(message, isError = false) { |
| const progressLog = document.getElementById("progressLog"); |
| const logEntry = document.createElement("div"); |
| logEntry.textContent = message; |
| logEntry.className = isError |
| ? "text-danger-600" |
| : "text-gray-700"; |
| progressLog.appendChild(logEntry); |
| progressLog.scrollTop = progressLog.scrollHeight; |
| } |
|
|
| function closeProgressModal(reload = false) { |
| const modal = document.getElementById("progressModal"); |
| modal.classList.add("hidden"); |
| if (reload) { |
| location.reload(); |
| } |
| } |
|
|
| function initializeKeySelectionListeners() { |
| const setupEventListenersForList = (listId, keyType) => { |
| const listElement = document.getElementById(listId); |
| if (!listElement) return; |
|
|
| |
| listElement.addEventListener("click", (event) => { |
| const listItem = event.target.closest("li[data-key]"); |
| if (!listItem) return; |
|
|
| |
| if ( |
| event.target.closest( |
| "button, a, input[type='button'], input[type='submit']" |
| ) |
| ) { |
| let currentTarget = event.target; |
| let isInteractiveElementClick = false; |
| while (currentTarget && currentTarget !== listItem) { |
| if ( |
| currentTarget.tagName === "BUTTON" || |
| currentTarget.tagName === "A" || |
| (currentTarget.tagName === "INPUT" && |
| ["button", "submit"].includes(currentTarget.type)) |
| ) { |
| isInteractiveElementClick = true; |
| break; |
| } |
| currentTarget = currentTarget.parentElement; |
| } |
| if (isInteractiveElementClick) return; |
| } |
|
|
| const checkbox = listItem.querySelector(".key-checkbox"); |
| if (checkbox) { |
| checkbox.checked = !checkbox.checked; |
| checkbox.dispatchEvent(new Event("change", { bubbles: true })); |
| } |
| }); |
|
|
| |
| listElement.addEventListener("change", (event) => { |
| if (event.target.classList.contains("key-checkbox")) { |
| const checkbox = event.target; |
| const listItem = checkbox.closest("li[data-key]"); |
|
|
| if (listItem) { |
| listItem.classList.toggle("selected", checkbox.checked); |
|
|
| |
| if (keyType !== 'attention') { |
| const key = listItem.dataset.key; |
| const masterList = |
| keyType === "valid" ? allValidKeys : allInvalidKeys; |
| if (masterList) { |
| const masterListItem = masterList.find( |
| (li) => li.dataset.key === key |
| ); |
| if (masterListItem) { |
| const masterCheckbox = |
| masterListItem.querySelector(".key-checkbox"); |
| if (masterCheckbox) { |
| masterCheckbox.checked = checkbox.checked; |
| } |
| } |
| } |
| } |
| } |
| updateBatchActions(keyType); |
| } |
| }); |
| }; |
|
|
| setupEventListenersForList("validKeys", "valid"); |
| setupEventListenersForList("invalidKeys", "invalid"); |
| setupEventListenersForList("attentionKeysList", "attention"); |
| } |
|
|
|
|
| |
| function debounce(func, delay) { |
| let timeout; |
| return function(...args) { |
| const context = this; |
| clearTimeout(timeout); |
| timeout = setTimeout(() => func.apply(context, args), delay); |
| }; |
| } |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| async function fetchAndDisplayKeys(type, page = 1) { |
| const listElement = document.getElementById(`${type}Keys`); |
| const paginationControls = document.getElementById(`${type}PaginationControls`); |
| if (!listElement || !paginationControls) return; |
|
|
| |
| listElement.innerHTML = `<li><div class="text-center py-4 col-span-full"><i class="fas fa-spinner fa-spin"></i> Loading...</div></li>`; |
|
|
| |
| const itemsPerPageSelect = document.getElementById(type === 'valid' ? "itemsPerPageSelect" : "invalidItemsPerPageSelect"); |
| const limit = itemsPerPageSelect ? parseInt(itemsPerPageSelect.value, 10) : 10; |
| |
| const searchInput = document.getElementById(type === 'valid' ? "keySearchInput" : "invalidKeySearchInput"); |
| const searchTerm = searchInput ? searchInput.value : ''; |
|
|
| const thresholdInput = document.getElementById(type === 'valid' ? "failCountThreshold" : "invalidFailCountThreshold"); |
| const failCountThreshold = thresholdInput ? (thresholdInput.value === '' ? null : parseInt(thresholdInput.value, 10)) : null; |
|
|
| try { |
| const params = new URLSearchParams({ |
| page: page, |
| limit: limit, |
| status: type, |
| }); |
| if (searchTerm) { |
| params.append('search', searchTerm); |
| } |
| if (failCountThreshold !== null) { |
| params.append('fail_count_threshold', failCountThreshold); |
| } |
|
|
| const data = await fetchAPI(`/api/keys?${params.toString()}`); |
|
|
| listElement.innerHTML = ""; |
|
|
| const keys = data.keys || {}; |
| if (Object.keys(keys).length > 0) { |
| Object.entries(keys).forEach(([key, fail_count]) => { |
| const listItem = createKeyListItem(key, fail_count, type); |
| listElement.appendChild(listItem); |
| }); |
| } else { |
| listElement.innerHTML = `<li><div class="text-center py-4 col-span-full">No keys found.</div></li>`; |
| } |
|
|
| setupPaginationControls(type, data.current_page, data.total_pages); |
| updateBatchActions(type); |
|
|
| } catch (error) { |
| console.error(`Error fetching ${type} keys:`, error); |
| listElement.innerHTML = `<li><div class="text-center py-4 text-red-500 col-span-full">Error loading keys.</div></li>`; |
| } |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| function createKeyListItem(key, fail_count, type) { |
| const li = document.createElement("li"); |
| li.className = `bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border ${type === 'valid' ? 'hover:border-success-300' : 'hover:border-danger-300'} transform hover:-translate-y-1`; |
| li.dataset.key = key; |
| li.dataset.failCount = fail_count; |
|
|
| const statusBadge = type === 'valid' |
| ? `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600"><i class="fas fa-check mr-1"></i> 有效</span>` |
| : `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600"><i class="fas fa-times mr-1"></i> 无效</span>`; |
|
|
| li.innerHTML = ` |
| <input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="${type}" value="${key}"> |
| <div class="flex-grow"> |
| <div class="flex flex-col justify-between h-full gap-3"> |
| <div class="flex flex-wrap items-center gap-2"> |
| ${statusBadge} |
| <div class="flex items-center gap-1"> |
| <span class="key-text font-mono" data-full-key="${key}">${key.substring(0, 4)}...${key.substring(key.length - 4)}</span> |
| <button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="Show/Hide Key"> |
| <i class="fas fa-eye"></i> |
| </button> |
| </div> |
| <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600"> |
| <i class="fas fa-exclamation-triangle mr-1"></i> |
| 失败: ${fail_count} |
| </span> |
| </div> |
| <div class="flex flex-wrap items-center gap-2"> |
| <button class="flex items-center gap-1 bg-success-600 hover:bg-success-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200" onclick="verifyKey('${key}', this)"><i class="fas fa-check-circle"></i> 验证</button> |
| <button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200" onclick="resetKeyFailCount('${key}', this)"><i class="fas fa-redo-alt"></i> 重置</button> |
| <button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200" onclick="copyKey('${key}')"><i class="fas fa-copy"></i> 复制</button> |
| <button class="flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200" onclick="showKeyUsageDetails('${key}')"><i class="fas fa-chart-pie"></i> 详情</button> |
| <button class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200" onclick="showSingleKeyDeleteConfirmModal('${key}', this)"><i class="fas fa-trash-alt"></i> 删除</button> |
| </div> |
| </div> |
| </div> |
| `; |
| return li; |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| function setupPaginationControls(type, currentPage, totalPages) { |
| const controlsContainer = document.getElementById(`${type}PaginationControls`); |
| if (!controlsContainer) return; |
|
|
| controlsContainer.innerHTML = ""; |
|
|
| if (totalPages <= 1) return; |
|
|
| |
| const prevButton = document.createElement("button"); |
| prevButton.innerHTML = '<i class="fas fa-chevron-left"></i>'; |
| prevButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed`; |
| prevButton.disabled = currentPage === 1; |
| prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1); |
| controlsContainer.appendChild(prevButton); |
|
|
| |
| for (let i = 1; i <= totalPages; i++) { |
| |
| const pageButton = document.createElement("button"); |
| pageButton.textContent = i; |
| pageButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out ${i === currentPage ? 'active font-semibold' : ''}`; |
| pageButton.onclick = () => fetchAndDisplayKeys(type, i); |
| controlsContainer.appendChild(pageButton); |
| } |
|
|
| |
| const nextButton = document.createElement("button"); |
| nextButton.innerHTML = '<i class="fas fa-chevron-right"></i>'; |
| nextButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed`; |
| nextButton.disabled = currentPage === totalPages; |
| nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1); |
| controlsContainer.appendChild(nextButton); |
| } |
| let allValidKeys = []; |
| let allInvalidKeys = []; |
| let filteredValidKeys = []; |
| let itemsPerPage = 10; |
| let validCurrentPage = 1; |
| let invalidCurrentPage = 1; |
| |
| function initializeKeyPaginationAndSearch() { |
| const debouncedFetchValidKeys = debounce(() => fetchAndDisplayKeys('valid', 1), 300); |
| const debouncedFetchInvalidKeys = debounce(() => fetchAndDisplayKeys('invalid', 1), 300); |
|
|
| |
| const searchInput = document.getElementById("keySearchInput"); |
| if (searchInput) { |
| searchInput.addEventListener("input", debouncedFetchValidKeys); |
| } |
|
|
| const thresholdInput = document.getElementById("failCountThreshold"); |
| if (thresholdInput) { |
| thresholdInput.addEventListener("input", debouncedFetchValidKeys); |
| } |
| |
| const itemsPerPageSelect = document.getElementById("itemsPerPageSelect"); |
| if (itemsPerPageSelect) { |
| itemsPerPageSelect.addEventListener("change", () => { |
| fetchAndDisplayKeys('valid', 1); |
| }); |
| } |
|
|
| |
| const invalidSearchInput = document.getElementById("invalidKeySearchInput"); |
| if (invalidSearchInput) { |
| invalidSearchInput.addEventListener("input", debouncedFetchInvalidKeys); |
| } |
|
|
| const invalidThresholdInput = document.getElementById("invalidFailCountThreshold"); |
| if (invalidThresholdInput) { |
| invalidThresholdInput.addEventListener("input", debouncedFetchInvalidKeys); |
| } |
| |
| const invalidItemsPerPageSelect = document.getElementById("invalidItemsPerPageSelect"); |
| if (invalidItemsPerPageSelect) { |
| invalidItemsPerPageSelect.addEventListener("change", () => { |
| fetchAndDisplayKeys('invalid', 1); |
| }); |
| } |
|
|
| |
| fetchAndDisplayKeys('valid'); |
| fetchAndDisplayKeys('invalid'); |
| } |
|
|
| function registerServiceWorker() { |
| if ("serviceWorker" in navigator) { |
| window.addEventListener("load", () => { |
| navigator.serviceWorker |
| .register("/static/service-worker.js") |
| .then((registration) => { |
| console.log("ServiceWorker注册成功:", registration.scope); |
| }) |
| .catch((error) => { |
| console.log("ServiceWorker注册失败:", error); |
| }); |
| }); |
| } |
| } |
|
|
| |
| function initializeDropdownMenu() { |
| |
| const dropdownButton = document.getElementById('dropdownMenuButton'); |
| if (dropdownButton) { |
| dropdownButton.addEventListener('click', (event) => { |
| event.stopPropagation(); |
| }); |
| } |
| |
| |
| const dropdownMenu = document.getElementById('dropdownMenu'); |
| if (dropdownMenu) { |
| dropdownMenu.addEventListener('click', (event) => { |
| event.stopPropagation(); |
| }); |
| } |
| } |
|
|
| |
| let apiStatsChart = null; |
|
|
| function buildChartConfig(labels, successData, failureData) { |
| return { |
| type: 'line', |
| data: { |
| labels, |
| datasets: [ |
| { |
| label: '成功', |
| data: successData, |
| borderColor: 'rgba(16,185,129,1)', |
| backgroundColor: 'rgba(16,185,129,0.15)', |
| tension: 0.3, |
| fill: true, |
| pointRadius: 2, |
| }, |
| { |
| label: '失败', |
| data: failureData, |
| borderColor: 'rgba(239,68,68,1)', |
| backgroundColor: 'rgba(239,68,68,0.15)', |
| tension: 0.3, |
| fill: true, |
| pointRadius: 2, |
| }, |
| ], |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { position: 'top' }, |
| tooltip: { mode: 'index', intersect: false }, |
| }, |
| interaction: { mode: 'nearest', axis: 'x', intersect: false }, |
| scales: { |
| x: { title: { display: true, text: '时间' } }, |
| y: { title: { display: true, text: '调用次数' }, beginAtZero: true, ticks: { precision: 0 } }, |
| }, |
| }, |
| }; |
| } |
|
|
| async function fetchPeriodDetails(period) { |
| |
| return await fetchAPI(`/api/stats/details?period=${period}`); |
| } |
|
|
| function bucketizeDetails(period, details) { |
| |
| |
| const buckets = new Map(); |
| const addToBucket = (key, isSuccess) => { |
| if (!buckets.has(key)) buckets.set(key, { success: 0, failure: 0 }); |
| const obj = buckets.get(key); |
| if (isSuccess) obj.success += 1; else obj.failure += 1; |
| }; |
|
|
| const toKey = (ts) => { |
| const d = new Date(ts); |
| if (period === '1m') { |
| |
| const mm = String(d.getMinutes()).padStart(2,'0'); |
| const ss = String(d.getSeconds()).padStart(2,'0'); |
| return `${mm}:${ss}`; |
| } else if (period === '1h') { |
| |
| const HH = String(d.getHours()).padStart(2,'0'); |
| const mm = String(d.getMinutes()).padStart(2,'0'); |
| return `${HH}:${mm}`; |
| } else if (period === '8h') { |
| |
| const MM = String(d.getMonth()+1).padStart(2,'0'); |
| const DD = String(d.getDate()).padStart(2,'0'); |
| const HH = String(d.getHours()).padStart(2,'0'); |
| return `${MM}-${DD} ${HH}:00`; |
| } else { |
| |
| const MM = String(d.getMonth()+1).padStart(2,'0'); |
| const DD = String(d.getDate()).padStart(2,'0'); |
| const HH = String(d.getHours()).padStart(2,'0'); |
| return `${MM}-${DD} ${HH}:00`; |
| } |
| }; |
|
|
| (details || []).forEach((call) => { |
| const key = toKey(call.timestamp); |
| const isSuccess = call.status === 'success'; |
| addToBucket(key, isSuccess); |
| }); |
|
|
| |
| const labels = Array.from(buckets.keys()).sort((a,b)=>{ |
| |
| const da = Date.parse(a) || 0; |
| const db = Date.parse(b) || 0; |
| if (da && db) return da - db; |
| return a.localeCompare(b); |
| }); |
| const successData = labels.map(l => buckets.get(l).success); |
| const failureData = labels.map(l => buckets.get(l).failure); |
| return { labels, successData, failureData }; |
| } |
|
|
| async function renderApiChart(period) { |
| const canvas = document.getElementById('apiStatsChart'); |
| if (!canvas || typeof Chart === 'undefined') return; |
| try { |
| const details = await fetchPeriodDetails(period); |
| const { labels, successData, failureData } = bucketizeDetails(period, details || []); |
| const cfg = buildChartConfig(labels, successData, failureData); |
| if (apiStatsChart) { |
| apiStatsChart.destroy(); |
| } |
| apiStatsChart = new Chart(canvas.getContext('2d'), cfg); |
| } catch (e) { |
| console.error('Failed to render chart:', e); |
| } |
| } |
|
|
| |
| |
| let currentStatus = 429; |
|
|
| function getLimit() { |
| const el = document.getElementById('attentionLimitInput'); |
| const v = parseInt(el && el.value, 10); |
| if (isNaN(v)) return 10; |
| |
| return Math.min(1000, Math.max(1, v)); |
| } |
|
|
| async function fetchAndRenderAttentionKeys(statusCode = 429, limit = 10) { |
| const listEl = document.getElementById('attentionKeysList'); |
| if (!listEl) return; |
| try { |
| const data = await fetchAPI(`/api/stats/attention-keys?status_code=${statusCode}&limit=${limit}`); |
| listEl.innerHTML = ''; |
| if (!data || (Array.isArray(data) && data.length === 0) || data.error) { |
| listEl.innerHTML = '<li class="text-center text-gray-500 py-2">暂无需要注意的Key</li>'; |
| updateBatchActions('attention'); |
| return; |
| } |
| data.forEach(item => { |
| const li = document.createElement('li'); |
| li.className = 'flex items-center justify-between bg-white rounded border px-3 py-2'; |
| li.dataset.key = item.key || ''; |
| const masked = item.key ? `${item.key.substring(0,4)}...${item.key.substring(item.key.length-4)}` : 'N/A'; |
| const code = item.status_code ?? statusCode; |
| li.innerHTML = ` |
| <div class="flex items-center gap-3"> |
| <input type="checkbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded key-checkbox" value="${item.key || ''}"> |
| <span class="font-mono text-sm">${masked}</span> |
| <span class="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">${code}: ${item.count}</span> |
| </div> |
| <div class="flex items-center gap-2"> |
| <button class="px-2 py-1 text-xs rounded bg-success-600 hover:bg-success-700 text-white" title="验证此Key">验证</button> |
| <button class="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white" title="查看24小时详情">详情</button> |
| <button class="px-2 py-1 text-xs rounded bg-blue-500 hover:bg-blue-600 text-white" title="复制Key">复制</button> |
| <button class="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-900 text-white" title="删除此Key">删除</button> |
| </div>`; |
| const [verifyBtn, detailBtn, copyBtn, deleteBtn] = li.querySelectorAll('button'); |
| verifyBtn.addEventListener('click', (e) => { e.stopPropagation(); verifyKey(item.key, e.currentTarget); }); |
| detailBtn.addEventListener('click', (e) => { e.stopPropagation(); window.showKeyUsageDetails(item.key); }); |
| copyBtn.addEventListener('click', (e) => { e.stopPropagation(); copyKey(item.key); }); |
| deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); showSingleKeyDeleteConfirmModal(item.key, e.currentTarget); }); |
| |
| const checkbox = li.querySelector('.key-checkbox'); |
| if (checkbox) { |
| checkbox.addEventListener('change', () => updateBatchActions('attention')); |
| } |
| listEl.appendChild(li); |
| }); |
| updateBatchActions('attention'); |
| } catch (e) { |
| listEl.innerHTML = `<li class="text-center text-red-500 py-2">加载失败: ${e.message}</li>`; |
| updateBatchActions('attention'); |
| } |
| } |
|
|
| function initChartControls() { |
| const btn1h = document.getElementById('chartBtn1h'); |
| const btn8h = document.getElementById('chartBtn8h'); |
| const btn24h = document.getElementById('chartBtn24h'); |
| const setActive = (activeBtn) => { |
| [btn1h, btn8h, btn24h].forEach(btn => { |
| if (!btn) return; |
| if (btn === activeBtn) { |
| btn.classList.remove('bg-gray-200'); |
| btn.classList.add('bg-primary-600','text-white'); |
| } else { |
| btn.classList.add('bg-gray-200'); |
| btn.classList.remove('bg-primary-600','text-white'); |
| } |
| }); |
| }; |
|
|
| if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); }); |
| if (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); }); |
| if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); }); |
|
|
| |
| if (btn1h) setActive(btn1h); |
| renderApiChart('1h'); |
| } |
|
|
| function initAttentionKeysControls() { |
| const btn429 = document.getElementById('attentionErr429'); |
| const btn403 = document.getElementById('attentionErr403'); |
| const btn400 = document.getElementById('attentionErr400'); |
| |
| const limitInput = document.getElementById('attentionLimitInput'); |
| const setActive = (activeBtn) => { |
| [btn429, btn403, btn400].forEach(btn => { |
| if (!btn) return; |
| if (btn === activeBtn) { |
| btn.classList.remove('bg-gray-200'); |
| btn.classList.add('bg-primary-600','text-white'); |
| } else { |
| btn.classList.add('bg-gray-200'); |
| btn.classList.remove('bg-primary-600','text-white'); |
| } |
| }); |
| }; |
| if (btn429) btn429.addEventListener('click', () => { setActive(btn429); currentStatus = 429; fetchAndRenderAttentionKeys(429, getLimit()); }); |
| if (btn403) btn403.addEventListener('click', () => { setActive(btn403); currentStatus = 403; fetchAndRenderAttentionKeys(403, getLimit()); }); |
| if (btn400) btn400.addEventListener('click', () => { setActive(btn400); currentStatus = 400; fetchAndRenderAttentionKeys(400, getLimit()); }); |
| |
| const input = document.getElementById('attentionErrCustom'); |
| const go = document.getElementById('attentionErrGo'); |
| const trigger = () => { |
| if (!input) return; |
| const val = parseInt(input.value, 10); |
| if (!isNaN(val) && val >= 100 && val <= 599) { |
| setActive(null); |
| [btn429, btn403, btn400].forEach(btn=>{ if(btn){ btn.classList.add('bg-gray-200'); btn.classList.remove('bg-primary-600','text-white'); }}); |
| currentStatus = val; |
| fetchAndRenderAttentionKeys(val, getLimit()); |
| } else { |
| showNotification('请输入100-599之间的HTTP状态码', 'warning'); |
| } |
| }; |
| if (go) go.addEventListener('click', trigger); |
| if (input) input.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ trigger(); }}); |
|
|
| |
| if (limitInput) limitInput.addEventListener('change', () => { |
| fetchAndRenderAttentionKeys(currentStatus, getLimit()); |
| }); |
|
|
| if (btn429) setActive(btn429); |
| } |
|
|
| |
| document.addEventListener("DOMContentLoaded", () => { |
| initializePageAnimationsAndEffects(); |
| initializeSectionToggleListeners(); |
| initializeKeyFilterControls(); |
| initializeGlobalBatchVerificationHandlers(); |
| initializeKeySelectionListeners(); |
| initializeKeyPaginationAndSearch(); |
| registerServiceWorker(); |
| initializeDropdownMenu(); |
| initChartControls(); |
| initAttentionKeysControls(); |
| fetchAndRenderAttentionKeys(429, 10); |
|
|
| |
| |
| |
| }); |
|
|
| |
|
|
| |
| function showSingleKeyDeleteConfirmModal(key, button) { |
| const modalElement = document.getElementById("singleKeyDeleteConfirmModal"); |
| const titleElement = document.getElementById( |
| "singleKeyDeleteConfirmModalTitle" |
| ); |
| const messageElement = document.getElementById( |
| "singleKeyDeleteConfirmModalMessage" |
| ); |
| const confirmButton = document.getElementById("confirmSingleKeyDeleteBtn"); |
|
|
| const keyDisplay = |
| key.substring(0, 4) + "..." + key.substring(key.length - 4); |
| titleElement.textContent = "确认删除密钥"; |
| messageElement.innerHTML = `确定要删除密钥 <span class="font-mono text-amber-300 font-semibold">${keyDisplay}</span> 吗?<br>此操作无法撤销。`; |
|
|
| |
| const newConfirmButton = confirmButton.cloneNode(true); |
| confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton); |
|
|
| newConfirmButton.onclick = () => executeSingleKeyDelete(key, button); |
|
|
| modalElement.classList.remove("hidden"); |
| } |
|
|
| |
| function closeSingleKeyDeleteConfirmModal() { |
| document |
| .getElementById("singleKeyDeleteConfirmModal") |
| .classList.add("hidden"); |
| } |
|
|
| |
| async function executeSingleKeyDelete(key, button) { |
| closeSingleKeyDeleteConfirmModal(); |
|
|
| button.disabled = true; |
| const originalHtml = button.innerHTML; |
| |
| button.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>删除中'; |
|
|
| try { |
| const response = await fetchAPI(`/api/config/keys/${key}`, { |
| method: "DELETE", |
| }); |
|
|
| if (response.success) { |
| |
| showResultModal(true, response.message || "密钥删除成功", true); |
| } else { |
| |
| showResultModal(false, response.message || "密钥删除失败", false); |
| button.innerHTML = originalHtml; |
| button.disabled = false; |
| } |
| } catch (error) { |
| console.error("删除密钥 API 请求失败:", error); |
| showResultModal(false, `删除密钥请求失败: ${error.message}`, false); |
| button.innerHTML = originalHtml; |
| button.disabled = false; |
| } |
| } |
|
|
| |
| function showDeleteConfirmationModal(type, event) { |
| if (event) { |
| event.stopPropagation(); |
| } |
| const modalElement = document.getElementById("deleteConfirmModal"); |
| const titleElement = document.getElementById("deleteConfirmModalTitle"); |
| const messageElement = document.getElementById("deleteConfirmModalMessage"); |
| const confirmButton = document.getElementById("confirmDeleteBtn"); |
|
|
| const selectedKeys = getSelectedKeys(type); |
| const count = selectedKeys.length; |
|
|
| titleElement.textContent = "确认批量删除"; |
| if (count > 0) { |
| messageElement.textContent = `确定要批量删除选中的 ${count} 个${ |
| type === "valid" ? "有效" : "无效" |
| }密钥吗?此操作无法撤销。`; |
| confirmButton.disabled = false; |
| } else { |
| |
| messageElement.textContent = `请先选择要删除的${ |
| type === "valid" ? "有效" : "无效" |
| }密钥。`; |
| confirmButton.disabled = true; |
| } |
|
|
| confirmButton.onclick = () => executeDeleteSelectedKeys(type); |
| modalElement.classList.remove("hidden"); |
| } |
|
|
| |
| function closeDeleteConfirmationModal() { |
| document.getElementById("deleteConfirmModal").classList.add("hidden"); |
| } |
|
|
| |
| async function executeDeleteSelectedKeys(type) { |
| closeDeleteConfirmationModal(); |
|
|
| const selectedKeys = getSelectedKeys(type); |
| if (selectedKeys.length === 0) { |
| showNotification("没有选中的密钥可删除", "warning"); |
| return; |
| } |
|
|
| |
| const batchActionsDiv = document.getElementById(`${type}BatchActions`); |
| const deleteButton = batchActionsDiv |
| ? batchActionsDiv.querySelector("button.bg-red-600") |
| : null; |
|
|
| let originalDeleteBtnHtml = ""; |
| if (deleteButton) { |
| originalDeleteBtnHtml = deleteButton.innerHTML; |
| deleteButton.disabled = true; |
| deleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 删除中'; |
| } |
|
|
| try { |
| const response = await fetchAPI("/api/config/keys/delete-selected", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ keys: selectedKeys }), |
| }); |
|
|
| if (response.success) { |
| |
| const message = |
| response.message || |
| `成功删除 ${response.deleted_count || selectedKeys.length} 个密钥。`; |
| showResultModal(true, message, true); |
| } else { |
| showResultModal(false, response.message || "批量删除密钥失败", true); |
| } |
| } catch (error) { |
| console.error("批量删除 API 请求失败:", error); |
| showResultModal(false, `批量删除请求失败: ${error.message}`, true); |
| } finally { |
| |
| |
| |
| |
| |
| |
| } |
| } |
|
|
| |
|
|
| function toggleKeyVisibility(button) { |
| const keyContainer = button.closest(".flex.items-center.gap-1"); |
| const keyTextSpan = keyContainer.querySelector(".key-text"); |
| const eyeIcon = button.querySelector("i"); |
| const fullKey = keyTextSpan.dataset.fullKey; |
| const maskedKey = |
| fullKey.substring(0, 4) + "..." + fullKey.substring(fullKey.length - 4); |
|
|
| if (keyTextSpan.textContent === maskedKey) { |
| keyTextSpan.textContent = fullKey; |
| eyeIcon.classList.remove("fa-eye"); |
| eyeIcon.classList.add("fa-eye-slash"); |
| button.title = "隐藏密钥"; |
| } else { |
| keyTextSpan.textContent = maskedKey; |
| eyeIcon.classList.remove("fa-eye-slash"); |
| eyeIcon.classList.add("fa-eye"); |
| button.title = "显示密钥"; |
| } |
| } |
|
|
| |
|
|
| |
| async function showApiCallDetails( |
| period, |
| totalCalls, |
| successCalls, |
| failureCalls |
| ) { |
| const modal = document.getElementById("apiCallDetailsModal"); |
| const contentArea = document.getElementById("apiCallDetailsContent"); |
| const titleElement = document.getElementById("apiCallDetailsModalTitle"); |
|
|
| if (!modal || !contentArea || !titleElement) { |
| console.error("无法找到 API 调用详情模态框元素"); |
| showNotification("无法显示详情,页面元素缺失", "error"); |
| return; |
| } |
|
|
| |
| let periodText = ""; |
| switch (period) { |
| case "1m": |
| periodText = "最近 1 分钟"; |
| break; |
| case "1h": |
| periodText = "最近 1 小时"; |
| break; |
| case "24h": |
| periodText = "最近 24 小时"; |
| break; |
| default: |
| periodText = "指定时间段"; |
| } |
| titleElement.textContent = `${periodText} API 调用详情`; |
|
|
| |
| modal.classList.remove("hidden"); |
| contentArea.innerHTML = ` |
| <div class="text-center py-10"> |
| <i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i> |
| <p class="text-gray-500 mt-2">加载中...</p> |
| </div>`; |
|
|
| try { |
| const data = await fetchAPI(`/api/stats/details?period=${period}`); |
| if (data) { |
| renderApiCallDetails( |
| data, |
| contentArea, |
| totalCalls, |
| successCalls, |
| failureCalls |
| ); |
| } else { |
| renderApiCallDetails( |
| [], |
| contentArea, |
| totalCalls, |
| successCalls, |
| failureCalls |
| ); |
| } |
| } catch (apiError) { |
| console.error("获取 API 调用详情失败:", apiError); |
| contentArea.innerHTML = ` |
| <div class="text-center py-10 text-danger-500"> |
| <i class="fas fa-exclamation-triangle text-3xl"></i> |
| <p class="mt-2">加载失败: ${apiError.message}</p> |
| </div>`; |
| } |
| } |
|
|
| |
| async function fetchAndShowErrorDetail(logId) { |
| try { |
| const detail = await fetchAPI(`/api/logs/errors/${logId}/details`); |
| if (!detail) { |
| showResultModal(false, `未找到日志 ${logId}`, false); |
| return; |
| } |
| const container = document.createElement('div'); |
| container.className = 'space-y-3 text-sm'; |
| const basic = document.createElement('div'); |
| basic.innerHTML = ` |
| <div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div> |
| <div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div> |
| <div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div> |
| <div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div> |
| `; |
| const codeBlock = document.createElement('pre'); |
| codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700'; |
| codeBlock.textContent = detail.error_log || '无错误日志内容'; |
| const reqBlock = document.createElement('pre'); |
| reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words'; |
| reqBlock.textContent = detail.request_msg || ''; |
| container.appendChild(basic); |
| container.appendChild(codeBlock); |
| if (detail.request_msg) container.appendChild(reqBlock); |
| showResultModal(false, container, false); |
| } catch (e) { |
| showResultModal(false, `加载日志详情失败: ${e.message}`, false); |
| } |
| } |
|
|
| |
| async function fetchAndShowErrorDetailByInfo(geminiKey, statusCode, timestampISO) { |
| try { |
| if (!geminiKey || !timestampISO) { |
| showResultModal(false, '缺少必要参数,无法查询错误详情', false); |
| return; |
| } |
| const params = new URLSearchParams(); |
| params.set('gemini_key', geminiKey); |
| params.set('timestamp', timestampISO); |
| if (statusCode !== null && statusCode !== undefined) { |
| params.set('status_code', String(statusCode)); |
| } |
| params.set('window_seconds', '100'); |
| const detail = await fetchAPI(`/api/logs/errors/lookup?${params.toString()}`); |
| if (!detail) { |
| showResultModal(false, '未找到匹配的错误日志', false); |
| return; |
| } |
| const container = document.createElement('div'); |
| container.className = 'space-y-3 text-sm'; |
| const basic = document.createElement('div'); |
| basic.innerHTML = ` |
| <div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div> |
| <div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div> |
| <div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div> |
| <div><span class="font-semibold">错误码:</span> ${detail.error_code ?? 'N/A'}</div> |
| <div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div> |
| `; |
| const codeBlock = document.createElement('pre'); |
| codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700'; |
| codeBlock.textContent = detail.error_log || '无错误日志内容'; |
| const reqBlock = document.createElement('pre'); |
| reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words'; |
| reqBlock.textContent = detail.request_msg || ''; |
| container.appendChild(basic); |
| container.appendChild(codeBlock); |
| if (detail.request_msg) container.appendChild(reqBlock); |
| showResultModal(false, container, false); |
| } catch (e) { |
| showResultModal(false, `加载日志详情失败: ${e.message}`, false); |
| } |
| } |
|
|
| |
| function closeApiCallDetailsModal() { |
| const modal = document.getElementById("apiCallDetailsModal"); |
| if (modal) { |
| modal.classList.add("hidden"); |
| } |
| } |
|
|
| |
| function renderApiCallDetails( |
| data, |
| container, |
| totalCalls, |
| successCalls, |
| failureCalls |
| ) { |
| let summaryHtml = ""; |
| |
| if ( |
| totalCalls !== undefined && |
| successCalls !== undefined && |
| failureCalls !== undefined |
| ) { |
| const total = Number(totalCalls) || 0; |
| const succ = Number(successCalls) || 0; |
| const fail = Number(failureCalls) || 0; |
| const denom = total > 0 ? total : succ + fail; |
| const succRate = denom > 0 ? ((succ / denom) * 100).toFixed(1) : '0.0'; |
| const failRate = denom > 0 ? ((fail / denom) * 100).toFixed(1) : '0.0'; |
|
|
| summaryHtml = ` |
| <div class="mb-4"> |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden"> |
| <thead class="bg-gray-50 dark:bg-gray-700/50"> |
| <tr> |
| <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th> |
| <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th> |
| <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th> |
| <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th> |
| </tr> |
| </thead> |
| <tbody class="bg-white dark:bg-gray-800"> |
| <tr> |
| <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${succRate}%</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| `; |
| } |
|
|
| if (!data || data.length === 0) { |
| container.innerHTML = |
| summaryHtml + |
| ` |
| <div class="text-center py-10 text-gray-500 dark:text-gray-400"> |
| <i class="fas fa-info-circle text-3xl"></i> |
| <p class="mt-2">该时间段内没有 API 调用记录。</p> |
| </div>`; |
| return; |
| } |
|
|
| |
| let tableHtml = ` |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> |
| <thead class="bg-gray-50 dark:bg-gray-700/50"> |
| <tr> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态码</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">耗时(ms)</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th> |
| <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">详情</th> |
| </tr> |
| </thead> |
| <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> |
| `; |
|
|
| |
| data.forEach((call) => { |
| const timestamp = new Date(call.timestamp).toLocaleString(); |
| const keyDisplay = call.key |
| ? `${call.key.substring(0, 4)}...${call.key.substring( |
| call.key.length - 4 |
| )}` |
| : "N/A"; |
| const statusClass = |
| call.status === "success" |
| ? "text-success-600 dark:text-success-400" |
| : "text-danger-600 dark:text-danger-400"; |
| const statusIcon = |
| call.status === "success" ? "fa-check-circle" : "fa-times-circle"; |
|
|
| const detailsBtn = |
| call.status === "failure" |
| ? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${call.key}', ${call.status_code ?? 'null'}, '${call.timestamp}')"> |
| <i class="fas fa-info-circle mr-1"></i>详情 |
| </button>` |
| : "-"; |
|
|
| tableHtml += ` |
| <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30"> |
| <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.latency_ms ?? "-"}</td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}"> |
| <i class="fas ${statusIcon} mr-1"></i> |
| ${call.status} |
| </td> |
| <td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td> |
| </tr> |
| `; |
| }); |
|
|
| tableHtml += ` |
| </tbody> |
| </table> |
| `; |
|
|
| container.innerHTML = summaryHtml + tableHtml; |
| } |
|
|
| |
|
|
| |
| window.showKeyUsageDetails = async function (key) { |
| const modal = document.getElementById("keyUsageDetailsModal"); |
| const contentArea = document.getElementById("keyUsageDetailsContent"); |
| const titleElement = document.getElementById("keyUsageDetailsModalTitle"); |
| const keyDisplay = |
| key.substring(0, 4) + "..." + key.substring(key.length - 4); |
|
|
| if (!modal || !contentArea || !titleElement) { |
| console.error("无法找到密钥使用详情模态框元素"); |
| showNotification("无法显示详情,页面元素缺失", "error"); |
| return; |
| } |
|
|
| |
| const controlsHtml = ` |
| <div class="flex items-center gap-2 mb-3 text-xs"> |
| <button id="keyBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button> |
| <button id="keyBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button> |
| <button id="keyBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button> |
| </div> |
| <div class="h-48 mb-4"> |
| <canvas id="keyUsageChart"></canvas> |
| </div> |
| <div id="keyUsageTable"></div>`; |
| contentArea.innerHTML = controlsHtml; |
|
|
| |
| titleElement.textContent = `密钥 ${keyDisplay} - 请求详情`; |
|
|
| |
| modal.classList.remove("hidden"); |
|
|
| let keyUsageChart = null; |
| function buildKeyChartConfig(labels, successData, failureData) { |
| return buildChartConfig(labels, successData, failureData); |
| } |
| function bucketizeKeyDetails(period, details) { |
| return bucketizeDetails(period, details); |
| } |
| function renderKeyUsageTable(data) { |
| const container = document.getElementById('keyUsageTable'); |
| if (!container) return; |
| if (!data || data.length === 0) { |
| container.innerHTML = ` |
| <div class="text-center py-10 text-gray-500"> |
| <i class="fas fa-info-circle text-3xl"></i> |
| <p class="mt-2">该时间段内没有 API 调用记录。</p> |
| </div>`; |
| return; |
| } |
| let tableHtml = ` |
| <table class="min-w-full divide-y divide-gray-200"> |
| <thead class="bg-gray-50"> |
| <tr> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态码</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时(ms)</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">详情</th> |
| </tr> |
| </thead> |
| <tbody class="bg-white divide-y divide-gray-200">`; |
| data.forEach((row) => { |
| const timestamp = new Date(row.timestamp).toLocaleString(); |
| const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600'; |
| const statusIcon = row.status === 'success' ? 'fa-check-circle' : 'fa-times-circle'; |
| const detailsBtn = row.status === 'failure' |
| ? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${row.key}', ${row.status_code ?? 'null'}, '${row.timestamp}')"> |
| <i class="fas fa-info-circle mr-1"></i>详情 |
| </button>` |
| : '-'; |
| tableHtml += ` |
| <tr> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${timestamp}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.model || 'N/A'}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.status_code ?? '-'}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.latency_ms ?? '-'}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm ${statusClass}"><i class="fas ${statusIcon} mr-1"></i>${row.status}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm">${detailsBtn}</td> |
| </tr>`; |
| }); |
| tableHtml += `</tbody></table>`; |
| container.innerHTML = tableHtml; |
| } |
| async function renderForPeriod(period) { |
| try { |
| const details = await fetchAPI(`/api/stats/key-details?key=${encodeURIComponent(key)}&period=${period}`); |
| const { labels, successData, failureData } = bucketizeKeyDetails(period, details || []); |
| const canvas = document.getElementById('keyUsageChart'); |
| if (canvas && typeof Chart !== 'undefined') { |
| const cfg = buildKeyChartConfig(labels, successData, failureData); |
| if (keyUsageChart) keyUsageChart.destroy(); |
| keyUsageChart = new Chart(canvas.getContext('2d'), cfg); |
| } |
| renderKeyUsageTable(details || []); |
| } catch (e) { |
| console.error('加载密钥期内详情失败:', e); |
| const tableContainer = document.getElementById('keyUsageTable'); |
| if (tableContainer) { |
| tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500"> |
| <i class="fas fa-exclamation-triangle text-3xl"></i> |
| <p class="mt-2">加载失败: ${e.message}</p> |
| </div>`; |
| } |
| } |
| } |
|
|
| |
| const btn1h = document.getElementById('keyBtn1h'); |
| const btn8h = document.getElementById('keyBtn8h'); |
| const btn24h = document.getElementById('keyBtn24h'); |
| const setActive = (activeBtn) => { |
| [btn1h, btn8h, btn24h].forEach((btn) => { |
| if (!btn) return; |
| if (btn === activeBtn) { |
| btn.classList.remove('bg-gray-200'); |
| btn.classList.add('bg-primary-600','text-white'); |
| } else { |
| btn.classList.add('bg-gray-200'); |
| btn.classList.remove('bg-primary-600','text-white'); |
| } |
| }); |
| }; |
| if (btn1h) btn1h.addEventListener('click', () => { setActive(btn1h); renderForPeriod('1h'); }); |
| if (btn8h) btn8h.addEventListener('click', () => { setActive(btn8h); renderForPeriod('8h'); }); |
| if (btn24h) btn24h.addEventListener('click', () => { setActive(btn24h); renderForPeriod('24h'); }); |
| if (btn1h) setActive(btn1h); |
| renderForPeriod('1h'); |
| }; |
|
|
| |
| window.closeKeyUsageDetailsModal = function () { |
| const modal = document.getElementById("keyUsageDetailsModal"); |
| if (modal) { |
| modal.classList.add("hidden"); |
| } |
| }; |
|
|
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| function displayPage(type, page, keyItemsArray) { |
| const listElement = document.getElementById(`${type}Keys`); |
| const paginationControls = document.getElementById( |
| `${type}PaginationControls` |
| ); |
| if (!listElement || !paginationControls) return; |
|
|
| |
| |
| |
| setupPaginationControls(type, page, totalPages); |
| updateBatchActions(type); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function setupPaginationControls(type, currentPage, totalPages) { |
| const controlsContainer = document.getElementById( |
| `${type}PaginationControls` |
| ); |
| if (!controlsContainer) return; |
|
|
| controlsContainer.innerHTML = ""; |
|
|
| if (totalPages <= 1) { |
| return; |
| } |
|
|
| |
| const baseButtonClasses = |
| "pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out"; |
| |
| |
| |
| |
|
|
| |
| const prevButton = document.createElement("button"); |
| prevButton.innerHTML = '<i class="fas fa-chevron-left"></i>'; |
| prevButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`; |
| prevButton.disabled = currentPage === 1; |
| prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1); |
| controlsContainer.appendChild(prevButton); |
|
|
| |
| const maxPageButtons = 5; |
| let startPage = Math.max(1, currentPage - Math.floor(maxPageButtons / 2)); |
| let endPage = Math.min(totalPages, startPage + maxPageButtons - 1); |
|
|
| if (endPage - startPage + 1 < maxPageButtons) { |
| startPage = Math.max(1, endPage - maxPageButtons + 1); |
| } |
|
|
| |
| if (startPage > 1) { |
| const firstPageButton = document.createElement("button"); |
| firstPageButton.textContent = "1"; |
| firstPageButton.className = `${baseButtonClasses}`; |
| firstPageButton.onclick = () => fetchAndDisplayKeys(type, 1); |
| controlsContainer.appendChild(firstPageButton); |
| if (startPage > 2) { |
| const ellipsis = document.createElement("span"); |
| ellipsis.textContent = "..."; |
| ellipsis.className = "px-3 py-1 text-gray-300 text-sm"; |
| controlsContainer.appendChild(ellipsis); |
| } |
| } |
|
|
| |
| for (let i = startPage; i <= endPage; i++) { |
| const pageButton = document.createElement("button"); |
| pageButton.textContent = i; |
| pageButton.className = `${baseButtonClasses} ${ |
| i === currentPage |
| ? "active font-semibold" // Relies on .pagination-button.active CSS for styling |
| : "" // Non-active buttons just use .pagination-button style |
| }`; |
| pageButton.onclick = () => fetchAndDisplayKeys(type, i); |
| controlsContainer.appendChild(pageButton); |
| } |
|
|
| |
| if (endPage < totalPages) { |
| if (endPage < totalPages - 1) { |
| const ellipsis = document.createElement("span"); |
| ellipsis.textContent = "..."; |
| ellipsis.className = "px-3 py-1 text-gray-300 text-sm"; |
| controlsContainer.appendChild(ellipsis); |
| } |
| const lastPageButton = document.createElement("button"); |
| lastPageButton.textContent = totalPages; |
| lastPageButton.className = `${baseButtonClasses}`; |
| lastPageButton.onclick = () => fetchAndDisplayKeys(type, totalPages); |
| controlsContainer.appendChild(lastPageButton); |
| } |
|
|
| |
| const nextButton = document.createElement("button"); |
| nextButton.innerHTML = '<i class="fas fa-chevron-right"></i>'; |
| nextButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`; |
| nextButton.disabled = currentPage === totalPages; |
| nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1); |
| controlsContainer.appendChild(nextButton); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| function filterAndSearchValidKeys() { |
| fetchAndDisplayKeys('valid', 1); |
| } |
|
|
| |
|
|
| |
| window.toggleDropdownMenu = function() { |
| const dropdownMenu = document.getElementById('dropdownMenu'); |
| const isVisible = dropdownMenu.classList.contains('show'); |
| |
| if (isVisible) { |
| hideDropdownMenu(); |
| } else { |
| showDropdownMenu(); |
| } |
| } |
|
|
| |
| function showDropdownMenu() { |
| const dropdownMenu = document.getElementById('dropdownMenu'); |
| dropdownMenu.classList.add('show'); |
| |
| |
| document.addEventListener('click', handleOutsideClick); |
| } |
|
|
| |
| function hideDropdownMenu() { |
| const dropdownMenu = document.getElementById('dropdownMenu'); |
| dropdownMenu.classList.remove('show'); |
| |
| |
| document.removeEventListener('click', handleOutsideClick); |
| } |
|
|
| |
| function handleOutsideClick(event) { |
| const dropdownToggle = document.querySelector('.dropdown-toggle'); |
| if (!dropdownToggle.contains(event.target)) { |
| hideDropdownMenu(); |
| } |
| } |
|
|
| |
| async function copyAllKeys() { |
| hideDropdownMenu(); |
| |
| try { |
| |
| const response = await fetchAPI('/api/keys/all'); |
| |
| const allKeys = [...response.valid_keys, ...response.invalid_keys]; |
| |
| if (allKeys.length === 0) { |
| showNotification("没有找到任何密钥", "warning"); |
| return; |
| } |
| |
| const keysText = allKeys.join('\n'); |
| await copyToClipboard(keysText); |
| showNotification(`已成功复制 ${allKeys.length} 个密钥到剪贴板`); |
| |
| } catch (error) { |
| console.error('复制全部密钥失败:', error); |
| showNotification(`复制失败: ${error.message}`, "error"); |
| } |
| } |
|
|
| |
| window.verifyAllKeys = async function() { |
| hideDropdownMenu(); |
| |
| try { |
| |
| const response = await fetchAPI('/api/keys/all'); |
| |
| const allKeys = [...response.valid_keys, ...response.invalid_keys]; |
| |
| if (allKeys.length === 0) { |
| showNotification("没有找到任何密钥可验证", "warning"); |
| return; |
| } |
| |
| |
| showVerifyModalForAllKeys(allKeys); |
| |
| } catch (error) { |
| console.error('获取所有密钥失败:', error); |
| showNotification(`获取密钥失败: ${error.message}`, "error"); |
| } |
| } |
|
|
| |
| function showVerifyModalForAllKeys(allKeys) { |
| const modalElement = document.getElementById("verifyModal"); |
| const titleElement = document.getElementById("verifyModalTitle"); |
| const messageElement = document.getElementById("verifyModalMessage"); |
| const confirmButton = document.getElementById("confirmVerifyBtn"); |
| |
| titleElement.textContent = "批量验证所有密钥"; |
| messageElement.textContent = `确定要验证所有 ${allKeys.length} 个密钥吗?此操作可能需要较长时间。`; |
| confirmButton.disabled = false; |
| |
| |
| confirmButton.onclick = () => executeVerifyAllKeys(allKeys); |
| |
| |
| modalElement.classList.remove("hidden"); |
| } |
|
|
| |
| async function executeVerifyAllKeys(allKeys) { |
| closeVerifyModal(); |
| |
| |
| const batchSizeInput = document.getElementById("batchSize"); |
| const batchSize = parseInt(batchSizeInput.value, 10) || 10; |
| |
| |
| showProgressModal(`批量验证所有 ${allKeys.length} 个密钥`); |
| |
| let allSuccessfulKeys = []; |
| let allFailedKeys = {}; |
| let processedCount = 0; |
| |
| for (let i = 0; i < allKeys.length; i += batchSize) { |
| const batch = allKeys.slice(i, i + batchSize); |
| const progressText = `正在验证批次 ${Math.floor(i / batchSize) + 1} / ${Math.ceil(allKeys.length / batchSize)} (密钥 ${i + 1}-${Math.min(i + batchSize, allKeys.length)})`; |
| |
| updateProgress(i, allKeys.length, progressText); |
| addProgressLog(`处理批次: ${batch.length}个密钥...`); |
| |
| try { |
| const options = { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ keys: batch }), |
| }; |
| const data = await fetchAPI(`/gemini/v1beta/verify-selected-keys`, options); |
| |
| if (data) { |
| if (data.successful_keys && data.successful_keys.length > 0) { |
| allSuccessfulKeys = allSuccessfulKeys.concat(data.successful_keys); |
| addProgressLog(`✅ 批次成功: ${data.successful_keys.length} 个`); |
| } |
| if (data.failed_keys && Object.keys(data.failed_keys).length > 0) { |
| Object.assign(allFailedKeys, data.failed_keys); |
| addProgressLog(`❌ 批次失败: ${Object.keys(data.failed_keys).length} 个`, true); |
| } |
| } else { |
| addProgressLog(`- 批次返回空数据`, true); |
| } |
| } catch (apiError) { |
| addProgressLog(`❌ 批次请求失败: ${apiError.message}`, true); |
| |
| batch.forEach(key => { |
| allFailedKeys[key] = apiError.message; |
| }); |
| } |
| processedCount += batch.length; |
| updateProgress(processedCount, allKeys.length, progressText); |
| } |
| |
| updateProgress( |
| allKeys.length, |
| allKeys.length, |
| `所有批次验证完成!` |
| ); |
| |
| |
| closeProgressModal(false); |
| showVerificationResultModal({ |
| successful_keys: allSuccessfulKeys, |
| failed_keys: allFailedKeys, |
| valid_count: allSuccessfulKeys.length, |
| invalid_count: Object.keys(allFailedKeys).length |
| }); |
| } |