590 lines
18 KiB
JavaScript
590 lines
18 KiB
JavaScript
// 仪表板JavaScript
|
||
|
||
class Dashboard {
|
||
constructor() {
|
||
this.charts = {};
|
||
this.refreshInterval = null;
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
console.log('初始化仪表板...');
|
||
|
||
// 初始化图表
|
||
this.initCharts();
|
||
|
||
// 加载初始数据
|
||
await this.loadDashboardData();
|
||
|
||
// 启动自动刷新
|
||
this.startAutoRefresh();
|
||
|
||
// 更新时间显示
|
||
this.updateCurrentTime();
|
||
setInterval(() => this.updateCurrentTime(), 1000);
|
||
|
||
console.log('仪表板初始化完成');
|
||
}
|
||
|
||
// 初始化图表
|
||
initCharts() {
|
||
// 代理趋势图
|
||
const ctx = document.getElementById('proxyTrendChart');
|
||
if (ctx) {
|
||
this.charts.proxyTrend = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: [],
|
||
datasets: [{
|
||
label: '总代理数',
|
||
data: [],
|
||
borderColor: '#007bff',
|
||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||
tension: 0.4,
|
||
fill: true
|
||
}, {
|
||
label: '可用代理',
|
||
data: [],
|
||
borderColor: '#28a745',
|
||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||
tension: 0.4,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'top',
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false,
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
precision: 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 加载仪表板数据
|
||
async loadDashboardData() {
|
||
try {
|
||
// 显示加载状态
|
||
this.showLoading(true);
|
||
|
||
// 获取统计数据
|
||
const statsResponse = await fetch('/api/dashboard/stats');
|
||
const statsData = await statsResponse.json();
|
||
|
||
if (statsData.success) {
|
||
this.updateStatistics(statsData.data);
|
||
this.updateCharts(statsData.data.charts);
|
||
this.updateRecentHistory(statsData.data.latest);
|
||
}
|
||
|
||
// 获取实时状态
|
||
const statusResponse = await fetch('/api/dashboard/status');
|
||
const statusData = await statusResponse.json();
|
||
|
||
if (statusData.success) {
|
||
this.updateSystemInfo(statusData.data);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('加载仪表板数据失败:', error);
|
||
this.showAlert('加载数据失败: ' + error.message, 'danger');
|
||
} finally {
|
||
this.showLoading(false);
|
||
}
|
||
}
|
||
|
||
// 更新统计信息
|
||
updateStatistics(data) {
|
||
document.getElementById('totalProxies').textContent = data.proxies.total || 0;
|
||
document.getElementById('validProxies').textContent = data.proxies.valid || 0;
|
||
document.getElementById('invalidProxies').textContent = data.proxies.invalid || 0;
|
||
document.getElementById('validRate').textContent = data.proxies.validRate || '0%';
|
||
}
|
||
|
||
// 更新图表
|
||
updateCharts(chartData) {
|
||
// 更新代理趋势图
|
||
if (this.charts.proxyTrend && chartData.daily_proxies) {
|
||
const labels = chartData.daily_proxies.map(item =>
|
||
new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||
);
|
||
|
||
this.charts.proxyTrend.data.labels = labels;
|
||
this.charts.proxyTrend.data.datasets[0].data = chartData.daily_proxies.map(item => item.total_added);
|
||
this.charts.proxyTrend.data.datasets[1].data = chartData.daily_proxies.map(item => item.valid_added);
|
||
this.charts.proxyTrend.update();
|
||
}
|
||
}
|
||
|
||
// 更新最近历史记录
|
||
updateRecentHistory(latest) {
|
||
// 更新抓取历史
|
||
if (latest.scrape && latest.scrape.length > 0) {
|
||
const scrapeHtml = this.generateHistoryTable(latest.scrape, 'scrape');
|
||
document.getElementById('recentScrapeHistory').innerHTML = scrapeHtml;
|
||
} else {
|
||
document.getElementById('recentScrapeHistory').innerHTML = this.generateEmptyState('暂无抓取历史');
|
||
}
|
||
|
||
// 更新验证历史
|
||
if (latest.validation && latest.validation.length > 0) {
|
||
const validationHtml = this.generateHistoryTable(latest.validation, 'validation');
|
||
document.getElementById('recentValidationHistory').innerHTML = validationHtml;
|
||
} else {
|
||
document.getElementById('recentValidationHistory').innerHTML = this.generateEmptyState('暂无验证历史');
|
||
}
|
||
}
|
||
|
||
// 生成历史记录表格
|
||
generateHistoryTable(history, type) {
|
||
if (!history || history.length === 0) {
|
||
return this.generateEmptyState('暂无历史记录');
|
||
}
|
||
|
||
let html = `
|
||
<div class="table-responsive">
|
||
<table class="table table-sm history-table">
|
||
<thead>
|
||
<tr>
|
||
<th>任务名称</th>
|
||
<th>状态</th>
|
||
<th>开始时间</th>
|
||
<th>执行时长</th>
|
||
<th>结果</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
history.forEach(item => {
|
||
const statusClass = this.getStatusClass(item.status);
|
||
const statusIcon = this.getStatusIcon(item.status);
|
||
const duration = item.duration ? this.formatDuration(item.duration) : '-';
|
||
const result = this.getResultSummary(item.details, type);
|
||
|
||
html += `
|
||
<tr>
|
||
<td class="task-name">${item.task_name}</td>
|
||
<td><span class="badge ${statusClass}">${statusIcon} ${this.getStatusText(item.status)}</span></td>
|
||
<td>${this.formatDateTime(item.start_time)}</td>
|
||
<td class="duration">${duration}</td>
|
||
<td>${result}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
// 生成空状态
|
||
generateEmptyState(message) {
|
||
return `
|
||
<div class="empty-state">
|
||
<i class="bi bi-inbox"></i>
|
||
<h6>${message}</h6>
|
||
<small class="text-muted">等待任务执行...</small>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 更新系统信息
|
||
updateSystemInfo(data) {
|
||
// 更新运行时间
|
||
document.getElementById('systemUptime').textContent = this.formatUptime(data.uptime);
|
||
|
||
// 更新内存使用
|
||
const memoryHtml = `
|
||
<div>已使用: ${data.memory.heapUsed}MB / ${data.memory.heapTotal}MB</div>
|
||
<div class="memory-bar">
|
||
<div class="memory-bar-fill" style="width: ${(data.memory.heapUsed / data.memory.heapTotal * 100)}%"></div>
|
||
</div>
|
||
`;
|
||
document.getElementById('memoryUsage').innerHTML = memoryHtml;
|
||
|
||
// 更新今日任务
|
||
const todayScrape = data.today_tasks.scrape;
|
||
const todayValidation = data.today_tasks.validation;
|
||
|
||
document.getElementById('todayScrape').innerHTML = `
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<span>成功: ${todayScrape.success}</span>
|
||
<span class="badge bg-success">${todayScrape.success_rate}%</span>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('todayValidation').innerHTML = `
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<span>成功: ${todayValidation.success}</span>
|
||
<span class="badge bg-success">${todayValidation.success_rate}%</span>
|
||
</div>
|
||
`;
|
||
|
||
// 更新下次执行时间
|
||
if (data.next_runs) {
|
||
document.getElementById('nextScrape').textContent = this.formatTime(data.next_runs.scrape);
|
||
document.getElementById('nextValidation').textContent = this.formatTime(data.next_runs.validation);
|
||
}
|
||
}
|
||
|
||
// 开始抓取任务
|
||
async startScrape() {
|
||
try {
|
||
this.showLoading(true, '正在启动抓取任务...');
|
||
|
||
const response = await fetch('/api/dashboard/actions/scrape', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ pages: 5 })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
this.showAlert('抓取任务已启动', 'success');
|
||
// 延迟刷新数据
|
||
setTimeout(() => this.loadDashboardData(), 2000);
|
||
} else {
|
||
this.showAlert('启动抓取任务失败: ' + result.error, 'danger');
|
||
}
|
||
} catch (error) {
|
||
console.error('启动抓取任务失败:', error);
|
||
this.showAlert('启动抓取任务失败: ' + error.message, 'danger');
|
||
} finally {
|
||
this.showLoading(false);
|
||
}
|
||
}
|
||
|
||
// 开始验证任务
|
||
async startValidate() {
|
||
try {
|
||
this.showLoading(true, '正在启动验证任务...');
|
||
|
||
const response = await fetch('/api/dashboard/actions/validate', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ limit: 50 })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
this.showAlert('验证任务已启动', 'success');
|
||
// 延迟刷新数据
|
||
setTimeout(() => this.loadDashboardData(), 2000);
|
||
} else {
|
||
this.showAlert('启动验证任务失败: ' + result.error, 'danger');
|
||
}
|
||
} catch (error) {
|
||
console.error('启动验证任务失败:', error);
|
||
this.showAlert('启动验证任务失败: ' + error.message, 'danger');
|
||
} finally {
|
||
this.showLoading(false);
|
||
}
|
||
}
|
||
|
||
// 清理无效代理
|
||
async cleanupInvalid() {
|
||
if (!confirm('确定要清理所有无效代理吗?此操作不可恢复。')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/proxies/cleanup', {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
this.showAlert(result.message, 'success');
|
||
await this.loadDashboardData();
|
||
} else {
|
||
this.showAlert('清理失败: ' + result.error, 'danger');
|
||
}
|
||
} catch (error) {
|
||
console.error('清理无效代理失败:', error);
|
||
this.showAlert('清理无效代理失败: ' + error.message, 'danger');
|
||
}
|
||
}
|
||
|
||
// 导出代理数据
|
||
async exportProxies() {
|
||
try {
|
||
this.showLoading(true, '正在导出数据...');
|
||
|
||
const response = await fetch('/api/proxies?limit=1000');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const csv = this.convertToCSV(result.data);
|
||
this.downloadCSV(csv, `proxies_${new Date().toISOString().slice(0, 10)}.csv`);
|
||
this.showAlert('数据导出成功', 'success');
|
||
} else {
|
||
this.showAlert('导出数据失败: ' + result.error, 'danger');
|
||
}
|
||
} catch (error) {
|
||
console.error('导出数据失败:', error);
|
||
this.showAlert('导出数据失败: ' + error.message, 'danger');
|
||
} finally {
|
||
this.showLoading(false);
|
||
}
|
||
}
|
||
|
||
// 工具函数
|
||
|
||
getStatusClass(status) {
|
||
const statusClasses = {
|
||
'success': 'bg-success',
|
||
'failed': 'bg-danger',
|
||
'running': 'bg-warning',
|
||
'pending': 'bg-secondary'
|
||
};
|
||
return statusClasses[status] || 'bg-secondary';
|
||
}
|
||
|
||
getStatusIcon(status) {
|
||
const statusIcons = {
|
||
'success': '✓',
|
||
'failed': '✗',
|
||
'running': '⏳',
|
||
'pending': '⏸'
|
||
};
|
||
return statusIcons[status] || '?';
|
||
}
|
||
|
||
getStatusText(status) {
|
||
const statusTexts = {
|
||
'success': '成功',
|
||
'failed': '失败',
|
||
'running': '运行中',
|
||
'pending': '等待中'
|
||
};
|
||
return statusTexts[status] || status;
|
||
}
|
||
|
||
getResultSummary(details, type) {
|
||
if (!details) return '-';
|
||
|
||
if (type === 'scrape') {
|
||
return `抓取 ${details.scraped || 0} 个,可用 ${details.valid || 0} 个`;
|
||
} else if (type === 'validation') {
|
||
return `验证 ${details.validated || 0} 个,有效 ${details.valid || 0} 个`;
|
||
}
|
||
|
||
return '-';
|
||
}
|
||
|
||
formatDateTime(dateString) {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleString('zh-CN', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
formatTime(dateString) {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
formatDuration(ms) {
|
||
if (!ms) return '-';
|
||
|
||
if (ms < 1000) {
|
||
return ms + 'ms';
|
||
} else if (ms < 60000) {
|
||
return (ms / 1000).toFixed(1) + 's';
|
||
} else {
|
||
return (ms / 60000).toFixed(1) + 'min';
|
||
}
|
||
}
|
||
|
||
formatUptime(seconds) {
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
return `${hours}h ${minutes}m`;
|
||
}
|
||
|
||
updateCurrentTime() {
|
||
const now = new Date();
|
||
const timeString = now.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
const timeElement = document.getElementById('currentTime');
|
||
if (timeElement) {
|
||
timeElement.textContent = timeString;
|
||
}
|
||
}
|
||
|
||
showLoading(show, message = '加载中...') {
|
||
// 实现加载状态显示
|
||
const loadingElements = document.querySelectorAll('.spinner-border');
|
||
loadingElements.forEach(element => {
|
||
element.style.display = show ? 'inline-block' : 'none';
|
||
});
|
||
}
|
||
|
||
showAlert(message, type = 'info') {
|
||
const alertContainer = document.getElementById('alertContainer');
|
||
if (!alertContainer) return;
|
||
|
||
const alertId = 'alert-' + Date.now();
|
||
const alertHtml = `
|
||
<div class="alert alert-${type} alert-dismissible fade show" role="alert" id="${alertId}">
|
||
<i class="bi bi-${this.getAlertIcon(type)}"></i>
|
||
${message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
`;
|
||
|
||
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
|
||
|
||
// 自动移除提示
|
||
setTimeout(() => {
|
||
const alertElement = document.getElementById(alertId);
|
||
if (alertElement) {
|
||
alertElement.remove();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
getAlertIcon(type) {
|
||
const icons = {
|
||
'success': 'check-circle-fill',
|
||
'danger': 'exclamation-triangle-fill',
|
||
'warning': 'exclamation-triangle-fill',
|
||
'info': 'info-circle-fill'
|
||
};
|
||
return icons[type] || 'info-circle-fill';
|
||
}
|
||
|
||
convertToCSV(data) {
|
||
if (!data || data.length === 0) return '';
|
||
|
||
const headers = ['IP', '端口', '位置', '响应时间', '是否可用', '创建时间'];
|
||
const csvRows = [headers.join(',')];
|
||
|
||
data.forEach(proxy => {
|
||
const row = [
|
||
proxy.ip,
|
||
proxy.port,
|
||
`"${proxy.location || ''}"`,
|
||
proxy.response_time || '',
|
||
proxy.is_valid ? '是' : '否',
|
||
proxy.created_at
|
||
];
|
||
csvRows.push(row.join(','));
|
||
});
|
||
|
||
return csvRows.join('\n');
|
||
}
|
||
|
||
downloadCSV(csv, filename) {
|
||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
link.setAttribute('href', url);
|
||
link.setAttribute('download', filename);
|
||
link.style.visibility = 'hidden';
|
||
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
|
||
startAutoRefresh() {
|
||
// 每30秒刷新一次数据
|
||
this.refreshInterval = setInterval(() => {
|
||
this.loadDashboardData();
|
||
}, 30000);
|
||
}
|
||
|
||
stopAutoRefresh() {
|
||
if (this.refreshInterval) {
|
||
clearInterval(this.refreshInterval);
|
||
this.refreshInterval = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 全局函数(供HTML调用)
|
||
let dashboard;
|
||
|
||
async function refreshData() {
|
||
if (dashboard) {
|
||
await dashboard.loadDashboardData();
|
||
dashboard.showAlert('数据已刷新', 'info');
|
||
}
|
||
}
|
||
|
||
async function startScrape() {
|
||
if (dashboard) {
|
||
await dashboard.startScrape();
|
||
}
|
||
}
|
||
|
||
async function startValidate() {
|
||
if (dashboard) {
|
||
await dashboard.startValidate();
|
||
}
|
||
}
|
||
|
||
async function cleanupInvalid() {
|
||
if (dashboard) {
|
||
await dashboard.cleanupInvalid();
|
||
}
|
||
}
|
||
|
||
async function exportProxies() {
|
||
if (dashboard) {
|
||
await dashboard.exportProxies();
|
||
}
|
||
}
|
||
|
||
// 页面加载完成后初始化
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
dashboard = new Dashboard();
|
||
});
|
||
|
||
// 页面卸载时清理
|
||
window.addEventListener('beforeunload', () => {
|
||
if (dashboard) {
|
||
dashboard.stopAutoRefresh();
|
||
}
|
||
}); |