mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-05 23:56:37 +08:00
feat(error-logs): 增强错误日志功能和UI交互
- 新增错误码搜索功能,支持精确匹配错误码 - 重构复制功能,支持批量选择和复制密钥 - 优化UI布局和交互体验,添加悬停复制按钮 - 重构路由结构,将log_routes.py重命名为error_log_routes.py
This commit is contained in:
@@ -157,6 +157,7 @@ async def get_error_logs(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
key_search: Optional[str] = None,
|
key_search: Optional[str] = None,
|
||||||
error_search: Optional[str] = None,
|
error_search: Optional[str] = None,
|
||||||
|
error_code_search: Optional[str] = None, # Added error code search
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@@ -168,6 +169,7 @@ async def get_error_logs(
|
|||||||
offset (int): 偏移量
|
offset (int): 偏移量
|
||||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||||
|
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||||
start_date (Optional[datetime]): 开始日期时间
|
start_date (Optional[datetime]): 开始日期时间
|
||||||
end_date (Optional[datetime]): 结束日期时间
|
end_date (Optional[datetime]): 结束日期时间
|
||||||
|
|
||||||
@@ -198,6 +200,17 @@ async def get_error_logs(
|
|||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
# Use the datetime object directly for comparison
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
|
if error_code_search:
|
||||||
|
try:
|
||||||
|
# Attempt to convert search string to integer for exact match
|
||||||
|
error_code_int = int(error_code_search)
|
||||||
|
query = query.where(ErrorLog.error_code == error_code_int)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, log a warning and potentially skip this filter
|
||||||
|
# or handle as needed (e.g., return no results for invalid code format)
|
||||||
|
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||||
|
# Optionally, force no results if the format is invalid:
|
||||||
|
# query = query.where(False) # This ensures no rows are returned
|
||||||
|
|
||||||
# Apply ordering, limit, and offset
|
# Apply ordering, limit, and offset
|
||||||
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
|
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
|
||||||
@@ -212,6 +225,7 @@ async def get_error_logs(
|
|||||||
async def get_error_logs_count(
|
async def get_error_logs_count(
|
||||||
key_search: Optional[str] = None,
|
key_search: Optional[str] = None,
|
||||||
error_search: Optional[str] = None,
|
error_search: Optional[str] = None,
|
||||||
|
error_code_search: Optional[str] = None, # Added error code search
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None
|
end_date: Optional[datetime] = None
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -221,6 +235,7 @@ async def get_error_logs_count(
|
|||||||
Args:
|
Args:
|
||||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||||
|
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||||
start_date (Optional[datetime]): 开始日期时间
|
start_date (Optional[datetime]): 开始日期时间
|
||||||
end_date (Optional[datetime]): 结束日期时间
|
end_date (Optional[datetime]): 结束日期时间
|
||||||
|
|
||||||
@@ -243,6 +258,16 @@ async def get_error_logs_count(
|
|||||||
if end_date:
|
if end_date:
|
||||||
# Use the datetime object directly for comparison
|
# Use the datetime object directly for comparison
|
||||||
query = query.where(ErrorLog.request_time < end_date)
|
query = query.where(ErrorLog.request_time < end_date)
|
||||||
|
if error_code_search:
|
||||||
|
try:
|
||||||
|
# Attempt to convert search string to integer for exact match
|
||||||
|
error_code_int = int(error_code_search)
|
||||||
|
query = query.where(ErrorLog.error_code == error_code_int)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, log a warning and potentially skip this filter
|
||||||
|
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||||
|
# Optionally, force count to 0 if the format is invalid:
|
||||||
|
# return 0 # Or query = query.where(False) before fetching
|
||||||
|
|
||||||
count_result = await database.fetch_one(query)
|
count_result = await database.fetch_one(query)
|
||||||
return count_result[0] if count_result else 0
|
return count_result[0] if count_result else 0
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async def get_error_logs_api(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
||||||
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
|
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
|
||||||
|
error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter
|
||||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
||||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
|
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
|
||||||
):
|
):
|
||||||
@@ -50,6 +51,7 @@ async def get_error_logs_api(
|
|||||||
offset: 偏移量
|
offset: 偏移量
|
||||||
key_search: 密钥搜索
|
key_search: 密钥搜索
|
||||||
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
||||||
|
error_code_search: 错误码搜索
|
||||||
start_date: 开始日期
|
start_date: 开始日期
|
||||||
end_date: 结束日期
|
end_date: 结束日期
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ async def get_error_logs_api(
|
|||||||
offset=offset,
|
offset=offset,
|
||||||
key_search=key_search,
|
key_search=key_search,
|
||||||
error_search=error_search, # 数据库查询需要处理这个
|
error_search=error_search, # 数据库查询需要处理这个
|
||||||
|
error_code_search=error_code_search, # Pass error code search to DB function
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
@@ -77,6 +80,7 @@ async def get_error_logs_api(
|
|||||||
total_count = await get_error_logs_count(
|
total_count = await get_error_logs_count(
|
||||||
key_search=key_search,
|
key_search=key_search,
|
||||||
error_search=error_search,
|
error_search=error_search,
|
||||||
|
error_code_search=error_code_search, # Pass error code search to DB count function
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date
|
end_date=end_date
|
||||||
)
|
)
|
||||||
@@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
|
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.log.logger import get_routes_logger
|
from app.log.logger import get_routes_logger
|
||||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes
|
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes # 新增导入 stats_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_service import StatsService
|
from app.service.stats_service import StatsService
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ def setup_routers(app: FastAPI) -> None:
|
|||||||
app.include_router(gemini_routes.router)
|
app.include_router(gemini_routes.router)
|
||||||
app.include_router(gemini_routes.router_v1beta)
|
app.include_router(gemini_routes.router_v1beta)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(log_routes.router)
|
app.include_router(error_log_routes.router)
|
||||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
||||||
app.include_router(stats_routes.router) # 包含 stats API 路由
|
app.include_router(stats_routes.router) # 包含 stats API 路由
|
||||||
|
|
||||||
|
|||||||
+240
-41
@@ -20,6 +20,7 @@ let errorLogs = []; // Store fetched logs for details view
|
|||||||
let currentSearch = { // Store current search parameters
|
let currentSearch = { // Store current search parameters
|
||||||
key: '',
|
key: '',
|
||||||
error: '',
|
error: '',
|
||||||
|
errorCode: '', // Added error code search
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: ''
|
endDate: ''
|
||||||
};
|
};
|
||||||
@@ -36,11 +37,15 @@ let logDetailModal;
|
|||||||
let modalCloseBtns; // Collection of close buttons for the modal
|
let modalCloseBtns; // Collection of close buttons for the modal
|
||||||
let keySearchInput;
|
let keySearchInput;
|
||||||
let errorSearchInput;
|
let errorSearchInput;
|
||||||
|
let errorCodeSearchInput; // Added error code input
|
||||||
let startDateInput;
|
let startDateInput;
|
||||||
let endDateInput;
|
let endDateInput;
|
||||||
let searchBtn;
|
let searchBtn;
|
||||||
let pageInput; // 新增:页码输入框
|
let pageInput;
|
||||||
let goToPageBtn; // 新增:跳转按钮
|
let goToPageBtn;
|
||||||
|
let selectAllCheckbox; // 新增:全选复选框
|
||||||
|
let copySelectedKeysBtn; // 新增:复制选中按钮
|
||||||
|
let selectedCountSpan; // 新增:选中计数显示
|
||||||
|
|
||||||
// 页面加载完成后执行
|
// 页面加载完成后执行
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -57,11 +62,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
||||||
keySearchInput = document.getElementById('keySearch');
|
keySearchInput = document.getElementById('keySearch');
|
||||||
errorSearchInput = document.getElementById('errorSearch');
|
errorSearchInput = document.getElementById('errorSearch');
|
||||||
|
errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input
|
||||||
startDateInput = document.getElementById('startDate');
|
startDateInput = document.getElementById('startDate');
|
||||||
endDateInput = document.getElementById('endDate');
|
endDateInput = document.getElementById('endDate');
|
||||||
searchBtn = document.getElementById('searchBtn');
|
searchBtn = document.getElementById('searchBtn');
|
||||||
pageInput = document.getElementById('pageInput'); // 新增
|
pageInput = document.getElementById('pageInput');
|
||||||
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
|
goToPageBtn = document.getElementById('goToPageBtn');
|
||||||
|
selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增
|
||||||
|
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增
|
||||||
|
selectedCountSpan = document.getElementById('selectedCount'); // 新增
|
||||||
|
|
||||||
// Initialize page size selector
|
// Initialize page size selector
|
||||||
if (pageSizeSelector) {
|
if (pageSizeSelector) {
|
||||||
@@ -81,6 +90,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Update search parameters from input fields
|
// Update search parameters from input fields
|
||||||
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
||||||
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
||||||
|
currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value
|
||||||
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
||||||
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
||||||
currentPage = 1; // Reset to first page on new search
|
currentPage = 1; // Reset to first page on new search
|
||||||
@@ -104,8 +114,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Initial load of error logs
|
// Initial load of error logs
|
||||||
loadErrorLogs();
|
loadErrorLogs();
|
||||||
|
|
||||||
// Add event listeners for copy buttons inside the modal
|
// Add event listeners for copy buttons inside the modal and table
|
||||||
setupCopyButtons();
|
setupCopyButtons(); // This will now also handle table copy buttons if called after render
|
||||||
|
|
||||||
|
// Add event listeners for bulk selection
|
||||||
|
setupBulkSelectionListeners(); // 新增:设置批量选择监听器
|
||||||
|
|
||||||
// 新增:为页码跳转按钮添加事件监听器
|
// 新增:为页码跳转按钮添加事件监听器
|
||||||
if (goToPageBtn && pageInput) {
|
if (goToPageBtn && pageInput) {
|
||||||
@@ -174,44 +187,196 @@ function handleCopyResult(buttonElement, success) {
|
|||||||
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to set up copy button listeners (using modern API with fallback)
|
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||||
function setupCopyButtons() {
|
function setupCopyButtons(containerSelector = 'body') {
|
||||||
const copyButtons = document.querySelectorAll('.copy-btn');
|
// Find buttons within the specified container (defaults to body)
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||||
copyButtons.forEach(button => {
|
copyButtons.forEach(button => {
|
||||||
button.addEventListener('click', function() {
|
// Remove existing listener to prevent duplicates if called multiple times
|
||||||
const targetId = this.getAttribute('data-target');
|
button.removeEventListener('click', handleCopyButtonClick);
|
||||||
const targetElement = document.getElementById(targetId);
|
// Add the listener
|
||||||
|
button.addEventListener('click', handleCopyButtonClick);
|
||||||
if (targetElement) {
|
|
||||||
const textToCopy = targetElement.textContent;
|
|
||||||
let copySuccess = false;
|
|
||||||
|
|
||||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
||||||
handleCopyResult(this, true); // Use helper for feedback
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Clipboard API failed, attempting fallback:', err);
|
|
||||||
// Attempt fallback if modern API fails
|
|
||||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
|
||||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Use fallback if modern API is not available or context is insecure
|
|
||||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
|
||||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
|
||||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Target element not found:', targetId);
|
|
||||||
showNotification('复制出错:找不到目标元素', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracted click handler logic for reusability and removing listeners
|
||||||
|
function handleCopyButtonClick() {
|
||||||
|
const button = this; // 'this' refers to the button clicked
|
||||||
|
const targetId = button.getAttribute('data-target');
|
||||||
|
const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key)
|
||||||
|
let textToCopy = '';
|
||||||
|
|
||||||
|
if (textToCopyDirect) {
|
||||||
|
textToCopy = textToCopyDirect;
|
||||||
|
} else if (targetId) {
|
||||||
|
const targetElement = document.getElementById(targetId);
|
||||||
|
if (targetElement) {
|
||||||
|
textToCopy = targetElement.textContent;
|
||||||
|
} else {
|
||||||
|
console.error('Target element not found:', targetId);
|
||||||
|
showNotification('复制出错:找不到目标元素', 'error');
|
||||||
|
return; // Exit if target element not found
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('No data-target or data-copy-text attribute found on button:', button);
|
||||||
|
showNotification('复制出错:未指定复制内容', 'error');
|
||||||
|
return; // Exit if no source specified
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (textToCopy) {
|
||||||
|
let copySuccess = false;
|
||||||
|
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||||
|
handleCopyResult(button, true); // Use helper for feedback
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Clipboard API failed, attempting fallback:', err);
|
||||||
|
// Attempt fallback if modern API fails
|
||||||
|
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||||
|
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use fallback if modern API is not available or context is insecure
|
||||||
|
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||||
|
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||||
|
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('No text found to copy for target:', targetId || 'direct text');
|
||||||
|
showNotification('没有内容可复制', 'warning');
|
||||||
|
}
|
||||||
|
} // End of handleCopyButtonClick function
|
||||||
|
|
||||||
|
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||||
|
function setupCopyButtons(containerSelector = 'body') {
|
||||||
|
// Find buttons within the specified container (defaults to body)
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||||
|
copyButtons.forEach(button => {
|
||||||
|
// Remove existing listener to prevent duplicates if called multiple times
|
||||||
|
button.removeEventListener('click', handleCopyButtonClick);
|
||||||
|
// Add the listener
|
||||||
|
button.addEventListener('click', handleCopyButtonClick);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:设置批量选择相关的事件监听器
|
||||||
|
function setupBulkSelectionListeners() {
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableBody) {
|
||||||
|
// 使用事件委托处理行复选框的点击
|
||||||
|
tableBody.addEventListener('change', handleRowCheckboxChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copySelectedKeysBtn) {
|
||||||
|
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:处理“全选”复选框变化的函数
|
||||||
|
function handleSelectAllChange() {
|
||||||
|
const isChecked = selectAllCheckbox.checked;
|
||||||
|
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||||
|
rowCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
});
|
||||||
|
updateSelectedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:处理行复选框变化的函数 (事件委托)
|
||||||
|
function handleRowCheckboxChange(event) {
|
||||||
|
if (event.target.classList.contains('row-checkbox')) {
|
||||||
|
updateSelectedState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:更新选中状态(计数、按钮状态、全选框状态)
|
||||||
|
function updateSelectedState() {
|
||||||
|
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||||
|
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||||
|
const selectedCount = selectedCheckboxes.length;
|
||||||
|
|
||||||
|
// 移除了数字显示,不再更新selectedCountSpan
|
||||||
|
// 仍然更新复制按钮的禁用状态
|
||||||
|
if (copySelectedKeysBtn) {
|
||||||
|
copySelectedKeysBtn.disabled = selectedCount === 0;
|
||||||
|
|
||||||
|
// 可选:根据选中项数量更新按钮标题属性
|
||||||
|
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新“全选”复选框的状态
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (selectedCount > 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true; // 部分选中状态
|
||||||
|
} else {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:处理“复制选中密钥”按钮点击的函数
|
||||||
|
function handleCopySelectedKeys() {
|
||||||
|
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||||
|
const keysToCopy = [];
|
||||||
|
selectedCheckboxes.forEach(checkbox => {
|
||||||
|
const key = checkbox.getAttribute('data-key');
|
||||||
|
if (key) {
|
||||||
|
keysToCopy.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keysToCopy.length > 0) {
|
||||||
|
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
|
||||||
|
copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数
|
||||||
|
} else {
|
||||||
|
showNotification('没有选中的密钥可复制', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:通用的文本复制函数(结合现有逻辑)
|
||||||
|
function copyTextToClipboard(text, buttonElement = null) {
|
||||||
|
let copySuccess = false;
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
if (buttonElement) handleCopyResult(buttonElement, true);
|
||||||
|
else showNotification('已复制到剪贴板', 'success');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Clipboard API failed, attempting fallback:', err);
|
||||||
|
copySuccess = fallbackCopyTextToClipboard(text);
|
||||||
|
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||||
|
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||||
|
copySuccess = fallbackCopyTextToClipboard(text);
|
||||||
|
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||||
|
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 加载错误日志数据
|
// 加载错误日志数据
|
||||||
async function loadErrorLogs() {
|
async function loadErrorLogs() {
|
||||||
|
// 重置选择状态
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
|
||||||
|
updateSelectedState(); // 更新按钮状态和计数
|
||||||
|
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
showError(false);
|
showError(false);
|
||||||
showNoData(false);
|
showNoData(false);
|
||||||
@@ -227,6 +392,9 @@ async function loadErrorLogs() {
|
|||||||
if (currentSearch.error) {
|
if (currentSearch.error) {
|
||||||
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
||||||
}
|
}
|
||||||
|
if (currentSearch.errorCode) { // Add error code to API request
|
||||||
|
apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`;
|
||||||
|
}
|
||||||
if (currentSearch.startDate) {
|
if (currentSearch.startDate) {
|
||||||
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
||||||
}
|
}
|
||||||
@@ -274,6 +442,12 @@ function renderErrorLogs(logs) {
|
|||||||
if (!tableBody) return;
|
if (!tableBody) return;
|
||||||
tableBody.innerHTML = ''; // Clear previous entries
|
tableBody.innerHTML = ''; // Clear previous entries
|
||||||
|
|
||||||
|
// 重置全选复选框状态(在清空表格后)
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!logs || logs.length === 0) {
|
if (!logs || logs.length === 0) {
|
||||||
// Handled by showNoData
|
// Handled by showNoData
|
||||||
return;
|
return;
|
||||||
@@ -306,10 +480,20 @@ function renderErrorLogs(logs) {
|
|||||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||||
};
|
};
|
||||||
const maskedKey = maskKey(log.gemini_key);
|
const maskedKey = maskKey(log.gemini_key);
|
||||||
|
const fullKey = log.gemini_key || ''; // Store the full key
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
|
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
|
||||||
|
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}">
|
||||||
|
</td>
|
||||||
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
||||||
<td title="${log.gemini_key || ''}">${maskedKey}</td>
|
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
|
||||||
|
${maskedKey}
|
||||||
|
<!-- Added copy button for the key in the table row -->
|
||||||
|
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td>${log.error_type || '未知'}</td>
|
<td>${log.error_type || '未知'}</td>
|
||||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||||
<td>${log.model_name || '未知'}</td>
|
<td>${log.model_name || '未知'}</td>
|
||||||
@@ -331,6 +515,11 @@ function renderErrorLogs(logs) {
|
|||||||
showLogDetails(logId);
|
showLogDetails(logId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-initialize copy buttons specifically for the newly rendered table rows
|
||||||
|
setupCopyButtons('#errorLogsTable');
|
||||||
|
// Update selected state after rendering
|
||||||
|
updateSelectedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示错误日志详情 (从 API 获取)
|
// 显示错误日志详情 (从 API 获取)
|
||||||
@@ -403,6 +592,9 @@ async function showLogDetails(logId) {
|
|||||||
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
||||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||||
|
|
||||||
|
// Re-initialize copy buttons specifically for the modal after content is loaded
|
||||||
|
setupCopyButtons('#logDetailModal');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取日志详情失败:', error);
|
console.error('获取日志详情失败:', error);
|
||||||
// Show error in modal
|
// Show error in modal
|
||||||
@@ -547,10 +739,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
|
|||||||
|
|
||||||
// Function to show temporary status notifications (like copy success)
|
// Function to show temporary status notifications (like copy success)
|
||||||
function showNotification(message, type = 'success', duration = 3000) {
|
function showNotification(message, type = 'success', duration = 3000) {
|
||||||
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
|
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
|
||||||
if (!notificationElement) return;
|
if (!notificationElement) {
|
||||||
|
console.error("Notification element with ID 'notification' not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set message and type class
|
||||||
notificationElement.textContent = message;
|
notificationElement.textContent = message;
|
||||||
|
// Remove previous type classes before adding the new one
|
||||||
|
notificationElement.classList.remove('success', 'error', 'warning', 'info');
|
||||||
|
notificationElement.classList.add(type); // Add the type class for styling
|
||||||
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
||||||
|
|
||||||
// Hide after duration
|
// Hide after duration
|
||||||
|
|||||||
@@ -48,6 +48,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Modal styles are in base.html */
|
/* Modal styles are in base.html */
|
||||||
|
|
||||||
|
/* 自定义样式调整:减小搜索按钮和复制按钮的高度和宽度 */
|
||||||
|
#searchBtn, #copySelectedKeysBtn {
|
||||||
|
padding-top: 0.4rem !important;
|
||||||
|
padding-bottom: 0.4rem !important;
|
||||||
|
width: 96px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增加搜索按钮和时间控件之间的间距 */
|
||||||
|
.search-controls-grid {
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保输入框和按钮高度一致 */
|
||||||
|
.search-controls-grid input, .date-range-container input {
|
||||||
|
padding-top: 0.4rem !important;
|
||||||
|
padding-bottom: 0.4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 为搜索按钮添加额外的左边距 */
|
||||||
|
#searchBtn {
|
||||||
|
margin-left: 1rem !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -83,26 +106,35 @@
|
|||||||
<!-- 控制区域 (Refresh button removed, page size moved below) -->
|
<!-- 控制区域 (Refresh button removed, page size moved below) -->
|
||||||
<!-- Removed the original controls div -->
|
<!-- Removed the original controls div -->
|
||||||
|
|
||||||
<!-- 搜索控件 -->
|
<!-- 搜索与操作控件 -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-3 mb-6">
|
||||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1">
|
||||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1">
|
||||||
|
<input type="text" id="errorCodeSearch" placeholder="搜索错误码" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1">
|
||||||
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
|
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
|
||||||
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
<input type="datetime-local" id="startDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm">
|
||||||
<span class="text-gray-700">至</span>
|
<span class="text-gray-700">至</span>
|
||||||
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
<input type="datetime-local" id="endDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 col-span-1">
|
||||||
|
<button id="searchBtn" style="height: 36px; width: 80px;" class="flex items-center justify-center bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-all duration-200">
|
||||||
|
<i class="fas fa-search mr-1"></i>搜索
|
||||||
|
</button>
|
||||||
|
<button id="copySelectedKeysBtn" style="height: 36px; width: 80px;" class="flex items-center justify-center bg-success-600 hover:bg-success-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
|
||||||
|
<i class="far fa-copy mr-1"></i>复制
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
|
|
||||||
<i class="fas fa-search"></i> 搜索
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表格容器 - Enhanced Styling -->
|
<!-- 表格容器 - Enhanced Styling -->
|
||||||
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
|
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
|
||||||
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
|
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
|
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
|
||||||
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
|
<th class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"> <!-- Adjusted padding and width -->
|
||||||
|
<input type="checkbox" id="selectAllCheckbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500">
|
||||||
|
</th>
|
||||||
|
<th class="px-5 py-3 font-semibold">ID</th> <!-- Increased padding, adjusted rounding -->
|
||||||
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
|
||||||
<th class="px-5 py-3 font-semibold">错误类型</th>
|
<th class="px-5 py-3 font-semibold">错误类型</th>
|
||||||
<th class="px-5 py-3 font-semibold">错误码</th>
|
<th class="px-5 py-3 font-semibold">错误码</th>
|
||||||
@@ -186,17 +218,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
|
||||||
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
|
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
|
||||||
</div>
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalGeminiKey" title="复制密钥">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
</button>
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
|
||||||
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||||
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||||
|
<p id="modalErrorType" class="text-danger-600 font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||||
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorType" title="复制错误类型">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
|
||||||
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
|
||||||
@@ -204,7 +242,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
|
||||||
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
|
||||||
@@ -212,14 +250,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
||||||
<p id="modalModelName" class="font-medium"></p>
|
<p id="modalModelName" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||||
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalModelName" title="复制模型名称">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
||||||
<p id="modalRequestTime" class="font-medium"></p>
|
<p id="modalRequestTime" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||||
|
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestTime" title="复制请求时间">
|
||||||
|
<i class="far fa-copy"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user