mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-08 01:06:37 +08:00
refactor: 重构密钥状态页面为客户端动态加载
- 新增 key_routes.py 分离密钥相关路由逻辑 - 将密钥列表从服务器端渲染改为 JavaScript 动态加载 - 优化 keys_status 页面错误处理,提供默认数据结构 - 在 KeyManager 中添加 get_all_keys_with_fail_count 方法 - 移除服务器端模板中的静态密钥渲染代码 这次重构提升了页面加载性能和用户体验,同时改善了错误处理机制。
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
+23
-4
@@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_routes_logger
|
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.key.key_manager import get_key_manager_instance
|
||||||
from app.service.stats.stats_service import StatsService
|
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(openai_compatiable_routes.router)
|
||||||
app.include_router(vertex_express_routes.router)
|
app.include_router(vertex_express_routes.router)
|
||||||
app.include_router(files_routes.router)
|
app.include_router(files_routes.router)
|
||||||
|
app.include_router(key_routes.router)
|
||||||
|
|
||||||
setup_page_routes(app)
|
setup_page_routes(app)
|
||||||
|
|
||||||
@@ -103,8 +104,8 @@ def setup_page_routes(app: FastAPI) -> None:
|
|||||||
"keys_status.html",
|
"keys_status.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"valid_keys": keys_status["valid_keys"],
|
"valid_keys": {},
|
||||||
"invalid_keys": keys_status["invalid_keys"],
|
"invalid_keys": {},
|
||||||
"total_keys": total_keys,
|
"total_keys": total_keys,
|
||||||
"valid_key_count": valid_key_count,
|
"valid_key_count": valid_key_count,
|
||||||
"invalid_key_count": invalid_key_count,
|
"invalid_key_count": invalid_key_count,
|
||||||
@@ -113,7 +114,25 @@ def setup_page_routes(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving keys status or API stats: {str(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)
|
@app.get("/config", response_class=HTMLResponse)
|
||||||
async def config_page(request: Request):
|
async def config_page(request: Request):
|
||||||
|
|||||||
@@ -141,6 +141,18 @@ class KeyManager:
|
|||||||
"""获取指定 Vertex 密钥的失败次数"""
|
"""获取指定 Vertex 密钥的失败次数"""
|
||||||
return self.vertex_key_failure_counts.get(key, 0)
|
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:
|
async def get_keys_by_status(self) -> dict:
|
||||||
"""获取分类后的API key列表,包括失败次数"""
|
"""获取分类后的API key列表,包括失败次数"""
|
||||||
valid_keys = {}
|
valid_keys = {}
|
||||||
|
|||||||
+199
-150
@@ -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 allValidKeys = [];
|
||||||
let allInvalidKeys = [];
|
let allInvalidKeys = [];
|
||||||
let filteredValidKeys = [];
|
let filteredValidKeys = [];
|
||||||
let itemsPerPage = 10; // Default
|
let itemsPerPage = 10; // Default
|
||||||
let validCurrentPage = 1; // Also used by displayPage
|
let validCurrentPage = 1; // Also used by displayPage
|
||||||
let invalidCurrentPage = 1; // Also used by displayPage
|
let invalidCurrentPage = 1; // Also used by displayPage
|
||||||
|
|
||||||
function initializeKeyPaginationAndSearch() {
|
function initializeKeyPaginationAndSearch() {
|
||||||
const validKeysListElement = document.getElementById("validKeys");
|
const debouncedFetchValidKeys = debounce(() => fetchAndDisplayKeys('valid', 1), 300);
|
||||||
const invalidKeysListElement = document.getElementById("invalidKeys");
|
const debouncedFetchInvalidKeys = debounce(() => fetchAndDisplayKeys('invalid', 1), 300);
|
||||||
const searchInput = document.getElementById("keySearchInput");
|
|
||||||
const itemsPerPageSelect = document.getElementById("itemsPerPageSelect");
|
|
||||||
const thresholdInput = document.getElementById("failCountThreshold"); // Already used by initializeKeyFilterControls
|
|
||||||
|
|
||||||
if (validKeysListElement) {
|
const searchInput = document.getElementById("keySearchInput");
|
||||||
allValidKeys = Array.from(
|
if (searchInput) {
|
||||||
validKeysListElement.querySelectorAll("li[data-key]")
|
searchInput.addEventListener("input", debouncedFetchValidKeys);
|
||||||
);
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsPerPageSelect) {
|
const thresholdInput = document.getElementById("failCountThreshold");
|
||||||
itemsPerPage = parseInt(itemsPerPageSelect.value, 10); // Initialize itemsPerPage
|
if (thresholdInput) {
|
||||||
itemsPerPageSelect.addEventListener("change", () => {
|
thresholdInput.addEventListener("input", debouncedFetchValidKeys);
|
||||||
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 itemsPerPageSelect = document.getElementById("itemsPerPageSelect");
|
||||||
});
|
if (itemsPerPageSelect) {
|
||||||
}
|
itemsPerPageSelect.addEventListener("change", () => {
|
||||||
|
fetchAndDisplayKeys('valid', 1);
|
||||||
|
fetchAndDisplayKeys('invalid', 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initial display calls
|
// Initial fetch
|
||||||
filterAndSearchValidKeys();
|
fetchAndDisplayKeys('valid');
|
||||||
displayPage("invalid", 1, allInvalidKeys);
|
fetchAndDisplayKeys('invalid');
|
||||||
|
|
||||||
// Event listeners for search and filter (thresholdInput listener is in initializeKeyFilterControls)
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener("input", filterAndSearchValidKeys);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerServiceWorker() {
|
function registerServiceWorker() {
|
||||||
@@ -1649,77 +1785,11 @@ function displayPage(type, page, keyItemsArray) {
|
|||||||
);
|
);
|
||||||
if (!listElement || !paginationControls) return;
|
if (!listElement || !paginationControls) return;
|
||||||
|
|
||||||
// Update current page based on type
|
// This function is now mostly handled by fetchAndDisplayKeys.
|
||||||
if (type === "valid") {
|
// We can simplify this or remove it if all display logic is in fetchAndDisplayKeys.
|
||||||
validCurrentPage = page;
|
// For now, let's keep it for rendering the pagination controls as a separate step.
|
||||||
// Read itemsPerPage from the select specifically for valid keys
|
setupPaginationControls(type, page, totalPages);
|
||||||
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);
|
|
||||||
updateBatchActions(type); // Update batch actions based on the currently displayed page
|
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 {number} totalPages Total number of pages
|
||||||
* @param {Array} keyItemsArray The array of li elements being paginated
|
* @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(
|
const controlsContainer = document.getElementById(
|
||||||
`${type}PaginationControls`
|
`${type}PaginationControls`
|
||||||
);
|
);
|
||||||
@@ -1754,7 +1824,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
|
|||||||
prevButton.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
prevButton.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||||||
prevButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`;
|
prevButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`;
|
||||||
prevButton.disabled = currentPage === 1;
|
prevButton.disabled = currentPage === 1;
|
||||||
prevButton.onclick = () => displayPage(type, currentPage - 1, keyItemsArray);
|
prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1);
|
||||||
controlsContainer.appendChild(prevButton);
|
controlsContainer.appendChild(prevButton);
|
||||||
|
|
||||||
// Page Number Buttons (Logic for ellipsis)
|
// Page Number Buttons (Logic for ellipsis)
|
||||||
@@ -1771,7 +1841,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
|
|||||||
const firstPageButton = document.createElement("button");
|
const firstPageButton = document.createElement("button");
|
||||||
firstPageButton.textContent = "1";
|
firstPageButton.textContent = "1";
|
||||||
firstPageButton.className = `${baseButtonClasses}`;
|
firstPageButton.className = `${baseButtonClasses}`;
|
||||||
firstPageButton.onclick = () => displayPage(type, 1, keyItemsArray);
|
firstPageButton.onclick = () => fetchAndDisplayKeys(type, 1);
|
||||||
controlsContainer.appendChild(firstPageButton);
|
controlsContainer.appendChild(firstPageButton);
|
||||||
if (startPage > 2) {
|
if (startPage > 2) {
|
||||||
const ellipsis = document.createElement("span");
|
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
|
? "active font-semibold" // Relies on .pagination-button.active CSS for styling
|
||||||
: "" // Non-active buttons just use .pagination-button style
|
: "" // Non-active buttons just use .pagination-button style
|
||||||
}`;
|
}`;
|
||||||
pageButton.onclick = () => displayPage(type, i, keyItemsArray);
|
pageButton.onclick = () => fetchAndDisplayKeys(type, i);
|
||||||
controlsContainer.appendChild(pageButton);
|
controlsContainer.appendChild(pageButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1805,7 +1875,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
|
|||||||
const lastPageButton = document.createElement("button");
|
const lastPageButton = document.createElement("button");
|
||||||
lastPageButton.textContent = totalPages;
|
lastPageButton.textContent = totalPages;
|
||||||
lastPageButton.className = `${baseButtonClasses}`;
|
lastPageButton.className = `${baseButtonClasses}`;
|
||||||
lastPageButton.onclick = () => displayPage(type, totalPages, keyItemsArray);
|
lastPageButton.onclick = () => fetchAndDisplayKeys(type, totalPages);
|
||||||
controlsContainer.appendChild(lastPageButton);
|
controlsContainer.appendChild(lastPageButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1814,7 +1884,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
|
|||||||
nextButton.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
nextButton.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||||
nextButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`;
|
nextButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`;
|
||||||
nextButton.disabled = currentPage === totalPages;
|
nextButton.disabled = currentPage === totalPages;
|
||||||
nextButton.onclick = () => displayPage(type, currentPage + 1, keyItemsArray);
|
nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1);
|
||||||
controlsContainer.appendChild(nextButton);
|
controlsContainer.appendChild(nextButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1825,26 +1895,5 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
|
|||||||
* Updates the `filteredValidKeys` array and redisplays the first page.
|
* Updates the `filteredValidKeys` array and redisplays the first page.
|
||||||
*/
|
*/
|
||||||
function filterAndSearchValidKeys() {
|
function filterAndSearchValidKeys() {
|
||||||
const thresholdInput = document.getElementById("failCountThreshold");
|
fetchAndDisplayKeys('valid', 1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1319,95 +1319,10 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||||
<!-- Key list will be populated by JS -->
|
<!-- Key list will be populated by JS -->
|
||||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<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
|
{# This content is now loaded via JavaScript #}
|
||||||
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 %}
|
|
||||||
<li class="text-center text-gray-500 py-4 col-span-full">
|
<li class="text-center text-gray-500 py-4 col-span-full">
|
||||||
暂无有效密钥
|
<i class="fas fa-spinner fa-spin"></i> Loading keys...
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
<!-- 有效密钥分页控件容器 -->
|
<!-- 有效密钥分页控件容器 -->
|
||||||
<div
|
<div
|
||||||
@@ -1503,93 +1418,10 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||||
<!-- Key list will be populated by JS -->
|
<!-- Key list will be populated by JS -->
|
||||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{# Initial keys rendered by server-side #} {# JS will replace this
|
{# This content is now loaded via JavaScript #}
|
||||||
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 %}
|
|
||||||
<li class="text-center text-gray-500 py-4 col-span-full">
|
<li class="text-center text-gray-500 py-4 col-span-full">
|
||||||
暂无无效密钥
|
<i class="fas fa-spinner fa-spin"></i> Loading keys...
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
<!-- 无效密钥分页控件容器 -->
|
<!-- 无效密钥分页控件容器 -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user