440 lines
17 KiB
JavaScript
440 lines
17 KiB
JavaScript
|
||
// ========================================================================= //
|
||
// 全局变量 //
|
||
// ========================================================================= //
|
||
|
||
/** @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 = "");
|
||
});
|
||
}
|