2190 lines
82 KiB
JavaScript
2190 lines
82 KiB
JavaScript
// 统计数据可视化交互效果
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 添加统计项动画效果
|
||
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) {
|
||
const checkboxes = document.querySelectorAll(
|
||
`#${type}Keys .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`);
|
||
const selectedCountSpan = document.getElementById(`${type}SelectedCount`);
|
||
const buttons = batchActionsDiv.querySelectorAll("button");
|
||
|
||
if (count > 0) {
|
||
batchActionsDiv.classList.remove("hidden");
|
||
selectedCountSpan.textContent = count;
|
||
buttons.forEach((button) => (button.disabled = false));
|
||
} else {
|
||
batchActionsDiv.classList.add("hidden");
|
||
selectedCountSpan.textContent = "0";
|
||
buttons.forEach((button) => (button.disabled = true));
|
||
}
|
||
|
||
// 更新全选复选框状态
|
||
const selectAllCheckbox = document.getElementById(
|
||
`selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`
|
||
);
|
||
const allCheckboxes = document.querySelectorAll(`#${type}Keys .key-checkbox`);
|
||
// 只有在有可见的 key 时才考虑全选状态
|
||
const visibleCheckboxes = document.querySelectorAll(
|
||
`#${type}Keys 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 listElement = document.getElementById(`${type}Keys`);
|
||
// Select checkboxes within LI elements that are NOT styled with display:none
|
||
// This targets currently visible items based on filtering.
|
||
const visibleCheckboxes = listElement.querySelectorAll(
|
||
`li:not([style*="display: none"]) .key-checkbox`
|
||
);
|
||
|
||
visibleCheckboxes.forEach((checkbox) => {
|
||
checkbox.checked = isChecked;
|
||
const listItem = checkbox.closest("li[data-key]"); // Get the LI from the current DOM
|
||
if (listItem) {
|
||
listItem.classList.toggle("selected", isChecked);
|
||
|
||
// Sync with master array
|
||
const key = listItem.dataset.key;
|
||
const masterList = type === "valid" ? allValidKeys : allInvalidKeys;
|
||
if (masterList) {
|
||
// Ensure masterList is defined
|
||
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");
|
||
});
|
||
}
|
||
|
||
// showCopyStatus 函数已废弃。
|
||
|
||
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",
|
||
});
|
||
|
||
// 根据验证结果更新UI并显示模态提示框
|
||
if (data && (data.success || data.status === "valid")) {
|
||
// 验证成功,显示成功结果
|
||
button.style.backgroundColor = "#27ae60";
|
||
// 使用结果模态框显示成功消息
|
||
showResultModal(true, "密钥验证成功");
|
||
// 模态框关闭时会自动刷新页面
|
||
} else {
|
||
// 验证失败,显示失败结果
|
||
const errorMsg = data.error || "密钥无效";
|
||
button.style.backgroundColor = "#e74c3c";
|
||
// 使用结果模态框显示失败消息,改为true以在关闭时刷新
|
||
showResultModal(false, "密钥验证失败: " + errorMsg, true);
|
||
}
|
||
} catch (apiError) {
|
||
console.error("密钥验证 API 请求失败:", apiError);
|
||
showResultModal(false, `验证请求失败: ${apiError.message}`, true);
|
||
} finally {
|
||
// 1秒后恢复按钮原始状态 (如果页面不刷新)
|
||
// 由于现在成功和失败都会刷新,这部分逻辑可以简化或移除
|
||
// 但为了防止未来修改刷新逻辑,暂时保留,但可能不会执行
|
||
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);
|
||
// 确保在捕获到错误时恢复按钮状态 (如果页面不刷新)
|
||
// button.disabled = false; // 由 finally 处理或因刷新而无需处理
|
||
// button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
|
||
showResultModal(false, "验证处理失败: " + error.message, true); // 改为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",
|
||
});
|
||
|
||
// 根据重置结果更新UI
|
||
if (data.success) {
|
||
showNotification("失败计数重置成功");
|
||
// 成功时保留绿色背景一会儿
|
||
button.style.backgroundColor = "#27ae60";
|
||
// 稍后刷新页面
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
const errorMsg = data.message || "重置失败";
|
||
showNotification("重置失败: " + errorMsg, "error");
|
||
// 失败时保留红色背景一会儿
|
||
button.style.backgroundColor = "#e74c3c";
|
||
// 如果失败,1秒后恢复按钮
|
||
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.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; // 使用 innerText 防止 XSS
|
||
messageElement.appendChild(messageDiv);
|
||
} else if (message instanceof Node) {
|
||
// 如果传入的是 DOM 节点,直接添加
|
||
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 {
|
||
// 都为 0 或其他情况
|
||
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";
|
||
// Store full key in dataset for potential future use, display masked
|
||
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); // Get array of failed keys
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 如果没有匹配到特定模式,使用500
|
||
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); // Always reload
|
||
|
||
// 显示模态框
|
||
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" });
|
||
}
|
||
|
||
// 移除这个函数,因为它可能正在干扰按钮的显示
|
||
// HTML中已经设置了滚动按钮为flex显示,不需要JavaScript额外控制
|
||
// function updateScrollButtons() {
|
||
// // 不执行任何操作
|
||
// }
|
||
|
||
function refreshPage(button) {
|
||
button.classList.add("loading"); // Maybe add a loading class for visual feedback
|
||
button.disabled = true;
|
||
const icon = button.querySelector("i");
|
||
if (icon) icon.classList.add("fa-spin"); // Add spin animation
|
||
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
// No need to remove loading/spin as page reloads
|
||
}, 300);
|
||
}
|
||
|
||
// 展开/收起区块内容的函数,带有平滑动画效果。
|
||
// @param {HTMLElement} header - 被点击的区块头部元素。
|
||
// @param {string} sectionId - (当前未使用,但可用于更精确的目标定位) 关联内容区块的ID。
|
||
function toggleSection(header, sectionId) {
|
||
const toggleIcon = header.querySelector(".toggle-icon");
|
||
// 内容元素是卡片内的 .key-content div
|
||
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"); // 移除 collapsed 类以应用展开的样式
|
||
|
||
// 步骤 1: 重置内联样式,让CSS控制初始的"隐藏"状态 (通常是 maxHeight: 0, opacity: 0)。
|
||
// 同时,确保 overflow 在动画开始前是 hidden。
|
||
content.style.maxHeight = ""; // 清除可能存在的内联 maxHeight
|
||
content.style.opacity = ""; // 清除可能存在的内联 opacity
|
||
content.style.paddingTop = ""; // 清除内联 padding
|
||
content.style.paddingBottom = "";
|
||
content.style.overflow = "hidden"; // 动画过程中隐藏溢出内容
|
||
|
||
// 步骤 2: 使用 requestAnimationFrame (rAF) 确保浏览器在计算 scrollHeight 之前
|
||
// 已经应用了上一步的样式重置(特别是如果CSS中有过渡效果)。
|
||
requestAnimationFrame(() => {
|
||
// 步骤 3: 计算内容区的目标高度。
|
||
// 这包括内容本身的 scrollHeight,以及任何可见的批量操作栏和分页控件的高度。
|
||
let targetHeight = content.scrollHeight;
|
||
|
||
if (batchActions && !batchActions.classList.contains("hidden")) {
|
||
targetHeight += batchActions.offsetHeight;
|
||
}
|
||
if (pagination && pagination.offsetHeight > 0) {
|
||
// 尝试获取分页控件的 margin-top,以获得更精确的高度
|
||
const paginationStyle = getComputedStyle(pagination);
|
||
const paginationMarginTop = parseFloat(paginationStyle.marginTop) || 0;
|
||
targetHeight += pagination.offsetHeight + paginationMarginTop;
|
||
}
|
||
|
||
// 步骤 4: 设置 maxHeight 和 opacity 以触发CSS过渡到展开状态。
|
||
content.style.maxHeight = targetHeight + "px";
|
||
content.style.opacity = "1";
|
||
// 假设展开后的 padding 为 1rem (p-4 in Tailwind). 根据实际情况调整。
|
||
content.style.paddingTop = "1rem";
|
||
content.style.paddingBottom = "1rem";
|
||
|
||
// 步骤 5: 监听 transitionend 事件。动画结束后,移除 maxHeight 以允许内容动态调整,
|
||
// 并将 overflow 设置为 visible,以防内容变化后被裁剪。
|
||
content.addEventListener(
|
||
"transitionend",
|
||
function onExpansionEnd() {
|
||
content.removeEventListener("transitionend", onExpansionEnd); // 清理监听器
|
||
// 再次检查确保是在展开状态 (避免在快速连续点击时出错)
|
||
if (!content.classList.contains("collapsed")) {
|
||
content.style.maxHeight = ""; // 允许内容自适应高度
|
||
content.style.overflow = "visible"; // 允许内容溢出(如果需要)
|
||
}
|
||
},
|
||
{ once: true }
|
||
); // 确保监听器只执行一次
|
||
});
|
||
} else {
|
||
// --- 准备收起动画 ---
|
||
// 步骤 1: 获取当前内容区的可见高度。
|
||
// 这对于从当前渲染高度平滑过渡到0是必要的。
|
||
let currentVisibleHeight = content.scrollHeight; // scrollHeight 应该已经是包括padding的内部高度
|
||
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;
|
||
}
|
||
|
||
// 步骤 2: 将 maxHeight 设置为当前计算的可见高度,以确保过渡从当前高度开始。
|
||
// 同时,确保 overflow 在动画开始前是 hidden。
|
||
content.style.maxHeight = currentVisibleHeight + "px";
|
||
content.style.overflow = "hidden";
|
||
|
||
// 步骤 3: 使用 requestAnimationFrame (rAF) 确保浏览器应用了上述 maxHeight。
|
||
requestAnimationFrame(() => {
|
||
// 步骤 4: 过渡到目标状态 (收起): maxHeight 和 padding 设为0,opacity 设为0。
|
||
content.style.maxHeight = "0px";
|
||
content.style.opacity = "0";
|
||
content.style.paddingTop = "0";
|
||
content.style.paddingBottom = "0";
|
||
// 在动画开始(或即将开始)后添加 collapsed 类,以便CSS可以应用最终的折叠样式。
|
||
content.classList.add("collapsed");
|
||
});
|
||
}
|
||
}
|
||
|
||
// filterValidKeys 函数已被 filterAndSearchValidKeys 替代,此函数保留为空或可移除
|
||
function filterValidKeys() {
|
||
// This function is now handled by filterAndSearchValidKeys
|
||
// Kept for now to avoid breaking any potential legacy calls, but should be removed later.
|
||
filterAndSearchValidKeys();
|
||
}
|
||
|
||
// --- Initialization Helper Functions ---
|
||
function initializePageAnimationsAndEffects() {
|
||
initStatItemAnimations(); // Already an external function
|
||
|
||
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");
|
||
};
|
||
|
||
// executeVerifyAll 变为 initializeGlobalBatchVerificationHandlers 的局部函数
|
||
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);
|
||
// Mark all keys in this batch as failed due to API error
|
||
batch.forEach(key => {
|
||
allFailedKeys[key] = apiError.message;
|
||
});
|
||
}
|
||
processedCount += batch.length;
|
||
updateProgress(processedCount, keysToVerify.length, progressText);
|
||
}
|
||
|
||
updateProgress(
|
||
keysToVerify.length,
|
||
keysToVerify.length,
|
||
`所有批次验证完成!`
|
||
);
|
||
|
||
// Close progress modal and show final results
|
||
closeProgressModal(false); // Don't reload yet
|
||
showVerificationResultModal({
|
||
successful_keys: allSuccessfulKeys,
|
||
failed_keys: allFailedKeys,
|
||
valid_count: allSuccessfulKeys.length,
|
||
invalid_count: Object.keys(allFailedKeys).length
|
||
});
|
||
}
|
||
// The confirmButton.onclick in showVerifyModal (defined earlier in initializeGlobalBatchVerificationHandlers)
|
||
// will correctly reference this local executeVerifyAll due to closure.
|
||
}
|
||
|
||
// --- 进度条模态框函数 ---
|
||
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; // Auto-scroll to bottom
|
||
}
|
||
|
||
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;
|
||
|
||
// Event delegation for clicks on list items to toggle checkbox
|
||
listElement.addEventListener("click", (event) => {
|
||
const listItem = event.target.closest("li[data-key]");
|
||
if (!listItem) return;
|
||
|
||
// Do not toggle if a button, a link, or any element explicitly designed for interaction within the li was clicked
|
||
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 }));
|
||
}
|
||
});
|
||
|
||
// Event delegation for 'change' event on checkboxes within the list
|
||
listElement.addEventListener("change", (event) => {
|
||
if (event.target.classList.contains("key-checkbox")) {
|
||
const checkbox = event.target; // This is the checkbox in the DOM
|
||
const listItem = checkbox.closest("li[data-key]"); // This is the LI in the DOM
|
||
|
||
if (listItem) {
|
||
listItem.classList.toggle("selected", checkbox.checked);
|
||
|
||
// Sync with master array
|
||
const key = listItem.dataset.key;
|
||
const masterList =
|
||
keyType === "valid" ? allValidKeys : allInvalidKeys;
|
||
if (masterList) {
|
||
// Ensure masterList is defined
|
||
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");
|
||
}
|
||
|
||
function initializeAutoRefreshControls() {
|
||
const autoRefreshToggle = document.getElementById("autoRefreshToggle");
|
||
const autoRefreshIntervalTime = 60000; // 60秒
|
||
let autoRefreshTimer = null;
|
||
|
||
function startAutoRefresh() {
|
||
if (autoRefreshTimer) return;
|
||
console.log("启动自动刷新...");
|
||
showNotification("自动刷新已启动", "info", 2000);
|
||
autoRefreshTimer = setInterval(() => {
|
||
console.log("自动刷新 keys_status 页面...");
|
||
location.reload();
|
||
}, autoRefreshIntervalTime);
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshTimer) {
|
||
console.log("停止自动刷新...");
|
||
showNotification("自动刷新已停止", "info", 2000);
|
||
clearInterval(autoRefreshTimer);
|
||
autoRefreshTimer = null;
|
||
}
|
||
}
|
||
|
||
if (autoRefreshToggle) {
|
||
const isAutoRefreshEnabled =
|
||
localStorage.getItem("autoRefreshEnabled") === "true";
|
||
autoRefreshToggle.checked = isAutoRefreshEnabled;
|
||
if (isAutoRefreshEnabled) {
|
||
startAutoRefresh();
|
||
}
|
||
autoRefreshToggle.addEventListener("change", () => {
|
||
if (autoRefreshToggle.checked) {
|
||
localStorage.setItem("autoRefreshEnabled", "true");
|
||
startAutoRefresh();
|
||
} else {
|
||
localStorage.setItem("autoRefreshEnabled", "false");
|
||
stopAutoRefresh();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Debounce function
|
||
function debounce(func, delay) {
|
||
let timeout;
|
||
return function(...args) {
|
||
const context = this;
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(() => func.apply(context, args), delay);
|
||
};
|
||
}
|
||
|
||
|
||
// --- Key List Display & Pagination ---
|
||
|
||
/**
|
||
* Fetches and displays keys.
|
||
* @param {string} type 'valid' or 'invalid'
|
||
* @param {number} page Page number (1-based)
|
||
*/
|
||
async function fetchAndDisplayKeys(type, page = 1) {
|
||
const listElement = document.getElementById(`${type}Keys`);
|
||
const paginationControls = document.getElementById(`${type}PaginationControls`);
|
||
if (!listElement || !paginationControls) return;
|
||
|
||
// Show loading indicator
|
||
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 = ""; // Clear loading indicator
|
||
|
||
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>`;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Creates a single key list item element.
|
||
* @param {string} key The API key.
|
||
* @param {number} fail_count The failure count for the key.
|
||
* @param {string} type 'valid' or 'invalid'.
|
||
* @returns {HTMLElement} The created list item element.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
|
||
/**
|
||
* Sets up pagination controls.
|
||
* @param {string} type 'valid' or 'invalid'
|
||
* @param {number} currentPage Current page number
|
||
* @param {number} totalPages Total number of pages
|
||
*/
|
||
function setupPaginationControls(type, currentPage, totalPages) {
|
||
const controlsContainer = document.getElementById(`${type}PaginationControls`);
|
||
if (!controlsContainer) return;
|
||
|
||
controlsContainer.innerHTML = "";
|
||
|
||
if (totalPages <= 1) return;
|
||
|
||
// Previous Button
|
||
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);
|
||
|
||
// Page Number Buttons
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
// Simple pagination for now, can be improved with ellipsis for many pages
|
||
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);
|
||
}
|
||
|
||
// Next Button
|
||
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; // Default
|
||
let validCurrentPage = 1; // Also used by displayPage
|
||
let invalidCurrentPage = 1; // Also used by displayPage
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
// Initial fetch
|
||
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();
|
||
});
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initializePageAnimationsAndEffects();
|
||
initializeSectionToggleListeners();
|
||
initializeKeyFilterControls();
|
||
initializeGlobalBatchVerificationHandlers();
|
||
initializeKeySelectionListeners();
|
||
initializeAutoRefreshControls();
|
||
initializeKeyPaginationAndSearch(); // This will also handle initial display
|
||
registerServiceWorker();
|
||
initializeDropdownMenu(); // 初始化下拉菜单
|
||
|
||
// Initial batch actions update might be needed if not covered by displayPage
|
||
// updateBatchActions('valid');
|
||
// updateBatchActions('invalid');
|
||
});
|
||
|
||
// --- 新增:删除密钥相关功能 ---
|
||
|
||
// 新版:显示单个密钥删除确认模态框
|
||
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>此操作无法撤销。`;
|
||
|
||
// 移除旧的监听器并重新附加,以确保 key 和 button 参数是最新的
|
||
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) {
|
||
// 使用 resultModal 并确保刷新
|
||
showResultModal(true, response.message || "密钥删除成功", true);
|
||
} else {
|
||
// 使用 resultModal,失败时不刷新,以便用户看到错误信息
|
||
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;
|
||
}
|
||
|
||
// 找到批量删除按钮并显示加载状态 (假设它在对应类型的 batchActions 中是最后一个按钮)
|
||
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) {
|
||
// 使用 resultModal 显示更详细的结果
|
||
const message =
|
||
response.message ||
|
||
`成功删除 ${response.deleted_count || selectedKeys.length} 个密钥。`;
|
||
showResultModal(true, message, true); // true 表示成功,message,true 表示关闭后刷新
|
||
} else {
|
||
showResultModal(false, response.message || "批量删除密钥失败", true); // false 表示失败,message,true 表示关闭后刷新
|
||
}
|
||
} catch (error) {
|
||
console.error("批量删除 API 请求失败:", error);
|
||
showResultModal(false, `批量删除请求失败: ${error.message}`, true);
|
||
} finally {
|
||
// resultModal 关闭时会刷新页面,所以通常不需要在这里恢复按钮状态。
|
||
// 如果不刷新,则需要恢复按钮状态:
|
||
// if (deleteButton && (!document.getElementById("resultModal") || document.getElementById("resultModal").classList.contains("hidden") || document.getElementById("resultModalTitle").textContent.includes("失败"))) {
|
||
// deleteButton.innerHTML = originalDeleteBtnHtml;
|
||
// // 按钮的 disabled 状态会在 updateBatchActions 中处理,或者因页面刷新而重置
|
||
// }
|
||
}
|
||
}
|
||
|
||
// --- 结束:删除密钥相关功能 ---
|
||
|
||
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 = "显示密钥";
|
||
}
|
||
}
|
||
|
||
// --- API 调用详情模态框逻辑 ---
|
||
|
||
// 显示 API 调用详情模态框
|
||
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
|
||
); // Show empty state if no data
|
||
}
|
||
} 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>`;
|
||
}
|
||
}
|
||
|
||
// 关闭 API 调用详情模态框
|
||
function closeApiCallDetailsModal() {
|
||
const modal = document.getElementById("apiCallDetailsModal");
|
||
if (modal) {
|
||
modal.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
// 渲染 API 调用详情到模态框
|
||
function renderApiCallDetails(
|
||
data,
|
||
container,
|
||
totalCalls,
|
||
successCalls,
|
||
failureCalls
|
||
) {
|
||
let summaryHtml = "";
|
||
// 只有在提供了这些统计数据时才显示概览
|
||
if (
|
||
totalCalls !== undefined &&
|
||
successCalls !== undefined &&
|
||
failureCalls !== undefined
|
||
) {
|
||
summaryHtml = `
|
||
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg">
|
||
<h4 class="font-semibold text-gray-700 dark:text-gray-200 mb-2 text-md border-b pb-1.5 dark:border-gray-600">期间调用概览:</h4>
|
||
<div class="grid grid-cols-3 gap-2 text-center">
|
||
<div>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p>
|
||
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p>
|
||
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p>
|
||
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
</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";
|
||
|
||
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 ${statusClass}">
|
||
<i class="fas ${statusIcon} mr-1"></i>
|
||
${call.status}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
tableHtml += `
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
container.innerHTML = summaryHtml + tableHtml; // Prepend summary
|
||
}
|
||
|
||
// --- 密钥使用详情模态框逻辑 ---
|
||
|
||
// 显示密钥使用详情模态框
|
||
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;
|
||
}
|
||
|
||
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
|
||
function renderKeyUsageDetails(data, container) {
|
||
if (!data || Object.keys(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">该密钥在最近24小时内没有调用记录。</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
let tableHtml = `
|
||
<table class="min-w-full divide-y divide-gray-200">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||
const sortedModels = Object.entries(data).sort(
|
||
([, countA], [, countB]) => countB - countA
|
||
);
|
||
sortedModels.forEach(([model, count]) => {
|
||
tableHtml += `
|
||
<tr>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
||
</tr>`;
|
||
});
|
||
tableHtml += `
|
||
</tbody>
|
||
</table>`;
|
||
container.innerHTML = tableHtml;
|
||
}
|
||
|
||
// 设置标题
|
||
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
||
|
||
// 显示模态框并设置加载状态
|
||
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/key-usage-details/${key}`);
|
||
if (data) {
|
||
renderKeyUsageDetails(data, contentArea);
|
||
} else {
|
||
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
|
||
}
|
||
} catch (apiError) {
|
||
console.error("获取密钥使用详情失败:", 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>`;
|
||
}
|
||
};
|
||
|
||
// 关闭密钥使用详情模态框
|
||
window.closeKeyUsageDetailsModal = function () {
|
||
const modal = document.getElementById("keyUsageDetailsModal");
|
||
if (modal) {
|
||
modal.classList.add("hidden");
|
||
}
|
||
};
|
||
|
||
// window.renderKeyUsageDetails 函数已被移入 showKeyUsageDetails 内部, 此处残留代码已删除。
|
||
|
||
// --- Key List Display & Pagination ---
|
||
|
||
/**
|
||
* Displays key list items for a specific type and page.
|
||
* @param {string} type 'valid' or 'invalid'
|
||
* @param {number} page Page number (1-based)
|
||
* @param {Array} keyItemsArray The array of li elements to paginate (e.g., filteredValidKeys, allInvalidKeys)
|
||
*/
|
||
function displayPage(type, page, keyItemsArray) {
|
||
const listElement = document.getElementById(`${type}Keys`);
|
||
const paginationControls = document.getElementById(
|
||
`${type}PaginationControls`
|
||
);
|
||
if (!listElement || !paginationControls) return;
|
||
|
||
// This function is now mostly handled by fetchAndDisplayKeys.
|
||
// We can simplify this or remove it if all display logic is in fetchAndDisplayKeys.
|
||
// For now, let's keep it for rendering the pagination controls as a separate step.
|
||
setupPaginationControls(type, page, totalPages);
|
||
updateBatchActions(type); // Update batch actions based on the currently displayed page
|
||
}
|
||
|
||
/**
|
||
* Sets up pagination controls.
|
||
* @param {string} type 'valid' or 'invalid'
|
||
* @param {number} currentPage Current page number
|
||
* @param {number} totalPages Total number of pages
|
||
* @param {Array} keyItemsArray The array of li elements being paginated
|
||
*/
|
||
function setupPaginationControls(type, currentPage, totalPages) {
|
||
const controlsContainer = document.getElementById(
|
||
`${type}PaginationControls`
|
||
);
|
||
if (!controlsContainer) return;
|
||
|
||
controlsContainer.innerHTML = "";
|
||
|
||
if (totalPages <= 1) {
|
||
return; // No controls needed for single/no page
|
||
}
|
||
|
||
// Base classes for all buttons (Tailwind for layout, custom for consistent styling)
|
||
const baseButtonClasses =
|
||
"pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out";
|
||
// Define hover classes that work with the custom background by adjusting opacity or a border effect.
|
||
// Since .pagination-button defines a background, a hover effect might be a subtle border change or brightness.
|
||
// For simplicity, we can rely on CSS for hover effects on .pagination-button:hover
|
||
// const hoverClasses = "hover:border-purple-400"; // Example if you want JS to add specific hover behavior
|
||
|
||
// Previous Button
|
||
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);
|
||
|
||
// Page Number Buttons (Logic for ellipsis)
|
||
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);
|
||
}
|
||
|
||
// First Page Button & Ellipsis
|
||
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"; // Adjusted color for dark theme
|
||
controlsContainer.appendChild(ellipsis);
|
||
}
|
||
}
|
||
|
||
// Middle Page Buttons
|
||
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);
|
||
}
|
||
|
||
// Ellipsis & Last Page Button
|
||
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"; // Adjusted color
|
||
controlsContainer.appendChild(ellipsis);
|
||
}
|
||
const lastPageButton = document.createElement("button");
|
||
lastPageButton.textContent = totalPages;
|
||
lastPageButton.className = `${baseButtonClasses}`;
|
||
lastPageButton.onclick = () => fetchAndDisplayKeys(type, totalPages);
|
||
controlsContainer.appendChild(lastPageButton);
|
||
}
|
||
|
||
// Next Button
|
||
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);
|
||
}
|
||
|
||
// --- Filtering & Searching (Valid Keys Only) ---
|
||
|
||
/**
|
||
* Filters and searches the valid keys based on threshold and search term.
|
||
* Updates the `filteredValidKeys` array and redisplays the first page.
|
||
*/
|
||
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
|
||
});
|
||
} |