feat: 实现API密钥的单独和批量删除功能

本次更新引入了删除API密钥的功能,包括前端界面和后端逻辑。

主要变更:

- **API路由 (`app/router/config_routes.py`):**
    - 添加了新的API端点 `/keys/{key_to_delete}` 用于删除单个密钥。
    - 添加了新的API端点 `/keys/delete-selected` 用于批量删除选定的密钥。
    - 增加了对请求体 `DeleteKeysRequest` 的Pydantic模型定义。
    - 在删除操作前进行身份验证。

- **配置服务 (`app/service/config/config_service.py`):**
    - 实现了 `delete_key` 方法来处理单个密钥的删除逻辑。
    - 实现了 `delete_selected_keys` 方法来处理批量密钥的删除逻辑。
    - 确保在删除操作后更新配置。

- **密钥管理器 (`app/service/key/key_manager.py`):**
    - 更新了 `remove_key` 方法,以确保从活动密钥列表中正确移除密钥。
    - 改进了 `reset_instance` 方法,在重置时保留下一个密钥提示(`_preserved_next_key_in_cycle`),以防止在配置重载后立即丢失轮换状态。

- **前端JavaScript (`app/static/js/keys_status.js`):**
    - 添加了 `showSingleKeyDeleteConfirmModal` 函数,用于显示单个密钥删除的确认模态框。
    - 添加了 `executeSingleKeyDelete` 函数,用于执行单个密钥的删除请求。
    - 添加了 `showDeleteConfirmationModal` 函数,用于显示批量删除密钥的确认模态框。
    - 添加了 `executeDeleteSelectedKeys` 函数,用于执行批量删除密钥的请求。
    - 更新了UI交互,包括按钮状态(加载中、禁用)和结果通知。

- **HTML模板 (`app/templates/keys_status.html`):**
    - 为有效密钥和无效密钥列表中的每个密钥添加了“删除”按钮。
    - 为有效密钥和无效密钥列表添加了“批量删除”按钮。
    - 添加了用于单个密钥删除和批量删除的确认模态框HTML结构。
    - 调整了现有模态框的样式,以提高视觉一致性。

这些更改增强了密钥管理功能,允许用户更灵活地管理其API密钥。
This commit is contained in:
snaily
2025-05-08 21:58:26 +08:00
parent f1f568afca
commit 920228d3aa
5 changed files with 635 additions and 19 deletions

View File

