1105 lines
40 KiB
JavaScript
1105 lines
40 KiB
JavaScript
// 全局变量
|
||
let domainDataCache = {
|
||
blocked: null,
|
||
resolved: null
|
||
};
|
||
let domainUpdateTimer = null;
|
||
const DOMAIN_UPDATE_INTERVAL = 5000; // 域名排行更新间隔,设为5秒,比统计数据更新慢一些
|
||
|
||
// 初始化仪表盘面板
|
||
function initDashboardPanel() {
|
||
// 初始化小型图表
|
||
if (typeof initMiniCharts === 'function') {
|
||
initMiniCharts();
|
||
}
|
||
// 加载统计数据
|
||
loadDashboardData();
|
||
// 启动实时更新
|
||
if (typeof startRealTimeUpdate === 'function') {
|
||
startRealTimeUpdate();
|
||
}
|
||
// 启动域名排行的独立更新
|
||
startDomainUpdate();
|
||
}
|
||
|
||
// 加载仪表盘数据
|
||
function loadDashboardData() {
|
||
// 加载统计卡片数据
|
||
updateStatCards();
|
||
|
||
// 首次加载时获取域名排行数据
|
||
if (!domainDataCache.blocked) {
|
||
loadTopBlockedDomains();
|
||
}
|
||
if (!domainDataCache.resolved) {
|
||
loadTopResolvedDomains();
|
||
}
|
||
}
|
||
|
||
// 启动域名排行的独立更新
|
||
function startDomainUpdate() {
|
||
if (domainUpdateTimer) {
|
||
clearInterval(domainUpdateTimer);
|
||
}
|
||
|
||
// 立即执行一次更新
|
||
updateDomainRankings();
|
||
|
||
// 设置定时器
|
||
domainUpdateTimer = setInterval(() => {
|
||
// 仅当当前面板是仪表盘时更新数据
|
||
if (document.getElementById('dashboard') && document.getElementById('dashboard').classList.contains('active')) {
|
||
updateDomainRankings();
|
||
}
|
||
}, DOMAIN_UPDATE_INTERVAL);
|
||
}
|
||
|
||
// 停止域名排行更新
|
||
function stopDomainUpdate() {
|
||
if (domainUpdateTimer) {
|
||
clearInterval(domainUpdateTimer);
|
||
domainUpdateTimer = null;
|
||
}
|
||
}
|
||
|
||
// 更新域名排行数据
|
||
function updateDomainRankings() {
|
||
// 使用Promise.all并行加载,提高效率
|
||
Promise.all([
|
||
loadTopBlockedDomains(true),
|
||
loadTopResolvedDomains(true)
|
||
]).catch(error => {
|
||
console.error('更新域名排行数据失败:', error);
|
||
});
|
||
}
|
||
|
||
// 更新统计卡片数据
|
||
function updateStatCards() {
|
||
// 获取所有统计数据
|
||
apiRequest('/stats')
|
||
.then(data => {
|
||
// 更新请求统计
|
||
if (data && data.dns) {
|
||
// 屏蔽请求
|
||
const blockedCount = data.dns.Blocked || data.dns.blocked || 0;
|
||
smoothUpdateStatCard('blocked-count', blockedCount);
|
||
|
||
// 允许请求
|
||
const allowedCount = data.dns.Allowed || data.dns.allowed || 0;
|
||
smoothUpdateStatCard('allowed-count', allowedCount);
|
||
|
||
// 错误请求
|
||
const errorCount = data.dns.Errors || data.dns.errors || 0;
|
||
smoothUpdateStatCard('error-count', errorCount);
|
||
|
||
// 总请求数
|
||
const totalCount = blockedCount + allowedCount + errorCount;
|
||
smoothUpdateStatCard('total-queries', totalCount);
|
||
|
||
// 更新数据历史记录和小型图表
|
||
if (typeof updateDataHistory === 'function') {
|
||
updateDataHistory('blocked', blockedCount);
|
||
updateDataHistory('query', totalCount);
|
||
}
|
||
|
||
// 更新小型图表
|
||
if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') {
|
||
updateMiniChart('blocked-chart', dataHistory.blocked);
|
||
updateMiniChart('query-chart', dataHistory.query);
|
||
}
|
||
} else {
|
||
// 处理其他可能的数据格式
|
||
const blockedValue = data && (data.Blocked !== undefined ? data.Blocked : (data.blocked !== undefined ? data.blocked : 0));
|
||
const allowedValue = data && (data.Allowed !== undefined ? data.Allowed : (data.allowed !== undefined ? data.allowed : 0));
|
||
const errorValue = data && (data.Errors !== undefined ? data.Errors : (data.errors !== undefined ? data.errors : 0));
|
||
smoothUpdateStatCard('blocked-count', blockedValue);
|
||
smoothUpdateStatCard('allowed-count', allowedValue);
|
||
smoothUpdateStatCard('error-count', errorValue);
|
||
const totalCount = blockedValue + allowedValue + errorValue;
|
||
smoothUpdateStatCard('total-queries', totalCount);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取统计数据失败:', error);
|
||
});
|
||
|
||
// 获取规则数
|
||
apiRequest('/shield')
|
||
.then(data => {
|
||
let rulesCount = 0;
|
||
|
||
// 增强的数据格式处理,确保能正确处理各种返回格式
|
||
if (Array.isArray(data)) {
|
||
rulesCount = data.length;
|
||
} else if (data && data.rules && Array.isArray(data.rules)) {
|
||
rulesCount = data.rules.length;
|
||
} else if (data && data.domainRules) {
|
||
// 处理可能的规则分类格式
|
||
let domainRulesCount = 0;
|
||
let regexRulesCount = 0;
|
||
|
||
if (Array.isArray(data.domainRules)) {
|
||
domainRulesCount = data.domainRules.length;
|
||
} else if (typeof data.domainRules === 'object') {
|
||
domainRulesCount = Object.keys(data.domainRules).length;
|
||
}
|
||
|
||
if (data.regexRules && Array.isArray(data.regexRules)) {
|
||
regexRulesCount = data.regexRules.length;
|
||
}
|
||
|
||
rulesCount = domainRulesCount + regexRulesCount;
|
||
}
|
||
|
||
// 确保至少显示0而不是--
|
||
smoothUpdateStatCard('rules-count', rulesCount);
|
||
|
||
// 更新数据历史记录和小型图表
|
||
if (typeof updateDataHistory === 'function') {
|
||
updateDataHistory('rules', rulesCount);
|
||
}
|
||
|
||
if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') {
|
||
updateMiniChart('rules-chart', dataHistory.rules);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取规则数失败:', error);
|
||
// 即使出错也要设置为0,避免显示--
|
||
smoothUpdateStatCard('rules-count', 0);
|
||
});
|
||
|
||
// 获取Hosts条目数量
|
||
apiRequest('/shield/hosts')
|
||
.then(data => {
|
||
let hostsCount = 0;
|
||
|
||
// 处理各种可能的数据格式
|
||
if (Array.isArray(data)) {
|
||
hostsCount = data.length;
|
||
} else if (data && data.hosts && Array.isArray(data.hosts)) {
|
||
hostsCount = data.hosts.length;
|
||
} else if (data && typeof data === 'object' && data !== null) {
|
||
// 如果是对象格式,计算键的数量
|
||
hostsCount = Object.keys(data).length;
|
||
}
|
||
|
||
// 确保至少显示0而不是--
|
||
smoothUpdateStatCard('hosts-count', hostsCount);
|
||
|
||
// 更新数据历史记录和小型图表
|
||
if (typeof updateDataHistory === 'function') {
|
||
updateDataHistory('hosts', hostsCount);
|
||
}
|
||
|
||
if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') {
|
||
updateMiniChart('hosts-chart', dataHistory.hosts);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取Hosts数量失败:', error);
|
||
// 即使出错也要设置为0,避免显示--
|
||
smoothUpdateStatCard('hosts-count', 0);
|
||
});
|
||
|
||
// 获取Hosts条目数
|
||
apiRequest('/shield/hosts')
|
||
.then(data => {
|
||
let hostsCount = 0;
|
||
if (Array.isArray(data)) {
|
||
hostsCount = data.length;
|
||
} else if (data && data.hosts && Array.isArray(data.hosts)) {
|
||
hostsCount = data.hosts.length;
|
||
}
|
||
|
||
smoothUpdateStatCard('hosts-count', hostsCount);
|
||
|
||
// 更新数据历史记录和小型图表
|
||
if (typeof updateDataHistory === 'function') {
|
||
updateDataHistory('hosts', hostsCount);
|
||
}
|
||
|
||
if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') {
|
||
updateMiniChart('hosts-chart', dataHistory.hosts);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取Hosts条目数失败:', error);
|
||
});
|
||
}
|
||
|
||
|
||
// 更新单个统计卡片
|
||
function updateStatCard(elementId, value) {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
|
||
// 格式化为可读数字
|
||
const formattedValue = formatNumber(value);
|
||
|
||
// 更新显示
|
||
element.textContent = formattedValue;
|
||
|
||
// 使用全局checkAndAnimate函数检测变化并添加光晕效果
|
||
if (typeof checkAndAnimate === 'function') {
|
||
checkAndAnimate(elementId, value);
|
||
}
|
||
}
|
||
|
||
// 平滑更新统计卡片(数字递增动画)
|
||
function smoothUpdateStatCard(elementId, newValue) {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
|
||
// 获取旧值
|
||
const oldValue = previousStats[elementId] || 0;
|
||
|
||
// 如果值相同,不更新
|
||
if (newValue === oldValue) return;
|
||
|
||
// 如果是初始值,直接更新
|
||
if (oldValue === 0 || oldValue === '--') {
|
||
updateStatCard(elementId, newValue);
|
||
return;
|
||
}
|
||
|
||
// 设置动画持续时间
|
||
const duration = 500; // 500ms
|
||
const startTime = performance.now();
|
||
|
||
// 动画函数
|
||
function animate(currentTime) {
|
||
const elapsedTime = currentTime - startTime;
|
||
const progress = Math.min(elapsedTime / duration, 1);
|
||
|
||
// 使用缓动函数
|
||
const easeOutQuad = 1 - (1 - progress) * (1 - progress);
|
||
|
||
// 计算当前值
|
||
const currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutQuad);
|
||
|
||
// 更新显示
|
||
element.textContent = formatNumber(currentValue);
|
||
|
||
// 继续动画
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
} else {
|
||
// 动画完成,设置最终值
|
||
element.textContent = formatNumber(newValue);
|
||
// 添加光晕效果
|
||
element.classList.add('update');
|
||
setTimeout(() => {
|
||
element.classList.remove('update');
|
||
}, 1000);
|
||
// 更新记录
|
||
previousStats[elementId] = newValue;
|
||
}
|
||
}
|
||
|
||
// 开始动画
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
// 加载24小时统计数据
|
||
function loadHourlyStats() {
|
||
apiRequest('/hourly-stats')
|
||
.then(data => {
|
||
// 检查数据是否变化,避免不必要的重绘
|
||
if (typeof previousChartData !== 'undefined' &&
|
||
JSON.stringify(previousChartData) === JSON.stringify(data)) {
|
||
return; // 数据未变化,无需更新图表
|
||
}
|
||
|
||
previousChartData = JSON.parse(JSON.stringify(data));
|
||
|
||
// 处理不同可能的数据格式
|
||
if (data) {
|
||
// 优先处理用户提供的实际数据格式 {data: [], labels: []}
|
||
if (data.labels && data.data && Array.isArray(data.labels) && Array.isArray(data.data)) {
|
||
// 确保labels和data数组长度一致
|
||
if (data.labels.length === data.data.length) {
|
||
// 假设data数组包含的是屏蔽请求数据,允许请求设为0
|
||
renderHourlyChart(data.labels, data.data, Array(data.data.length).fill(0));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 处理其他可能的数据格式
|
||
if (data.labels && data.blocked && data.allowed) {
|
||
// 完整数据格式:分别有屏蔽和允许的数据
|
||
renderHourlyChart(data.labels, data.blocked, data.allowed);
|
||
} else if (data.labels && data.data) {
|
||
// 简化数据格式:只有一组数据
|
||
renderHourlyChart(data.labels, data.data, Array(data.data.length).fill(0));
|
||
} else {
|
||
// 尝试直接使用数据对象的属性
|
||
const hours = [];
|
||
const blocked = [];
|
||
const allowed = [];
|
||
|
||
// 假设数据是按小时组织的对象
|
||
for (const key in data) {
|
||
if (data.hasOwnProperty(key)) {
|
||
hours.push(key);
|
||
// 尝试不同的数据结构访问方式
|
||
if (typeof data[key] === 'object' && data[key] !== null) {
|
||
blocked.push(data[key].Blocked || data[key].blocked || 0);
|
||
allowed.push(data[key].Allowed || data[key].allowed || 0);
|
||
} else {
|
||
blocked.push(data[key]);
|
||
allowed.push(0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只在有数据时渲染
|
||
if (hours.length > 0) {
|
||
renderHourlyChart(hours, blocked, allowed);
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取24小时统计失败:', error);
|
||
// 显示默认空数据,避免图表区域空白
|
||
const emptyHours = Array.from({length: 24}, (_, i) => `${i}:00`);
|
||
const emptyData = Array(24).fill(0);
|
||
renderHourlyChart(emptyHours, emptyData, emptyData);
|
||
});
|
||
}
|
||
|
||
// 渲染24小时统计图表 - 使用ECharts重新设计
|
||
function renderHourlyChart(hours, blocked, allowed) {
|
||
const chartContainer = document.getElementById('hourly-chart');
|
||
if (!chartContainer) return;
|
||
|
||
// 销毁现有ECharts实例
|
||
if (window.hourlyChart) {
|
||
window.hourlyChart.dispose();
|
||
}
|
||
|
||
// 创建ECharts实例
|
||
window.hourlyChart = echarts.init(chartContainer);
|
||
|
||
// 计算24小时内的最大请求数,为Y轴设置合适的上限
|
||
const maxRequests = Math.max(...blocked, ...allowed);
|
||
const yAxisMax = maxRequests > 0 ? Math.ceil(maxRequests * 1.2) : 10;
|
||
|
||
// 设置ECharts配置
|
||
const option = {
|
||
title: {
|
||
text: '24小时请求统计',
|
||
left: 'center',
|
||
textStyle: {
|
||
fontSize: 16,
|
||
fontWeight: 'normal'
|
||
}
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||
borderColor: '#ddd',
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: '#333'
|
||
},
|
||
formatter: function(params) {
|
||
let result = params[0].name + '<br/>';
|
||
params.forEach(param => {
|
||
result += param.marker + param.seriesName + ': ' + param.value + '<br/>';
|
||
});
|
||
return result;
|
||
}
|
||
},
|
||
legend: {
|
||
data: ['屏蔽请求', '允许请求'],
|
||
top: '10%',
|
||
textStyle: {
|
||
color: '#666'
|
||
}
|
||
},
|
||
grid: {
|
||
left: '3%',
|
||
right: '4%',
|
||
bottom: '10%',
|
||
top: '25%',
|
||
containLabel: true
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: hours,
|
||
axisLabel: {
|
||
color: '#666',
|
||
interval: 1, // 每隔一个小时显示一个标签,避免拥挤
|
||
rotate: 30 // 标签旋转30度,提高可读性
|
||
},
|
||
axisLine: {
|
||
lineStyle: {
|
||
color: '#ddd'
|
||
}
|
||
},
|
||
axisTick: {
|
||
show: false
|
||
}
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
min: 0,
|
||
max: yAxisMax,
|
||
axisLabel: {
|
||
color: '#666',
|
||
formatter: '{value}'
|
||
},
|
||
axisLine: {
|
||
lineStyle: {
|
||
color: '#ddd'
|
||
}
|
||
},
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: '#f0f0f0',
|
||
type: 'dashed'
|
||
}
|
||
}
|
||
},
|
||
series: [
|
||
{
|
||
name: '屏蔽请求',
|
||
type: 'line',
|
||
smooth: true, // 平滑曲线
|
||
symbol: 'circle', // 拐点形状
|
||
symbolSize: 6, // 拐点大小
|
||
data: blocked,
|
||
itemStyle: {
|
||
color: '#e74c3c'
|
||
},
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(231, 76, 60, 0.3)' },
|
||
{ offset: 1, color: 'rgba(231, 76, 60, 0.05)' }
|
||
])
|
||
},
|
||
emphasis: {
|
||
focus: 'series',
|
||
itemStyle: {
|
||
borderWidth: 2,
|
||
borderColor: '#fff',
|
||
shadowBlur: 10,
|
||
shadowColor: 'rgba(231, 76, 60, 0.5)'
|
||
}
|
||
},
|
||
animationDuration: 800,
|
||
animationEasing: 'cubicOut'
|
||
},
|
||
{
|
||
name: '允许请求',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 6,
|
||
data: allowed,
|
||
itemStyle: {
|
||
color: '#2ecc71'
|
||
},
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(46, 204, 113, 0.3)' },
|
||
{ offset: 1, color: 'rgba(46, 204, 113, 0.05)' }
|
||
])
|
||
},
|
||
emphasis: {
|
||
focus: 'series',
|
||
itemStyle: {
|
||
borderWidth: 2,
|
||
borderColor: '#fff',
|
||
shadowBlur: 10,
|
||
shadowColor: 'rgba(46, 204, 113, 0.5)'
|
||
}
|
||
},
|
||
animationDuration: 800,
|
||
animationEasing: 'cubicOut'
|
||
}
|
||
],
|
||
// 添加数据提示功能
|
||
toolbox: {
|
||
feature: {
|
||
dataZoom: {
|
||
yAxisIndex: 'none'
|
||
},
|
||
dataView: {
|
||
readOnly: false
|
||
},
|
||
magicType: {
|
||
type: ['line', 'bar']
|
||
},
|
||
restore: {},
|
||
saveAsImage: {}
|
||
},
|
||
top: '15%'
|
||
},
|
||
// 添加数据缩放功能
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
start: 0,
|
||
end: 100
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23.1h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z',
|
||
handleSize: '80%',
|
||
handleStyle: {
|
||
color: '#fff',
|
||
shadowBlur: 3,
|
||
shadowColor: 'rgba(0, 0, 0, 0.6)',
|
||
shadowOffsetX: 2,
|
||
shadowOffsetY: 2
|
||
}
|
||
}
|
||
]
|
||
};
|
||
|
||
// 应用配置项
|
||
window.hourlyChart.setOption(option);
|
||
|
||
// 添加窗口大小变化时的自适应
|
||
window.addEventListener('resize', function() {
|
||
if (window.hourlyChart) {
|
||
window.hourlyChart.resize();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 加载请求类型分布 - 注意:后端可能没有这个API,暂时注释掉
|
||
function loadRequestsDistribution() {
|
||
// 后端没有对应的API路由,暂时跳过
|
||
console.log('请求类型分布API暂不可用');
|
||
return Promise.resolve()
|
||
.then(data => {
|
||
// 检查数据是否变化,避免不必要的重绘
|
||
if (typeof previousFullData !== 'undefined' &&
|
||
JSON.stringify(previousFullData) === JSON.stringify(data)) {
|
||
return; // 数据未变化,无需更新图表
|
||
}
|
||
|
||
previousFullData = JSON.parse(JSON.stringify(data));
|
||
|
||
// 构造饼图所需的数据,支持多种数据格式
|
||
const labels = ['允许请求', '屏蔽请求', '错误请求'];
|
||
let requestData = [0, 0, 0]; // 默认值
|
||
|
||
if (data) {
|
||
// 尝试多种可能的数据结构
|
||
if (data.dns) {
|
||
// 主要数据结构
|
||
requestData = [
|
||
data.dns.Allowed || data.dns.allowed || 0,
|
||
data.dns.Blocked || data.dns.blocked || 0,
|
||
data.dns.Errors || data.dns.errors || 0
|
||
];
|
||
} else if (data.Allowed !== undefined || data.Blocked !== undefined) {
|
||
// 直接在顶级对象中
|
||
requestData = [
|
||
data.Allowed || data.allowed || 0,
|
||
data.Blocked || data.blocked || 0,
|
||
data.Errors || data.errors || 0
|
||
];
|
||
} else if (data.requests) {
|
||
// 可能在requests属性中
|
||
requestData = [
|
||
data.requests.Allowed || data.requests.allowed || 0,
|
||
data.requests.Blocked || data.requests.blocked || 0,
|
||
data.requests.Errors || data.requests.errors || 0
|
||
];
|
||
}
|
||
}
|
||
|
||
// 渲染图表,即使数据全为0也渲染,避免空白
|
||
renderRequestsPieChart(labels, requestData);
|
||
})
|
||
.catch(error => {
|
||
console.error('获取请求类型分布失败:', error);
|
||
// 显示默认空数据的图表
|
||
const labels = ['允许请求', '屏蔽请求', '错误请求'];
|
||
const defaultData = [0, 0, 0];
|
||
renderRequestsPieChart(labels, defaultData);
|
||
});
|
||
}
|
||
|
||
// 渲染请求类型饼图
|
||
function renderRequestsPieChart(labels, data) {
|
||
const ctx = document.getElementById('requests-pie-chart');
|
||
if (!ctx) return;
|
||
|
||
// 销毁现有图表
|
||
if (window.requestsPieChart) {
|
||
window.requestsPieChart.destroy();
|
||
}
|
||
|
||
// 创建新图表
|
||
window.requestsPieChart = new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
data: data,
|
||
backgroundColor: [
|
||
'#2ecc71', // 允许
|
||
'#e74c3c', // 屏蔽
|
||
'#f39c12', // 错误
|
||
'#9b59b6' // 其他
|
||
],
|
||
borderWidth: 2,
|
||
borderColor: '#fff'
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'right',
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(context) {
|
||
const label = context.label || '';
|
||
const value = context.raw || 0;
|
||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||
const percentage = ((value / total) * 100).toFixed(1);
|
||
return `${label}: ${value} (${percentage}%)`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
cutout: '60%',
|
||
animation: {
|
||
duration: 500 // 快速动画,提升实时更新体验
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 辅助函数:深度比较两个对象是否相等
|
||
function isEqual(obj1, obj2) {
|
||
// 处理null或undefined情况
|
||
if (obj1 === obj2) return true;
|
||
if (obj1 == null || obj2 == null) return false;
|
||
|
||
// 确保都是数组
|
||
if (!Array.isArray(obj1) || !Array.isArray(obj2)) return false;
|
||
if (obj1.length !== obj2.length) return false;
|
||
|
||
// 比较数组中每个元素
|
||
for (let i = 0; i < obj1.length; i++) {
|
||
const a = obj1[i];
|
||
const b = obj2[i];
|
||
|
||
// 比较域名和计数
|
||
if (a.domain !== b.domain || a.count !== b.count) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 加载最常屏蔽的域名
|
||
function loadTopBlockedDomains(isUpdate = false) {
|
||
// 首先获取表格元素并显示加载状态
|
||
const topBlockedTable = document.getElementById('top-blocked-table');
|
||
const tbody = topBlockedTable ? topBlockedTable.querySelector('tbody') : null;
|
||
|
||
// 非更新操作时显示加载状态
|
||
if (tbody && !isUpdate) {
|
||
// 显示加载中状态
|
||
tbody.innerHTML = `<td colspan="100%" style="color: #7f8c8d; font-style: italic;">加载中...</td>`;
|
||
}
|
||
|
||
return apiRequest('/top-blocked')
|
||
.then(data => {
|
||
// 处理多种可能的数据格式,特别优化对用户提供格式的支持
|
||
let processedData = [];
|
||
|
||
if (Array.isArray(data)) {
|
||
// 数组格式:直接使用,并过滤出有效的域名数据
|
||
processedData = data.filter(item => item && (item.domain || item.name || item.Domain || item.Name) && (item.count !== undefined || item.Count !== undefined || item.hits !== undefined || item.Hits !== undefined));
|
||
} else if (data && data.domains && Array.isArray(data.domains)) {
|
||
// 嵌套在domains属性中
|
||
processedData = data.domains;
|
||
} else if (data && typeof data === 'object') {
|
||
// 对象格式:转换为数组
|
||
processedData = Object.keys(data).map(key => ({
|
||
domain: key,
|
||
count: data[key]
|
||
}));
|
||
}
|
||
|
||
// 计算最大值用于百分比计算
|
||
if (processedData.length > 0) {
|
||
const maxCount = Math.max(...processedData.map(item => {
|
||
return item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
}));
|
||
// 为每个项目添加百分比
|
||
processedData.forEach(item => {
|
||
const count = item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
item.percentage = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0;
|
||
});
|
||
}
|
||
|
||
// 数据变化检测
|
||
const hasDataChanged = !isEqual(domainDataCache.blocked, processedData);
|
||
|
||
// 只在数据发生变化或不是更新操作时重新渲染
|
||
if (hasDataChanged || !isUpdate) {
|
||
// 更新缓存
|
||
domainDataCache.blocked = JSON.parse(JSON.stringify(processedData));
|
||
// 渲染最常屏蔽的域名表格
|
||
smoothRenderTable('top-blocked-table', processedData, renderDomainRow);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取最常屏蔽域名失败:', error);
|
||
// 显示默认空数据而不是错误消息,保持界面一致性
|
||
const tbody = document.getElementById('top-blocked-table').querySelector('tbody');
|
||
if (tbody) {
|
||
showEmpty(tbody, '获取数据失败');
|
||
}
|
||
|
||
// 使用全局通知功能
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('danger', '获取最常屏蔽域名失败');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 加载最常解析的域名
|
||
function loadTopResolvedDomains(isUpdate = false) {
|
||
// 首先获取表格元素
|
||
const topResolvedTable = document.getElementById('top-resolved-table');
|
||
const tbody = topResolvedTable ? topResolvedTable.querySelector('tbody') : null;
|
||
|
||
// 非更新操作时显示加载状态
|
||
if (tbody && !isUpdate) {
|
||
// 显示加载中状态
|
||
tbody.innerHTML = `<td colspan="100%" style="color: #7f8c8d; font-style: italic;">加载中...</td>`;
|
||
}
|
||
|
||
return apiRequest('/top-resolved')
|
||
.then(data => {
|
||
// 处理多种可能的数据格式
|
||
let processedData = [];
|
||
|
||
if (Array.isArray(data)) {
|
||
// 数组格式:直接使用
|
||
processedData = data;
|
||
} else if (data && data.domains && Array.isArray(data.domains)) {
|
||
// 嵌套在domains属性中
|
||
processedData = data.domains;
|
||
} else if (data && typeof data === 'object') {
|
||
// 对象格式:转换为数组
|
||
processedData = Object.keys(data).map(key => ({
|
||
domain: key,
|
||
count: data[key]
|
||
}));
|
||
}
|
||
|
||
// 计算最大值用于百分比计算
|
||
if (processedData.length > 0) {
|
||
const maxCount = Math.max(...processedData.map(item => {
|
||
return item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
}));
|
||
// 为每个项目添加百分比
|
||
processedData.forEach(item => {
|
||
const count = item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
item.percentage = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0;
|
||
});
|
||
}
|
||
|
||
// 数据变化检测
|
||
const hasDataChanged = !isEqual(domainDataCache.resolved, processedData);
|
||
|
||
// 只在数据发生变化或不是更新操作时重新渲染
|
||
if (hasDataChanged || !isUpdate) {
|
||
// 更新缓存
|
||
domainDataCache.resolved = JSON.parse(JSON.stringify(processedData));
|
||
// 渲染最常解析的域名表格
|
||
smoothRenderTable('top-resolved-table', processedData, renderDomainRow);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取最常解析域名失败:', error);
|
||
// 显示默认空数据而不是错误消息,保持界面一致性
|
||
const tbody = document.getElementById('top-resolved-table').querySelector('tbody');
|
||
if (tbody) {
|
||
showEmpty(tbody, '暂无解析记录');
|
||
}
|
||
|
||
// 使用全局通知功能
|
||
if (typeof showNotification === 'function') {
|
||
showNotification('danger', '获取最常解析域名失败');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 渲染域名行
|
||
function renderDomainRow(item, index) {
|
||
if (!item) return null;
|
||
|
||
// 支持不同的字段名和格式
|
||
const domainName = item.domain || item.name || item.Domain || item.Name || '未知域名';
|
||
const count = item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
const percentage = item.percentage || 0;
|
||
|
||
const row = document.createElement('tr');
|
||
row.className = 'fade-in'; // 添加淡入动画类
|
||
row.dataset.domain = domainName;
|
||
row.dataset.count = count;
|
||
row.dataset.percentage = percentage;
|
||
|
||
// 为不同类型的排行使用不同的进度条颜色
|
||
let barColor = '#3498db'; // 默认蓝色
|
||
if (item.domain && item.domain.includes('microsoft.com')) {
|
||
barColor = '#2ecc71'; // 绿色
|
||
} else if (item.domain && item.domain.includes('tencent.com')) {
|
||
barColor = '#e74c3c'; // 红色
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td>${domainName}</td>
|
||
<td class="count-cell" style="position: relative; padding-right: 80px;">
|
||
<div class="count-number">${formatNumber(count)}</div>
|
||
<div class="percentage-text">${percentage}%</div>
|
||
<div class="percentage-bar-container">
|
||
<div class="percentage-bar" style="width: ${percentage}%; background-color: ${barColor};"></div>
|
||
</div>
|
||
</td>
|
||
`;
|
||
|
||
// 设置动画延迟,创建级联效果
|
||
row.style.animationDelay = `${index * 50}ms`;
|
||
|
||
return row;
|
||
}
|
||
|
||
// 平滑渲染表格数据
|
||
function smoothRenderTable(tableId, newData, rowRenderer) {
|
||
const table = document.getElementById(tableId);
|
||
const tbody = table ? table.querySelector('tbody') : null;
|
||
if (!tbody) return;
|
||
|
||
// 添加过渡类,用于CSS动画支持
|
||
tbody.classList.add('table-transition');
|
||
|
||
if (!newData || newData.length === 0) {
|
||
showEmpty(tbody, '暂无数据记录');
|
||
// 移除过渡类
|
||
setTimeout(() => tbody.classList.remove('table-transition'), 300);
|
||
return;
|
||
}
|
||
|
||
// 创建映射以提高查找效率
|
||
const oldRows = Array.from(tbody.querySelectorAll('tr'));
|
||
const rowMap = new Map();
|
||
|
||
oldRows.forEach(row => {
|
||
if (!row.querySelector('td:first-child')) return;
|
||
const key = row.dataset.domain || row.querySelector('td:first-child').textContent;
|
||
rowMap.set(key, row);
|
||
});
|
||
|
||
// 准备新的数据行
|
||
const newRows = [];
|
||
const updatedRows = new Set();
|
||
|
||
// 处理每一条新数据
|
||
newData.forEach((item, index) => {
|
||
const key = item.domain || item.name || item.Domain || item.Name || '未知域名';
|
||
|
||
if (rowMap.has(key)) {
|
||
// 数据项已存在,更新它
|
||
const existingRow = rowMap.get(key);
|
||
const oldCount = parseInt(existingRow.dataset.count) || 0;
|
||
const count = item.count !== undefined ? item.count :
|
||
(item.Count !== undefined ? item.Count :
|
||
(item.hits !== undefined ? item.hits :
|
||
(item.Hits !== undefined ? item.Hits : 0)));
|
||
|
||
// 更新数据属性
|
||
existingRow.dataset.count = count;
|
||
|
||
// 添加高亮效果,用于CSS过渡
|
||
existingRow.classList.add('table-row-highlight');
|
||
setTimeout(() => {
|
||
existingRow.classList.remove('table-row-highlight');
|
||
}, 1000);
|
||
|
||
// 如果计数变化,应用平滑更新
|
||
if (oldCount !== count) {
|
||
const countCell = existingRow.querySelector('.count-cell');
|
||
if (countCell) {
|
||
smoothUpdateNumber(countCell, oldCount, count);
|
||
}
|
||
}
|
||
|
||
// 更新位置
|
||
existingRow.style.animationDelay = `${index * 50}ms`;
|
||
newRows.push(existingRow);
|
||
updatedRows.add(key);
|
||
} else {
|
||
// 新数据项,创建新行
|
||
const newRow = rowRenderer(item, index);
|
||
if (newRow) {
|
||
// 添加淡入动画类
|
||
newRow.classList.add('table-row-fade-in');
|
||
// 先设置透明度为0,避免在错误位置闪烁
|
||
newRow.style.opacity = '0';
|
||
newRows.push(newRow);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 移除不再存在的数据行
|
||
oldRows.forEach(row => {
|
||
if (!row.querySelector('td:first-child')) return;
|
||
const key = row.dataset.domain || row.querySelector('td:first-child').textContent;
|
||
if (!updatedRows.has(key)) {
|
||
// 添加淡出动画
|
||
row.classList.add('table-row-fade-out');
|
||
setTimeout(() => {
|
||
if (row.parentNode === tbody) {
|
||
tbody.removeChild(row);
|
||
}
|
||
}, 300);
|
||
}
|
||
});
|
||
|
||
// 批量更新表格内容,减少重排
|
||
requestAnimationFrame(() => {
|
||
// 保留未移除的行并按新顺序插入
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
newRows.forEach(row => {
|
||
// 如果是新行,添加到文档片段
|
||
if (!row.parentNode || row.parentNode !== tbody) {
|
||
fragment.appendChild(row);
|
||
}
|
||
// 如果是已有行,移除它以便按新顺序重新插入
|
||
else if (tbody.contains(row)) {
|
||
tbody.removeChild(row);
|
||
fragment.appendChild(row);
|
||
}
|
||
});
|
||
|
||
// 将文档片段添加到表格
|
||
tbody.appendChild(fragment);
|
||
|
||
// 触发动画
|
||
setTimeout(() => {
|
||
newRows.forEach(row => {
|
||
row.style.opacity = '1';
|
||
});
|
||
|
||
// 移除过渡类和动画类
|
||
setTimeout(() => {
|
||
tbody.querySelectorAll('.table-row-fade-in').forEach(row => {
|
||
row.classList.remove('table-row-fade-in');
|
||
});
|
||
tbody.classList.remove('table-transition');
|
||
}, 300);
|
||
}, 10);
|
||
|
||
// 初始化表格排序
|
||
if (typeof initTableSort === 'function') {
|
||
initTableSort(tableId);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 平滑更新数字
|
||
function smoothUpdateNumber(element, oldValue, newValue) {
|
||
// 如果值相同,不更新
|
||
if (oldValue === newValue || !element) return;
|
||
|
||
// 根据数值差动态调整持续时间
|
||
const valueDiff = Math.abs(newValue - oldValue);
|
||
const baseDuration = 400;
|
||
const maxDuration = 1000;
|
||
// 数值变化越大,动画时间越长,但不超过最大值
|
||
const duration = Math.min(baseDuration + Math.log10(valueDiff + 1) * 200, maxDuration);
|
||
|
||
const startTime = performance.now();
|
||
|
||
function animate(currentTime) {
|
||
const elapsedTime = currentTime - startTime;
|
||
const progress = Math.min(elapsedTime / duration, 1);
|
||
|
||
// 使用easeOutQuart缓动函数,使动画更自然
|
||
let easeOutProgress;
|
||
if (progress < 1) {
|
||
// 四阶缓动函数:easeOutQuart
|
||
easeOutProgress = 1 - Math.pow(1 - progress, 4);
|
||
} else {
|
||
easeOutProgress = 1;
|
||
}
|
||
|
||
// 根据不同的数值范围使用不同的插值策略
|
||
let currentValue;
|
||
if (valueDiff < 10) {
|
||
// 小数值变化,使用线性插值
|
||
currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutProgress);
|
||
} else if (valueDiff < 100) {
|
||
// 中等数值变化,使用四舍五入
|
||
currentValue = Math.round(oldValue + (newValue - oldValue) * easeOutProgress);
|
||
} else {
|
||
// 大数值变化,使用更平滑的插值
|
||
currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutProgress);
|
||
}
|
||
|
||
// 更新显示
|
||
element.textContent = formatNumber(currentValue);
|
||
|
||
// 添加微小的缩放动画效果
|
||
const scaleFactor = 1 + 0.05 * Math.sin(progress * Math.PI);
|
||
element.style.transform = `scale(${scaleFactor})`;
|
||
|
||
// 继续动画
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
} else {
|
||
// 动画完成
|
||
element.textContent = formatNumber(newValue);
|
||
// 重置缩放
|
||
element.style.transform = 'scale(1)';
|
||
|
||
// 触发最终的高亮效果
|
||
element.classList.add('number-update-complete');
|
||
setTimeout(() => {
|
||
element.classList.remove('number-update-complete');
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// 重置元素样式
|
||
element.style.transform = 'scale(1)';
|
||
// 开始动画
|
||
requestAnimationFrame(animate);
|
||
} |