Files
gemini-banlancer/web/static/js/dashboard.js
2025-11-20 12:24:05 +08:00

440 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========================================================================= //
// 全局变量 //
// ========================================================================= //
/** @type {number | null} 全局自动刷新定时器ID */
let autoRefreshInterval = null;
/** @type {StatusGrid | null} API状态分布网格的实例 */
let apiCanvasInstance = null;
/** @type {import('chart.js').Chart | null} 全局历史趋势图表实例 */
let historicalChartInstance = null;
// 预热关键数据
document.addEventListener('DOMContentLoaded', function() {
apiFetch('/admin/dashboard/overview');
apiFetch('/admin/keygroups');
// ... dashboard页面的其他初始化代码 ...
});
// ========================================================================= //
// 主程序入口 //
// ========================================================================= //
// 脚本位于<body>末尾无需等待DOMContentLoaded立即执行主程序。
main();
/**
* 主执行函数负责编排核心UI渲染和非核心模块的异步加载。
* [语法修正] 此函数必须是 async以允许在其中使用 await。
*/
async function main() {
// 阶段一立即初始化并渲染所有核心UI
initializeStaticFeatures();
await hydrateCoreUIFromPrefetchedData(); // 等待核心UI所需数据加载并渲染完成
// (图表模块),并与之分离
// 这个函数调用本身是同步的,但它内部会启动一个不会阻塞主流程的异步加载过程。
loadChartModulesAndRender();
}
// ========================================================================= //
// 核心UI功能 //
// ========================================================================= //
/**
* 负责初始化所有静态的、非数据驱动的UI功能。
*/
function initializeStaticFeatures() {
setupCoreEventListeners(); // [名称修正] 只监听核心UI相关的事件
initializeDropdownMenu();
initializeAutoRefreshControls();
initStatItemAnimations();
}
/**
* 核心数据“水合”函数。
* 它的使命是使用由 base.html 中的“信使”脚本预取的数据尽快填充核心UI。
*/
async function hydrateCoreUIFromPrefetchedData() {
// 初始化Canvas网格实例
if (document.getElementById('poolGridCanvas')) {
apiCanvasInstance = new StatusGrid('poolGridCanvas');
apiCanvasInstance.init();
}
// 从缓存中等待并获取“概览”数据,然后更新统计卡片
try {
const overviewResponse = await apiFetch('/admin/dashboard/overview');
const overviewData = await overviewResponse.json();
updateStatCards(overviewData);
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to hydrate overview data:', error);
showNotification('渲染总览数据失败。', 'error');
}
}
// 从缓存中等待并获取“分组”数据,然后填充下拉菜单
try {
const keygroupsResponse = await apiFetch('/admin/keygroups');
const keygroupsData = await keygroupsResponse.json();
populateSelectWithOptions(keygroupsData);
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to hydrate keygroups data:', error);
}
}
}
// ========================================================================= //
// 二级火箭:图表模块功能 //
// ========================================================================= //
/**
* 动态加载Chart.js引擎并在加载成功后启动图表的渲染流程。
* 这个过程是完全异步的,不会阻塞页面其它部分的交互。
*/
function loadChartModulesAndRender() {
// 辅助函数:通过动态创建<script>标签来注入外部JS
const injectScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
};
// 1. 启动Chart.js引擎的后台下载
injectScript('https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js')
.then(() => {
// 2. 当且仅当引擎加载成功后,才执行图表相关的初始化
console.log("Chart.js engine loaded successfully. Fetching chart data...");
setupChartEventListeners(); // 绑定图表专用的事件监听
fetchAndRenderChart(); // 获取数据并渲染图表
})
.catch(error => {
console.error("Failed to load Chart.js engine:", error);
showNotification('图表引擎加载失败。', 'error');
});
}
/**
* 负责图表模块的数据获取与渲染。
* @param {string} [groupId=''] - 可选的组ID用于筛选数据。
*/
async function fetchAndRenderChart(groupId = '') {
const url = groupId ? `/admin/dashboard/chart?group_id=${groupId}` : '/admin/dashboard/chart';
try {
// 图表数据总是获取最新的,不使用缓存
const response = await apiFetch(url, { noCache: true });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const chartData = await response.json();
// --- 渲染逻辑 ---
const canvas = document.getElementById('historicalChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (historicalChartInstance) {
historicalChartInstance.destroy();
}
const noData = !chartData || !chartData.datasets || chartData.datasets.length === 0;
if (noData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "16px Inter, sans-serif";
ctx.fillStyle = "#9ca3af";
ctx.textAlign = "center";
ctx.fillText("暂无图表数据", canvas.width / 2, canvas.height / 2);
return;
}
historicalChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: chartData.datasets.map(dataset => ({
label: dataset.label, data: dataset.data, borderColor: dataset.color,
backgroundColor: `${dataset.color}33`, pointBackgroundColor: dataset.color,
pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: dataset.color, fill: true, tension: 0.4
}))
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.05)' } },
x: { grid: { display: false } }
},
plugins: {
legend: { position: 'top', align: 'end', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } }
},
interaction: { intersect: false, mode: 'index' }
}
});
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to fetch chart data:', error);
// 可以在此处调用渲染函数并传入null来显示错误信息
showNotification('渲染图表失败,请检查控制台。', 'error');
}
}
}
// ========================================================================= //
// 辅助函数与事件监听 //
// ========================================================================= //
/**
* [名称修正] 绑定所有与核心UI非图表相关的事件。
*/
function setupCoreEventListeners() {
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener('change', handleAutoRefreshToggle);
}
const refreshButton = document.querySelector('button[title="手动刷新"]');
if (refreshButton) {
refreshButton.addEventListener('click', () => refreshPage(refreshButton));
}
}
/**
* [新增] 绑定图表专用的事件监听。此函数在Chart.js加载后才被调用。
*/
function setupChartEventListeners() {
const chartGroupFilter = document.getElementById('chartGroupFilter');
if (chartGroupFilter) {
chartGroupFilter.addEventListener('change', (e) => fetchAndRenderChart(e.target.value));
}
}
/**
* [重构] 一个纯粹的UI填充函数它只负责根据传入的数据渲染下拉菜单。
* @param {object} result - 从 /admin/keygroups API 返回的完整JSON对象。
*/
function populateSelectWithOptions(result) {
const groups = result?.data?.items || [];
const selectElements = ['chartGroupFilter', 'poolGroupFilter'];
selectElements.forEach(selectId => {
const selectElement = document.getElementById(selectId);
if (!selectElement) return;
const currentVal = selectElement.value;
selectElement.innerHTML = '<option value="">所有分组</option>';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name.length > 20 ? group.name.substring(0, 20) + '...' : group.name;
selectElement.appendChild(option);
});
selectElement.value = currentVal;
});
}
/**
* 更新所有统计卡片的数字。
* @param {object} data - 从 /admin/dashboard/overview API 返回的数据对象。
*/
function updateStatCards(data) {
const useAnimation = true; // 动画默认开启
if (!data) return;
const updateFn = useAnimation ? animateValue : (id, val) => {
const elem = document.getElementById(id);
if (elem) elem.textContent = (val || 0).toLocaleString();
};
if (data.key_count) {
updateFn('stat-total-keys', data.key_count.value || 0);
updateFn('stat-invalid-keys', data.key_count.sub_value || 0);
}
if (data.key_status_count) {
updateFn('stat-valid-keys', data.key_status_count.ACTIVE || 0);
updateFn('stat-cooldown-keys', data.key_status_count.COOLDOWN || 0);
if (apiCanvasInstance) {
apiCanvasInstance.updateData(data.key_status_count);
}
updateLegendCounts(data.key_status_count);
}
if (data.request_counts) {
updateFn('stat-calls-1m', data.request_counts["1m"] || 0);
updateFn('stat-calls-1h', data.request_counts["1h"] || 0);
updateFn('stat-calls-1d', data.request_counts["1d"] || 0);
updateFn('stat-calls-30d', data.request_counts["30d"] || 0);
}
}
/**
* 更新API状态图例下方的各项计数。
* @param {object} counts - 包含各状态计数的对象。
*/
function updateLegendCounts(counts) {
if (!counts) return;
['active', 'pending', 'cooldown', 'disabled', 'banned'].forEach(field => {
const elem = document.getElementById(`legend-${field}`);
if(elem) elem.textContent = (counts[field.toUpperCase()] || 0).toLocaleString();
});
}
/**
* 统一的刷新函数,供手动和自动刷新调用。
*/
async function refreshAllData() {
// 强制刷新核心UI数据
try {
const overviewResponse = await apiFetch('/admin/dashboard/overview', { noCache: true });
const overviewData = await overviewResponse.json();
updateStatCards(overviewData); // 使用无动画的方式更新,追求速度
} catch(e) { console.error("Failed to refresh overview", e); }
// 强制刷新图表数据
if (typeof Chart !== 'undefined' && historicalChartInstance) { // 仅当图表已加载时才刷新
fetchAndRenderChart(document.getElementById('chartGroupFilter')?.value);
}
}
/**
* 处理手动刷新按钮的点击事件。
* @param {HTMLElement} button - 被点击的刷新按钮。
*/
function refreshPage(button) {
if (!button || button.disabled) return;
button.disabled = true;
const icon = button.querySelector('i');
if (icon) icon.classList.add('fa-spin');
showNotification('正在刷新...', 'info', 1500);
refreshAllData().finally(() => {
setTimeout(() => {
button.disabled = false;
if (icon) icon.classList.remove('fa-spin');
}, 500);
});
}
/**
* 处理自动刷新开关的事件。
* @param {Event} event - change事件对象。
*/
function handleAutoRefreshToggle(event) {
const isEnabled = event.target.checked;
localStorage.setItem("autoRefreshEnabled", isEnabled);
if (isEnabled) {
showNotification("自动刷新已开启 (30秒)", "info", 2000);
refreshAllData(); // 立即执行一次
autoRefreshInterval = setInterval(refreshAllData, 30000);
} else {
if (autoRefreshInterval) {
showNotification("自动刷新已停止", "info", 2000);
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
}
/**
* 初始化自动刷新控件的状态从localStorage读取
*/
function initializeAutoRefreshControls() {
const toggle = document.getElementById("autoRefreshToggle");
if (!toggle) return;
const isEnabled = localStorage.getItem("autoRefreshEnabled") === "true";
if (isEnabled) {
toggle.checked = true;
handleAutoRefreshToggle({ target: toggle });
}
}
/**
* 初始化右上角下拉菜单的交互逻辑。
*/
function initializeDropdownMenu() {
const dropdownButton = document.getElementById('dropdownMenuButton');
const dropdownMenu = document.getElementById('dropdownMenu');
const dropdownToggle = dropdownButton ? dropdownButton.closest('.dropdown-toggle') : null;
if (!dropdownButton || !dropdownMenu || !dropdownToggle) return;
dropdownButton.addEventListener('click', (event) => {
event.stopPropagation();
dropdownMenu.classList.toggle('show');
});
document.addEventListener('click', (event) => {
if (dropdownMenu.classList.contains('show') && !dropdownToggle.contains(event.target)) {
dropdownMenu.classList.remove('show');
}
});
}
/**
* 显示一个全局浮动通知。
* @param {string} message - 通知内容。
* @param {'info'|'success'|'error'} [type='info'] - 通知类型。
* @param {number} [duration=3000] - 显示时长(毫秒)。
*/
function showNotification(message, type = 'info', duration = 3000) {
const container = document.body;
const styles = {
info: { icon: 'fa-info-circle', color: 'blue-500' },
success: { icon: 'fa-check-circle', color: 'green-500' },
error: { icon: 'fa-times-circle', color: 'red-500' }
};
const style = styles[type] || styles.info;
const notification = document.createElement('div');
notification.className = `fixed top-5 right-5 flex items-center bg-white text-gray-800 p-4 rounded-lg shadow-lg border-l-4 border-${style.color} z-[9999] animate-fade-in`;
notification.innerHTML = `<i class="fas ${style.icon} text-${style.color} text-xl mr-3"></i><span class="font-semibold">${message}</span>`;
container.appendChild(notification);
setTimeout(() => {
notification.classList.remove('animate-fade-in');
notification.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
setTimeout(() => notification.remove(), 500);
}, duration);
}
/**
* 为统计卡片的数字添加动画效果。
* @param {string} elementId - 目标元素ID。
* @param {number} endValue - 最终要显示的数字。
*/
function animateValue(elementId, endValue) {
const element = document.getElementById(elementId);
if (!element) return;
const startValue = parseInt(element.textContent.replace(/,/g, '') || '0');
if (startValue === endValue) return;
let startTime = null;
const duration = 1200;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * (endValue - startValue) + startValue);
element.textContent = currentValue.toLocaleString();
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
/**
* 初始化统计项的悬浮动画(放大效果)。
*/
function initStatItemAnimations() {
document.querySelectorAll(".stat-item").forEach(item => {
item.addEventListener("mouseenter", () => item.style.transform = "scale(1.05)");
item.addEventListener("mouseleave", () => item.style.transform = "");
});
}