@@ -2,10 +2,11 @@
配置路由模块
"""
from typing import Any, Dict
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from app.core.security import verify_auth_token
from app.log.logger import Logger, get_config_routes_logger
@@ -54,6 +55,73 @@ async def reset_config(request: Request):
raise HTTPException(status_code=400, detail=str(e))
# Pydantic model for bulk delete request
class DeleteKeysRequest(BaseModel):
keys: List[str] = Field(..., description="List of API keys to delete")
@router.delete("/keys/{key_to_delete}", response_model=Dict[str, Any])
async def delete_single_key(key_to_delete: str, request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
return RedirectResponse(url="/", status_code=302)
try:
logger.info(f"Attempting to delete key: {key_to_delete}")
result = await ConfigService.delete_key(key_to_delete)
if not result.get("success"):
# Optionally, translate specific errors to HTTP status codes
# For now, let's assume 400 for any failure from service if not found,
# or 500 if it was an unexpected error (though service should handle that)
raise HTTPException(
status_code=(
404 if "not found" in result.get("message", "").lower() else 400
),
detail=result.get("message"),
)
return result
except HTTPException as e:
# Re-raise HTTPExceptions directly
raise e
except Exception as e:
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
@router.post("/keys/delete-selected", response_model=Dict[str, Any])
async def delete_selected_keys_route(
delete_request: DeleteKeysRequest, request: Request
):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized attempt to bulk delete keys")
return RedirectResponse(url="/", status_code=302)
if not delete_request.keys:
logger.warning("Attempt to bulk delete keys with an empty list.")
raise HTTPException(status_code=400, detail="No keys provided for deletion.")
try:
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
result = await ConfigService.delete_selected_keys(delete_request.keys)
# Similar to single delete, we can check result["success"]
if not result.get("success") and result.get("deleted_count", 0) == 0:
# If no keys were actually deleted, it might be a client error (e.g., all keys not found)
# or an empty list was somehow passed despite the check above.
raise HTTPException(
status_code=400, detail=result.get("message", "Failed to delete keys.")
)
# If some keys were deleted but others not found, it's still a partial success, return 200 with details.
return result
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error bulk deleting keys: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Error bulk deleting keys: {str(e)}"
)
@router.get("/ui/models")
async def get_ui_models(request: Request):
auth_token_cookie = request.cookies.get("auth_token")

View File

@@ -125,6 +125,78 @@ class ConfigService:
return await ConfigService.get_config()
@staticmethod
async def delete_key(key_to_delete: str) -> Dict[str, Any]:
"""删除单个API密钥"""
# 确保 settings.API_KEYS 是一个列表
if not isinstance(settings.API_KEYS, list):
settings.API_KEYS = []
original_keys_count = len(settings.API_KEYS)
# 创建一个不包含待删除密钥的新列表
updated_api_keys = [k for k in settings.API_KEYS if k != key_to_delete]
if len(updated_api_keys) < original_keys_count:
# 密钥已找到并从列表中移除
settings.API_KEYS = updated_api_keys # 首先更新内存中的 settings
# 使用 update_config 持久化更改,它同时处理数据库和 KeyManager
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info(f"密钥 '{key_to_delete}' 已成功删除。")
return {"success": True, "message": f"密钥 '{key_to_delete}' 已成功删除。"}
else:
# 未找到密钥
logger.warning(f"尝试删除密钥 '{key_to_delete}',但未找到该密钥。")
return {"success": False, "message": f"未找到密钥 '{key_to_delete}'"}
@staticmethod
async def delete_selected_keys(keys_to_delete: List[str]) -> Dict[str, Any]:
"""批量删除选定的API密钥"""
if not isinstance(settings.API_KEYS, list):
settings.API_KEYS = []
deleted_count = 0
not_found_keys: List[str] = []
current_api_keys = list(settings.API_KEYS) # 创建副本以进行修改
keys_actually_removed: List[str] = []
for key_to_del in keys_to_delete:
if key_to_del in current_api_keys:
current_api_keys.remove(key_to_del)
keys_actually_removed.append(key_to_del)
deleted_count += 1
else:
not_found_keys.append(key_to_del)
if deleted_count > 0:
settings.API_KEYS = current_api_keys # 更新内存中的 settings
await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
logger.info(
f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
)
message = f"成功删除 {deleted_count} 个密钥。"
if not_found_keys:
message += f" {len(not_found_keys)} 个密钥未找到: {not_found_keys}"
return {
"success": True,
"message": message,
"deleted_count": deleted_count,
"not_found_keys": not_found_keys,
}
else:
message = "没有密钥被删除。"
if not_found_keys: # 如果提供了密钥但都未找到
message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}"
elif not keys_to_delete: # 如果 keys_to_delete 列表为空
message = "未指定要删除的密钥。"
logger.warning(message)
return {
"success": False,
"message": message,
"deleted_count": 0,
"not_found_keys": not_found_keys,
}
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""

View File

@@ -2,7 +2,6 @@ import asyncio
from itertools import cycle
from typing import Dict
from app.config.config import settings
from app.log.logger import get_key_manager_logger
@@ -37,7 +36,7 @@ class KeyManager:
async with self.failure_count_lock:
for key in self.key_failure_counts:
self.key_failure_counts[key] = 0
async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数"""
async with self.failure_count_lock:
@@ -45,7 +44,9 @@ class KeyManager:
self.key_failure_counts[key] = 0
logger.info(f"Reset failure count for key: {key}")
return True
logger.warning(f"Attempt to reset failure count for non-existent key: {key}")
logger.warning(
f"Attempt to reset failure count for non-existent key: {key}"
)
return False
async def get_next_working_key(self) -> str:
@@ -62,7 +63,7 @@ class KeyManager:
# await self.reset_failure_counts() 取消重置
return current_key
async def handle_api_failure(self, api_key: str,retries: int) -> str:
async def handle_api_failure(self, api_key: str, retries: int) -> str:
"""处理API调用失败"""
async with self.failure_count_lock:
self.key_failure_counts[api_key] += 1
@@ -72,7 +73,7 @@ class KeyManager:
)
if retries < settings.MAX_RETRIES:
return await self.get_next_working_key()
else:
else:
return ""
def get_fail_count(self, key: str) -> int:
@@ -100,10 +101,32 @@ class KeyManager:
for key in self.key_failure_counts:
if self.key_failure_counts[key] < self.MAX_FAILURES:
return key
return self.api_keys[0]
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
if self.api_keys:
return self.api_keys[0]
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
# 根据现有代码如果api_keys为空self.api_keys[0]会报错。
# 如果没有有效key且列表不空返回第一个。若列表为空这里会出IndexError。
# 更安全的做法是:
if not self.api_keys:
logger.warning("API key list is empty, cannot get first valid key.")
# Depending on desired behavior, either raise error or return an indicator like "" or None
# For now, let's allow it to potentially fail if a key is expected by caller
# but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
return ""
return self.api_keys[
0
] # Fallback to the first key if no key is "valid" but list is not empty
_singleton_instance = None
_singleton_lock = asyncio.Lock()
_preserved_failure_counts: Dict[str, int] | None = None
_preserved_old_api_keys_for_reset: list | None = None
_preserved_next_key_in_cycle: str | None = None
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
@@ -112,22 +135,174 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
"""
global _singleton_instance
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance is None:
if api_keys is None:
raise ValueError("API keys are required to initialize the KeyManager")
# This case needs careful handling. If it's the very first call, api_keys are required.
# If it's after a reset and no api_keys are provided, what should happen?
# The original ValueError was "API keys are required to initialize the KeyManager".
# Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
# However, the user's request implies new api_keys will be part of the reset flow.
# For now, stick to a strict requirement for api_keys if _singleton_instance is None.
raise ValueError(
"API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys: # Handle case where api_keys is an empty list
logger.warning(
"Initializing KeyManager with an empty list of API keys."
)
# Consider if this should be an error or allowed. Current KeyManager supports it.
_singleton_instance = KeyManager(api_keys)
logger.info("KeyManager instance created.")
logger.info(
f"KeyManager instance created/re-created with {len(api_keys)} API keys."
)
# 1. 恢复失败计数
if _preserved_failure_counts:
# Initialize new instance's failure_counts for all new keys to 0
current_failure_counts = {
key: 0 for key in _singleton_instance.api_keys
}
# Inherit counts for keys that exist in both old and new lists
for key, count in _preserved_failure_counts.items():
if key in current_failure_counts:
current_failure_counts[key] = count
_singleton_instance.key_failure_counts = current_failure_counts
logger.info("Inherited failure counts for applicable keys.")
_preserved_failure_counts = None # Clear after use
# 2. 调整 key_cycle 的起始点
start_key_for_new_cycle = None
if (
_preserved_old_api_keys_for_reset
and _preserved_next_key_in_cycle
and _singleton_instance.api_keys # Ensure new api_keys list is not empty
):
try:
# Find the index of the preserved next key in the *old* list
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
_preserved_next_key_in_cycle
)
# Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
# Find the first key that also exists in the new api_keys list
for i in range(len(_preserved_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_old_api_keys_for_reset
)
key_candidate = _preserved_old_api_keys_for_reset[
current_old_key_idx
]
if key_candidate in _singleton_instance.api_keys:
start_key_for_new_cycle = key_candidate
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_next_key_in_cycle}' not found in preserved old API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
logger.error(
f"Error determining start key for new cycle from preserved state: {e}. "
"New cycle will start from the beginning."
)
if start_key_for_new_cycle and _singleton_instance.api_keys:
try:
# Find the index of the determined start_key in the new api_keys list
target_idx = _singleton_instance.api_keys.index(
start_key_for_new_cycle
)
# Advance the new cycle by calling next() target_idx times
# This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
for _ in range(target_idx):
next(_singleton_instance.key_cycle)
logger.info(
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
)
except ValueError:
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
logger.warning(
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except (
StopIteration
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
logger.error(
"StopIteration while advancing key cycle, implies empty new API key list previously missed."
)
except Exception as e:
logger.error(
f"Error advancing new key cycle: {e}. Cycle will start from beginning."
)
else:
if _singleton_instance.api_keys:
logger.info(
"New key cycle will start from the beginning of the new API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New key cycle not applicable as the new API key list is empty."
)
# 清理所有保存的状态
_preserved_old_api_keys_for_reset = None
_preserved_next_key_in_cycle = None
# _preserved_failure_counts already cleared
return _singleton_instance
async def reset_key_manager_instance():
"""重置 KeyManager 单例实例"""
global _singleton_instance
"""
重置 KeyManager 单例实例。
将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
以供下一次 get_key_manager_instance 调用时恢复。
"""
global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance:
# 1. 保存失败计数
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
# 2. 保存旧的 API keys 列表
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
# 3. 保存 key_cycle 的下一个 key 提示
# This should be the key that get_next_key() would return next.
try:
if (
_singleton_instance.api_keys
): # Only if there are keys to cycle through
# Calling get_next_key() consumes one key and returns it. This is the key
# we want the new cycle to effectively start with.
_preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key()
)
else:
_preserved_next_key_in_cycle = None # No keys, so no next key
except (
StopIteration
): # Should be caught by "if _singleton_instance.api_keys"
logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance."
)
_preserved_next_key_in_cycle = None
except Exception as e:
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_next_key_in_cycle = None
_singleton_instance = None
logger.info("KeyManager instance reset.")
logger.info(
"KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation."
)
else:
logger.info(
"KeyManager instance was not set (or already reset), no reset action performed."
)

View File

@@ -1235,6 +1235,162 @@ document.addEventListener("DOMContentLoaded", () => {
// 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 表示成功messagetrue 表示关闭后刷新
} else {
showResultModal(false, response.message || "批量删除密钥失败", true); // false 表示失败messagetrue 表示关闭后刷新
}
} 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");

View File

@@ -802,6 +802,13 @@ endblock %} {% block head_extra_styles %}
>
<i class="fas fa-copy"></i> 批量复制
</button>
<button
class="flex items-center gap-2 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
onclick="event.stopPropagation(); showDeleteConfirmationModal('valid', event)"
disabled
>
<i class="fas fa-trash-alt"></i> 批量删除
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
@@ -882,6 +889,13 @@ endblock %} {% block head_extra_styles %}
<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>
@@ -972,6 +986,13 @@ endblock %} {% block head_extra_styles %}
>
<i class="fas fa-copy"></i> 批量复制
</button>
<button
class="flex items-center gap-2 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
onclick="event.stopPropagation(); showDeleteConfirmationModal('invalid', event)"
disabled
>
<i class="fas fa-trash-alt"></i> 批量删除
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
@@ -1050,6 +1071,13 @@ endblock %} {% block head_extra_styles %}
<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>
@@ -1131,31 +1159,44 @@ endblock %} {% block head_extra_styles %}
>
<div
class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border-color: rgba(120, 100, 200, 0.4);
"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">
<h3
class="text-lg font-semibold"
id="verifyModalTitle"
style="
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
font-weight: 600;
"
>
批量验证密钥
</h3>
<button
onclick="closeVerifyModal()"
class="text-gray-500 hover:text-gray-700 focus:outline-none"
class="text-gray-300 hover:text-white focus:outline-none"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6">
<p class="text-gray-600" id="verifyModalMessage"></p>
<p style="color: #f8fafc" id="verifyModalMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button
onclick="closeVerifyModal()"
class="px-3 py-1.5 text-xs font-medium bg-slate-500 hover:bg-slate-600 text-white rounded-lg transition-colors"
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
>
取消
</button>
<button
id="confirmVerifyBtn"
class="px-3 py-1.5 text-xs font-medium bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors"
class="px-3 py-1.5 text-xs font-medium bg-teal-700 hover:bg-teal-800 text-white rounded-lg transition-colors"
>
确认验证
</button>
@@ -1163,6 +1204,110 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
<!-- 删除确认模态框 -->
<div
id="deleteConfirmModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
>
<div
class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border-color: rgba(120, 100, 200, 0.4);
"
>
<div class="flex items-center justify-between mb-4">
<h3
class="text-lg font-semibold"
id="deleteConfirmModalTitle"
style="
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
font-weight: 600;
"
>
确认删除
</h3>
<button
onclick="closeDeleteConfirmationModal()"
class="text-gray-300 hover:text-white focus:outline-none"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6">
<p style="color: #f8fafc" id="deleteConfirmModalMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button
onclick="closeDeleteConfirmationModal()"
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
>
取消
</button>
<button
id="confirmDeleteBtn"
class="px-3 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-800 text-white rounded-lg transition-colors"
>
确认删除
</button>
</div>
</div>
</div>
<!-- 新增:单个密钥删除确认模态框 -->
<div
id="singleKeyDeleteConfirmModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
>
<div
class="rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border-color: rgba(120, 100, 200, 0.4);
"
>
<div class="flex items-center justify-between mb-4">
<h3
class="text-lg font-semibold"
id="singleKeyDeleteConfirmModalTitle"
style="
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
font-weight: 600;
"
>
确认删除密钥
</h3>
<button
onclick="closeSingleKeyDeleteConfirmModal()"
class="text-gray-300 hover:text-white focus:outline-none"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6">
<p style="color: #f8fafc" id="singleKeyDeleteConfirmModalMessage"></p>
</div>
<div class="flex justify-end gap-3">
<button
onclick="closeSingleKeyDeleteConfirmModal()"
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
>
取消
</button>
<button
id="confirmSingleKeyDeleteBtn"
class="px-3 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-800 text-white rounded-lg transition-colors"
>
确认删除
</button>
</div>
</div>
</div>
<!-- 操作结果模态框 -->
<div
id="resultModal"