- 添加API密钥分页显示功能,每页显示20个密钥

- 实现分页控件和搜索功能的集成
- 优化API密钥的数据处理逻辑,从DOM操作改为数组操作
- 修改登录成功后重定向路径从/config改为/keys
- 重构routes.py的import语句,按字母顺序排列
- 改进代码格式和缩进风格
This commit is contained in:
snaily
2025-08-16 17:42:16 +08:00
parent d2906d89a6
commit 380e6426ed
3 changed files with 261 additions and 74 deletions

View File

@@ -6,10 +6,22 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token
from app.config.config import settings
from app.core.security import verify_auth_token
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, key_routes
from app.router import (
config_routes,
error_log_routes,
files_routes,
gemini_routes,
key_routes,
openai_compatiable_routes,
openai_routes,
scheduler_routes,
stats_routes,
version_routes,
vertex_express_routes,
)
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats.stats_service import StatsService
@@ -69,9 +81,12 @@ def setup_page_routes(app: FastAPI) -> None:
if verify_auth_token(auth_token):
logger.info("Successful authentication")
response = RedirectResponse(url="/config", status_code=302)
response = RedirectResponse(url="/keys", status_code=302)
response.set_cookie(
key="auth_token", value=auth_token, httponly=True, max_age=settings.ADMIN_SESSION_EXPIRE
key="auth_token",
value=auth_token,
httponly=True,
max_age=settings.ADMIN_SESSION_EXPIRE,
)
return response
logger.warning("Failed authentication attempt with invalid token")
@@ -91,7 +106,9 @@ def setup_page_routes(app: FastAPI) -> None:
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
total_keys = len(keys_status["valid_keys"]) + len(
keys_status["invalid_keys"]
)
valid_key_count = len(keys_status["valid_keys"])
invalid_key_count = len(keys_status["invalid_keys"])
@@ -133,7 +150,7 @@ def setup_page_routes(app: FastAPI) -> None:
},
},
)
@app.get("/config", response_class=HTMLResponse)
async def config_page(request: Request):
"""配置编辑页面"""
@@ -142,13 +159,15 @@ def setup_page_routes(app: FastAPI) -> None:
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
logger.info("Config page accessed successfully")
return templates.TemplateResponse("config_editor.html", {"request": request})
return templates.TemplateResponse(
"config_editor.html", {"request": request}
)
except Exception as e:
logger.error(f"Error accessing config page: {str(e)}")
raise
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
"""错误日志页面"""
@@ -157,7 +176,7 @@ def setup_page_routes(app: FastAPI) -> None:
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to logs page")
return RedirectResponse(url="/", status_code=302)
logger.info("Logs page accessed successfully")
return templates.TemplateResponse("error_logs.html", {"request": request})
except Exception as e:
@@ -187,6 +206,7 @@ def setup_api_stats_routes(app: FastAPI) -> None:
Args:
app: FastAPI应用程序实例
"""
@app.get("/api/stats/details")
async def api_stats_details(request: Request, period: str):
"""获取指定时间段内的 API 调用详情"""
@@ -201,8 +221,12 @@ def setup_api_stats_routes(app: FastAPI) -> None:
details = await stats_service.get_api_call_details(period)
return details
except ValueError as e:
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
logger.warning(
f"Invalid period requested for API stats details: {period} - {str(e)}"
)
return {"error": str(e)}, 400
except Exception as e:
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
logger.error(
f"Error fetching API stats details for period {period}: {str(e)}"
)
return {"error": "Internal server error"}, 500

View File

@@ -16,6 +16,13 @@ const PROXY_REGEX =
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_\-]{50}/g; // 新增 Vertex Express API Key 正则
const MASKED_VALUE = "••••••••";
// API Keys Pagination Constants
const API_KEYS_PER_PAGE = 20; // 每页显示的API密钥数量
let currentApiKeyPage = 1;
let totalApiKeyPages = 1;
let allApiKeys = []; // 存储所有API密钥数据
let filteredApiKeys = []; // 存储过滤后的API密钥数据
// DOM Elements - Global Scope for frequently accessed elements
const safetySettingsContainer = document.getElementById(
"SAFETY_SETTINGS_container"
@@ -147,6 +154,17 @@ document.addEventListener("DOMContentLoaded", function () {
if (apiKeySearchInput)
apiKeySearchInput.addEventListener("input", handleApiKeySearch);
// API Key Pagination Event Listeners
const apiKeyPrevBtn = document.getElementById("apiKeyPrevBtn");
const apiKeyNextBtn = document.getElementById("apiKeyNextBtn");
if (apiKeyPrevBtn) {
apiKeyPrevBtn.addEventListener("click", prevApiKeyPage);
}
if (apiKeyNextBtn) {
apiKeyNextBtn.addEventListener("click", nextApiKeyPage);
}
// Bulk Delete API Key Modal Elements and Events
const bulkDeleteApiKeyBtn = document.getElementById("bulkDeleteApiKeyBtn");
const closeBulkDeleteModalBtn = document.getElementById(
@@ -924,9 +942,9 @@ function populateForm(config) {
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
}
// 4. Populate other array fields (excluding THINKING_MODELS)
// 4. Populate other array fields (excluding THINKING_MODELS and API_KEYS)
for (const [key, value] of Object.entries(config)) {
if (Array.isArray(value) && key !== "THINKING_MODELS") {
if (Array.isArray(value) && key !== "THINKING_MODELS" && key !== "API_KEYS") {
const container = document.getElementById(`${key}_container`);
if (container) {
value.forEach((itemValue) => {
@@ -940,6 +958,17 @@ function populateForm(config) {
}
}
// 4.1. 特殊处理API_KEYS - 使用分页
if (Array.isArray(config.API_KEYS)) {
allApiKeys = config.API_KEYS.filter(key =>
typeof key === "string" && key.trim() !== ""
);
filteredApiKeys = [...allApiKeys];
currentApiKeyPage = 1;
renderApiKeyPage();
updateApiKeyPagination();
}
// 5. Populate non-array/non-budget fields
for (const [key, value] of Object.entries(config)) {
if (
@@ -1062,44 +1091,31 @@ function populateForm(config) {
* Handles the bulk addition of API keys from the modal input.
*/
function handleBulkAddApiKeys() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return;
if (!apiKeyBulkInput || !apiKeyModal) return;
const bulkText = apiKeyBulkInput.value;
const extractedKeys = bulkText.match(API_KEY_REGEX) || [];
const currentKeyInputs = apiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
let currentKeys = Array.from(currentKeyInputs)
.map((input) => {
return input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value;
})
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
// 合并现有密钥和新密钥,去重
const combinedKeys = new Set([...allApiKeys, ...extractedKeys]);
const uniqueKeys = Array.from(combinedKeys);
apiKeyContainer.innerHTML = ""; // Clear existing items more directly
// 更新全局密钥数组
allApiKeys = uniqueKeys;
// 更新过滤后的数组
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
}
uniqueKeys.forEach((key) => {
addArrayItemWithValue("API_KEYS", key);
});
const newKeyInputs = apiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
newKeyInputs.forEach((input) => {
if (configForm && typeof initializeSensitiveFields === "function") {
const focusoutEvent = new Event("focusout", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(focusoutEvent);
}
});
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
closeModal(apiKeyModal);
showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, "success");
@@ -1109,32 +1125,139 @@ function handleBulkAddApiKeys() {
* Handles searching/filtering of API keys in the list.
*/
function handleApiKeySearch() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeySearchInput || !apiKeyContainer) return;
if (!apiKeySearchInput) return;
const searchTerm = apiKeySearchInput.value.toLowerCase();
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
keyItems.forEach((item) => {
const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
// 过滤API密钥
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
if (input) {
const realValue = input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value").toLowerCase()
: input.value.toLowerCase();
item.style.display = realValue.includes(searchTerm) ? "flex" : "none";
}
}
// 重置到第一页
currentApiKeyPage = 1;
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
}
/**
* 渲染当前页的API密钥
*/
function renderApiKeyPage() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!apiKeyContainer) return;
// 清空容器
apiKeyContainer.innerHTML = "";
// 计算当前页的数据范围
const startIndex = (currentApiKeyPage - 1) * API_KEYS_PER_PAGE;
const endIndex = Math.min(startIndex + API_KEYS_PER_PAGE, filteredApiKeys.length);
const pageKeys = filteredApiKeys.slice(startIndex, endIndex);
// 渲染当前页的密钥
pageKeys.forEach((key) => {
addArrayItemWithValue("API_KEYS", key);
});
// 如果没有密钥,显示提示信息
if (pageKeys.length === 0) {
const emptyMessage = document.createElement("div");
emptyMessage.className = "text-gray-500 text-sm italic text-center py-4";
emptyMessage.textContent = filteredApiKeys.length === 0 ?
(allApiKeys.length === 0 ? "暂无API密钥" : "未找到匹配的密钥") :
"当前页无数据";
apiKeyContainer.appendChild(emptyMessage);
}
}
/**
* 更新分页控件
*/
function updateApiKeyPagination() {
totalApiKeyPages = Math.max(1, Math.ceil(filteredApiKeys.length / API_KEYS_PER_PAGE));
// 确保当前页在有效范围内
if (currentApiKeyPage > totalApiKeyPages) {
currentApiKeyPage = totalApiKeyPages;
}
const paginationContainer = document.getElementById("apiKeyPagination");
if (!paginationContainer) return;
// 如果只有一页或没有数据,隐藏分页控件
if (totalApiKeyPages <= 1) {
paginationContainer.style.display = "none";
return;
}
paginationContainer.style.display = "flex";
// 更新页码信息
const pageInfo = document.getElementById("apiKeyPageInfo");
if (pageInfo) {
pageInfo.textContent = `${currentApiKeyPage} 页,共 ${totalApiKeyPages} 页 (${filteredApiKeys.length} 个密钥)`;
}
// 更新按钮状态
const prevBtn = document.getElementById("apiKeyPrevBtn");
const nextBtn = document.getElementById("apiKeyNextBtn");
if (prevBtn) {
prevBtn.disabled = currentApiKeyPage <= 1;
prevBtn.className = currentApiKeyPage <= 1 ?
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
}
if (nextBtn) {
nextBtn.disabled = currentApiKeyPage >= totalApiKeyPages;
nextBtn.className = currentApiKeyPage >= totalApiKeyPages ?
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
}
}
/**
* 跳转到指定页
*/
function goToApiKeyPage(page) {
if (page < 1 || page > totalApiKeyPages) return;
currentApiKeyPage = page;
renderApiKeyPage();
updateApiKeyPagination();
}
/**
* 上一页
*/
function prevApiKeyPage() {
if (currentApiKeyPage > 1) {
goToApiKeyPage(currentApiKeyPage - 1);
}
}
/**
* 下一页
*/
function nextApiKeyPage() {
if (currentApiKeyPage < totalApiKeyPages) {
goToApiKeyPage(currentApiKeyPage + 1);
}
}
/**
* Handles the bulk deletion of API keys based on input from the modal.
*/
function handleBulkDeleteApiKeys() {
const apiKeyContainer = document.getElementById("API_KEYS_container");
if (!bulkDeleteApiKeyInput || !apiKeyContainer || !bulkDeleteApiKeyModal)
return;
if (!bulkDeleteApiKeyInput || !bulkDeleteApiKeyModal) return;
const bulkText = bulkDeleteApiKeyInput.value;
if (!bulkText.trim()) {
@@ -1149,24 +1272,30 @@ function handleBulkDeleteApiKeys() {
return;
}
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
// 从allApiKeys数组中删除匹配的密钥
let deleteCount = 0;
keyItems.forEach((item) => {
const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
);
const realValue =
input &&
(input.hasAttribute("data-real-value")
? input.getAttribute("data-real-value")
: input.value);
if (realValue && keysToDelete.has(realValue)) {
item.remove();
allApiKeys = allApiKeys.filter(key => {
if (keysToDelete.has(key)) {
deleteCount++;
return false;
}
return true;
});
// 更新过滤后的数组
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
if (!searchTerm) {
filteredApiKeys = [...allApiKeys];
} else {
filteredApiKeys = allApiKeys.filter(key =>
key.toLowerCase().includes(searchTerm)
);
}
// 重新渲染当前页
renderApiKeyPage();
updateApiKeyPagination();
closeModal(bulkDeleteApiKeyModal);
if (deleteCount > 0) {
@@ -1782,6 +1911,15 @@ function collectFormData() {
const arrayContainers = document.querySelectorAll(".array-container");
arrayContainers.forEach((container) => {
const key = container.id.replace("_container", "");
// 特殊处理API_KEYS - 使用全局数组而不是DOM元素
if (key === "API_KEYS") {
formData[key] = allApiKeys.filter(
(value) => value && value.trim() !== "" && value !== MASKED_VALUE
);
return;
}
const arrayInputs = container.querySelectorAll(`.${ARRAY_INPUT_CLASS}`);
formData[key] = Array.from(arrayInputs)
.map((input) => {

View File

@@ -961,6 +961,31 @@ endblock %} {% block head_extra_styles %}
<div class="array-container" id="API_KEYS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<!-- API密钥分页控件 -->
<div id="apiKeyPagination" class="flex items-center justify-between mt-2 mb-2" style="display: none;">
<div class="flex items-center gap-2">
<button
type="button"
id="apiKeyPrevBtn"
onclick="prevApiKeyPage()"
class="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
>
<i class="fas fa-chevron-left"></i> 上一页
</button>
<span id="apiKeyPageInfo" class="text-sm text-gray-600">第 1 页,共 1 页</span>
<button
type="button"
id="apiKeyNextBtn"
onclick="nextApiKeyPage()"
class="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
>
下一页 <i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="text-xs text-gray-500">
每页显示 20 个密钥
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"