refactor: 重构密钥状态页面为客户端动态加载

- 新增 key_routes.py 分离密钥相关路由逻辑
- 将密钥列表从服务器端渲染改为 JavaScript 动态加载
- 优化 keys_status 页面错误处理,提供默认数据结构
- 在 KeyManager 中添加 get_all_keys_with_fail_count 方法
- 移除服务器端模板中的静态密钥渲染代码

这次重构提升了页面加载性能和用户体验,同时改善了错误处理机制。
This commit is contained in:
snaily
2025-07-24 23:23:11 +08:00
parent ccd4722a77
commit 2270f6d998
5 changed files with 301 additions and 326 deletions

63
app/router/key_routes.py Normal file
View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, Request
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.core.security import verify_auth_token
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/keys")
async def get_keys_paginated(
request: Request,
page: int = 1,
limit: int = 10,
search: str = None,
fail_count_threshold: int = None,
status: str = "all", # 'valid', 'invalid', 'all'
key_manager: KeyManager = Depends(get_key_manager_instance),
):
"""
Get paginated, filtered, and searched keys.
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
all_keys_with_status = await key_manager.get_all_keys_with_fail_count()
# Filter by status
if status == "valid":
keys_to_filter = all_keys_with_status["valid_keys"]
elif status == "invalid":
keys_to_filter = all_keys_with_status["invalid_keys"]
else:
# Combine both for 'all' status, which might be useful for a unified view if ever needed
keys_to_filter = {**all_keys_with_status["valid_keys"], **all_keys_with_status["invalid_keys"]}
# Further filtering (search and fail_count_threshold)
filtered_keys = {}
for key, fail_count in keys_to_filter.items():
search_match = True
if search:
search_match = search.lower() in key.lower()
fail_count_match = True
if fail_count_threshold is not None:
fail_count_match = fail_count >= fail_count_threshold
if search_match and fail_count_match:
filtered_keys[key] = fail_count
# Pagination
keys_list = list(filtered_keys.items())
total_items = len(keys_list)
start_index = (page - 1) * limit
end_index = start_index + limit
paginated_keys = dict(keys_list[start_index:end_index])
return {
"keys": paginated_keys,
"total_items": total_items,
"total_pages": (total_items + limit - 1) // limit,
"current_page": page,
}

View File

@@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token
from app.config.config import settings
from app.log.logger import get_routes_logger
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes, key_routes
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats.stats_service import StatsService
@@ -36,6 +36,7 @@ def setup_routers(app: FastAPI) -> None:
app.include_router(openai_compatiable_routes.router)
app.include_router(vertex_express_routes.router)
app.include_router(files_routes.router)
app.include_router(key_routes.router)
setup_page_routes(app)
@@ -103,8 +104,8 @@ def setup_page_routes(app: FastAPI) -> None:
"keys_status.html",
{
"request": request,
"valid_keys": keys_status["valid_keys"],
"invalid_keys": keys_status["invalid_keys"],
"valid_keys": {},
"invalid_keys": {},
"total_keys": total_keys,
"valid_key_count": valid_key_count,
"invalid_key_count": invalid_key_count,
@@ -113,7 +114,25 @@ def setup_page_routes(app: FastAPI) -> None:
)
except Exception as e:
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
raise
# Even if there's an error, render the page with whatever data is available
# or with empty/default values, so the frontend can still load.
return templates.TemplateResponse(
"keys_status.html",
{
"request": request,
"valid_keys": {},
"invalid_keys": {},
"total_keys": 0,
"valid_key_count": 0,
"invalid_key_count": 0,
"api_stats": { # Provide a default structure for api_stats
"calls_1m": {"total": 0, "success": 0, "failure": 0},
"calls_1h": {"total": 0, "success": 0, "failure": 0},
"calls_24h": {"total": 0, "success": 0, "failure": 0},
"calls_month": {"total": 0, "success": 0, "failure": 0},
},
},
)
@app.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):

View File

@@ -141,6 +141,18 @@ class KeyManager:
"""获取指定 Vertex 密钥的失败次数"""
return self.vertex_key_failure_counts.get(key, 0)
async def get_all_keys_with_fail_count(self) -> dict:
"""获取所有API key及其失败次数"""
all_keys = {}
async with self.failure_count_lock:
for key in self.api_keys:
all_keys[key] = self.key_failure_counts.get(key, 0)
valid_keys = {k: v for k, v in all_keys.items() if v < self.MAX_FAILURES}
invalid_keys = {k: v for k, v in all_keys.items() if v >= self.MAX_FAILURES}
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys, "all_keys": all_keys}
async def get_keys_by_status(self) -> dict:
"""获取分类后的API key列表包括失败次数"""
valid_keys = {}

View File

@@ -1100,62 +1100,198 @@ function initializeAutoRefreshControls() {
}
}
// These variables are used by pagination and search, define them in a scope accessible by initializeKeyPaginationAndSearch
// 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("itemsPerPageSelect");
const limit = itemsPerPageSelect ? parseInt(itemsPerPageSelect.value, 10) : 10;
const searchInput = document.getElementById("keySearchInput");
const searchTerm = searchInput ? searchInput.value : '';
const thresholdInput = document.getElementById("failCountThreshold");
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
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 validKeysListElement = document.getElementById("validKeys");
const invalidKeysListElement = document.getElementById("invalidKeys");
const searchInput = document.getElementById("keySearchInput");
const itemsPerPageSelect = document.getElementById("itemsPerPageSelect");
const thresholdInput = document.getElementById("failCountThreshold"); // Already used by initializeKeyFilterControls
const debouncedFetchValidKeys = debounce(() => fetchAndDisplayKeys('valid', 1), 300);
const debouncedFetchInvalidKeys = debounce(() => fetchAndDisplayKeys('invalid', 1), 300);
if (validKeysListElement) {
allValidKeys = Array.from(
validKeysListElement.querySelectorAll("li[data-key]")
);
allValidKeys.forEach((li) => {
const keyTextSpan = li.querySelector(".key-text");
if (keyTextSpan && keyTextSpan.dataset.fullKey) {
li.dataset.key = keyTextSpan.dataset.fullKey;
}
});
filteredValidKeys = [...allValidKeys];
}
if (invalidKeysListElement) {
allInvalidKeys = Array.from(
invalidKeysListElement.querySelectorAll("li[data-key]")
);
allInvalidKeys.forEach((li) => {
const keyTextSpan = li.querySelector(".key-text");
if (keyTextSpan && keyTextSpan.dataset.fullKey) {
li.dataset.key = keyTextSpan.dataset.fullKey;
}
});
}
const searchInput = document.getElementById("keySearchInput");
if (searchInput) {
searchInput.addEventListener("input", debouncedFetchValidKeys);
}
if (itemsPerPageSelect) {
itemsPerPage = parseInt(itemsPerPageSelect.value, 10); // Initialize itemsPerPage
itemsPerPageSelect.addEventListener("change", () => {
itemsPerPage = parseInt(itemsPerPageSelect.value, 10);
filterAndSearchValidKeys(); // Re-filter and display page 1 for valid keys
displayPage("invalid", 1, allInvalidKeys); // Reset invalid keys to page 1
});
}
const thresholdInput = document.getElementById("failCountThreshold");
if (thresholdInput) {
thresholdInput.addEventListener("input", debouncedFetchValidKeys);
}
const itemsPerPageSelect = document.getElementById("itemsPerPageSelect");
if (itemsPerPageSelect) {
itemsPerPageSelect.addEventListener("change", () => {
fetchAndDisplayKeys('valid', 1);
fetchAndDisplayKeys('invalid', 1);
});
}
// Initial display calls
filterAndSearchValidKeys();
displayPage("invalid", 1, allInvalidKeys);
// Event listeners for search and filter (thresholdInput listener is in initializeKeyFilterControls)
if (searchInput) {
searchInput.addEventListener("input", filterAndSearchValidKeys);
}
// Initial fetch
fetchAndDisplayKeys('valid');
fetchAndDisplayKeys('invalid');
}
function registerServiceWorker() {
@@ -1649,77 +1785,11 @@ function displayPage(type, page, keyItemsArray) {
);
if (!listElement || !paginationControls) return;
// Update current page based on type
if (type === "valid") {
validCurrentPage = page;
// Read itemsPerPage from the select specifically for valid keys
const itemsPerPageSelect = document.getElementById("itemsPerPageSelect");
itemsPerPage = itemsPerPageSelect
? parseInt(itemsPerPageSelect.value, 10)
: 10;
} else {
invalidCurrentPage = page;
// For invalid keys, use a fixed itemsPerPage or the same global one
// itemsPerPage = 10; // Or read from a different select if needed
}
const totalItems = keyItemsArray.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
page = Math.max(1, Math.min(page, totalPages || 1)); // Ensure page is valid
// Update current page variable again after validation
if (type === "valid") {
validCurrentPage = page;
} else {
invalidCurrentPage = page;
}
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
listElement.innerHTML = ""; // Clear current list content
const pageItems = keyItemsArray.slice(startIndex, endIndex);
if (pageItems.length > 0) {
pageItems.forEach((originalMasterItem) => {
const listItemClone = originalMasterItem.cloneNode(true);
// The checkbox's 'checked' state is cloned from the master item.
// Now, ensure the 'selected' class on the clone matches this cloned checkbox state.
const checkboxInClone = listItemClone.querySelector(".key-checkbox");
if (checkboxInClone) {
listItemClone.classList.toggle("selected", checkboxInClone.checked);
}
listElement.appendChild(listItemClone);
});
} else if (
totalItems === 0 &&
type === "valid" &&
(document.getElementById("failCountThreshold").value !== "0" ||
document.getElementById("keySearchInput").value !== "")
) {
// Handle empty state after filtering/searching for valid keys
const noMatchMsgId = "no-valid-keys-msg";
let noMatchMsg = listElement.querySelector(`#${noMatchMsgId}`);
if (!noMatchMsg) {
noMatchMsg = document.createElement("li");
noMatchMsg.id = noMatchMsgId;
noMatchMsg.className = "text-center text-gray-500 py-4 col-span-full";
noMatchMsg.textContent = "没有符合条件的有效密钥";
listElement.appendChild(noMatchMsg);
}
noMatchMsg.style.display = "";
} else if (totalItems === 0) {
// Handle empty state for initially empty lists
const emptyMsg = document.createElement("li");
emptyMsg.className = "text-center text-gray-500 py-4 col-span-full";
emptyMsg.textContent = `暂无${type === "valid" ? "有效" : "无效"}密钥`;
listElement.appendChild(emptyMsg);
}
setupPaginationControls(type, page, totalPages, keyItemsArray);
// 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
// Re-attach event listeners for buttons inside the newly added list items if needed (using event delegation is better)
}
/**
@@ -1729,7 +1799,7 @@ function displayPage(type, page, keyItemsArray) {
* @param {number} totalPages Total number of pages
* @param {Array} keyItemsArray The array of li elements being paginated
*/
function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
function setupPaginationControls(type, currentPage, totalPages) {
const controlsContainer = document.getElementById(
`${type}PaginationControls`
);
@@ -1754,7 +1824,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
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 = () => displayPage(type, currentPage - 1, keyItemsArray);
prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1);
controlsContainer.appendChild(prevButton);
// Page Number Buttons (Logic for ellipsis)
@@ -1771,7 +1841,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
const firstPageButton = document.createElement("button");
firstPageButton.textContent = "1";
firstPageButton.className = `${baseButtonClasses}`;
firstPageButton.onclick = () => displayPage(type, 1, keyItemsArray);
firstPageButton.onclick = () => fetchAndDisplayKeys(type, 1);
controlsContainer.appendChild(firstPageButton);
if (startPage > 2) {
const ellipsis = document.createElement("span");
@@ -1790,7 +1860,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
? "active font-semibold" // Relies on .pagination-button.active CSS for styling
: "" // Non-active buttons just use .pagination-button style
}`;
pageButton.onclick = () => displayPage(type, i, keyItemsArray);
pageButton.onclick = () => fetchAndDisplayKeys(type, i);
controlsContainer.appendChild(pageButton);
}
@@ -1805,7 +1875,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
const lastPageButton = document.createElement("button");
lastPageButton.textContent = totalPages;
lastPageButton.className = `${baseButtonClasses}`;
lastPageButton.onclick = () => displayPage(type, totalPages, keyItemsArray);
lastPageButton.onclick = () => fetchAndDisplayKeys(type, totalPages);
controlsContainer.appendChild(lastPageButton);
}
@@ -1814,7 +1884,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
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 = () => displayPage(type, currentPage + 1, keyItemsArray);
nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1);
controlsContainer.appendChild(nextButton);
}
@@ -1825,26 +1895,5 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
* Updates the `filteredValidKeys` array and redisplays the first page.
*/
function filterAndSearchValidKeys() {
const thresholdInput = document.getElementById("failCountThreshold");
const searchInput = document.getElementById("keySearchInput");
const threshold = parseInt(thresholdInput.value, 10);
const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold;
const searchTerm = searchInput.value.trim().toLowerCase();
// Filter from the original full list (allValidKeys)
filteredValidKeys = allValidKeys.filter((item) => {
const failCount = parseInt(item.dataset.failCount, 10);
const fullKey = item.dataset.key || ""; // Use data-key which should hold the full key
const failCountMatch = failCount >= filterThreshold;
const searchMatch =
searchTerm === "" || fullKey.toLowerCase().includes(searchTerm);
return failCountMatch && searchMatch;
});
// Reset to the first page after filtering/searching
validCurrentPage = 1;
displayPage("valid", validCurrentPage, filteredValidKeys);
}
fetchAndDisplayKeys('valid', 1);
}

View File

@@ -1319,95 +1319,10 @@ endblock %} {% block head_extra_styles %}
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side for non-JS users or initial
load #} {# JS will replace this content with paginated/filtered
results #} {% if valid_keys %} {% for key, fail_count in
valid_keys.items() %}
<li
class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1"
data-fail-count="{{ fail_count }}"
data-key="{{ key }}"
>
<!-- Checkbox -->
<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="valid"
value="{{ key }}"
/>
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<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>
<div class="flex items-center gap-1">
<span class="key-text font-mono" data-full-key="{{ key }}"
>{{ key[:4] + '...' + key[-4:] }}</span
>
<button
class="text-gray-500 hover:text-primary-600 transition-colors"
onclick="toggleKeyVisibility(this)"
title="显示/隐藏密钥"
>
<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>
</li>
{% endfor %} {% else %}
{# This content is now loaded via JavaScript #}
<li class="text-center text-gray-500 py-4 col-span-full">
暂无有效密钥
<i class="fas fa-spinner fa-spin"></i> Loading keys...
</li>
{% endif %}
</ul>
<!-- 有效密钥分页控件容器 -->
<div
@@ -1503,93 +1418,10 @@ endblock %} {% block head_extra_styles %}
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side #} {# JS will replace this
content with paginated results #} {% if invalid_keys %} {% for key,
fail_count in invalid_keys.items() %}
<li
class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1"
data-key="{{ key }}"
>
<!-- Checkbox -->
<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="invalid"
value="{{ key }}"
/>
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<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>
<div class="flex items-center gap-1">
<span class="key-text font-mono" data-full-key="{{ key }}"
>{{ key[:4] + '...' + key[-4:] }}</span
>
<button
class="text-gray-500 hover:text-primary-600 transition-colors"
onclick="toggleKeyVisibility(this)"
title="显示/隐藏密钥"
>
<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>
</li>
{% endfor %} {% else %}
{# This content is now loaded via JavaScript #}
<li class="text-center text-gray-500 py-4 col-span-full">
暂无无效密钥
<i class="fas fa-spinner fa-spin"></i> Loading keys...
</li>
{% endif %}
</ul>
<!-- 无效密钥分页控件容器 -->
<div