Files
dns-server/static/index.html
T
2025-11-23 18:51:08 +08:00

1826 lines
68 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DNS服务器管理中心</title>
<!-- 引入Font Awesome图标 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--primary-color: #3498db;
--secondary-color: #2c3e50;
--success-color: #2ecc71;
--danger-color: #e74c3c;
--warning-color: #f39c12;
--info-color: #3498db;
--light-color: #ecf0f1;
--dark-color: #34495e;
--gray-100: #f8f9fa;
--gray-200: #e9ecef;
--gray-300: #dee2e6;
--gray-400: #ced4da;
--gray-500: #adb5bd;
--gray-600: #6c757d;
--gray-700: #495057;
--gray-800: #343a40;
--gray-900: #212529;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--border-radius-sm: 0.25rem;
--border-radius: 0.375rem;
--border-radius-md: 0.5rem;
--border-radius-lg: 0.75rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: var(--gray-100);
color: var(--gray-800);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
padding: 2rem 0;
border-radius: var(--border-radius-lg);
margin-bottom: 2rem;
box-shadow: var(--shadow-md);
text-align: center;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 700;
}
header p {
font-size: 1.1rem;
opacity: 0.9;
}
.tabs {
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
margin-bottom: 2rem;
overflow: hidden;
}
.tab-nav {
display: flex;
background-color: var(--gray-50);
border-bottom: 1px solid var(--gray-200);
}
.tab-btn {
flex: 1;
padding: 1rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: var(--gray-600);
transition: all var(--transition-fast);
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.tab-btn:hover {
background-color: var(--gray-100);
color: var(--primary-color);
}
.tab-btn.active {
color: var(--primary-color);
background-color: white;
box-shadow: inset 0 3px 0 var(--primary-color);
}
.tab-content {
display: none;
padding: 2rem;
animation: fadeIn 0.3s ease-in-out;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.card-header {
border-bottom: 1px solid var(--gray-200);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-800);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-body {
padding: 0.5rem 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
padding: 1rem;
text-align: center;
transition: transform var(--transition-fast);
border-top: 4px solid var(--primary-color);
}
.stat-card:hover {
transform: translateY(-3px);
}
.stat-card i {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-800);
margin-bottom: 0.25rem;
position: relative;
transition: all 0.3s ease;
}
.stat-value.update {
animation: glow 1s ease-out;
}
@keyframes glow {
0% {
text-shadow: 0 0 5px var(--primary-color), 0 0 10px var(--primary-color);
transform: scale(1.1);
}
100% {
text-shadow: none;
transform: scale(1);
}
}
.mini-chart-container {
height: 60px;
margin-top: 0.5rem;
opacity: 0.8;
}
.stat-label {
font-size: 0.9rem;
color: var(--gray-600);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chart-container {
position: relative;
height: 300px;
margin: 1rem 0;
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 1.5rem;
}
input[type="text"], select {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--gray-300);
border-radius: var(--border-radius);
font-size: 1rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
background-color: white;
}
input[type="text"]:focus, select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #27ae60;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-outline {
background-color: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.btn-outline:hover {
background-color: var(--primary-color);
color: white;
}
/* 悬浮通知样式 */
.notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
padding: 1rem 1.5rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideIn 0.3s ease-out;
max-width: 400px;
}
.notification-success {
background-color: white;
border-left: 4px solid var(--success-color);
color: var(--gray-800);
}
.notification-danger {
background-color: white;
border-left: 4px solid var(--danger-color);
color: var(--gray-800);
}
.notification-warning {
background-color: white;
border-left: 4px solid var(--warning-color);
color: var(--gray-800);
}
.notification-info {
background-color: white;
border-left: 4px solid var(--info-color);
color: var(--gray-800);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-icon {
font-size: 1.25rem;
}
.notification-success .notification-icon {
color: var(--success-color);
}
.notification-danger .notification-icon {
color: var(--danger-color);
}
.notification-warning .notification-icon {
color: var(--warning-color);
}
.notification-info .notification-icon {
color: var(--info-color);
}
.notification-content {
flex: 1;
font-weight: 500;
}
.notification-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--gray-500);
padding: 0;
margin-left: 0.5rem;
transition: color var(--transition-fast);
}
.notification-close:hover {
color: var(--gray-700);
}
.list-container {
max-height: 400px;
overflow-y: auto;
border-radius: var(--border-radius);
border: 1px solid var(--gray-200);
background-color: white;
}
.list-item {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--gray-100);
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color var(--transition-fast);
}
.list-item:hover {
background-color: var(--gray-50);
}
.list-item:last-child {
border-bottom: none;
}
.list-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.list-title {
font-weight: 500;
color: var(--gray-800);
}
.list-description {
font-size: 0.875rem;
color: var(--gray-600);
}
.list-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: var(--border-radius-sm);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--gray-500);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
font-size: 1rem;
}
.alert {
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
border-left: 4px solid;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: var(--success-color);
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: var(--danger-color);
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-color: var(--info-color);
}
.status-info {
margin-top: 2rem;
padding: 1.5rem;
background-color: var(--gray-50);
border-radius: var(--border-radius-lg);
border-left: 4px solid var(--info-color);
box-shadow: var(--shadow);
}
.status-info h3 {
margin-bottom: 1rem;
}
.status-info p {
margin: 0.5rem 0;
line-height: 1.6;
}
.status-info .stat-card {
border-top: 4px solid var(--primary-color);
}
.status-info p {
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-info strong {
color: var(--gray-700);
}
pre {
background-color: var(--gray-900);
color: var(--gray-100);
padding: 1.5rem;
border-radius: var(--border-radius);
overflow-x: auto;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.5;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
text-transform: uppercase;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
}
.badge-success {
background-color: var(--success-color);
color: white;
}
.badge-danger {
background-color: var(--danger-color);
color: white;
}
.badge-warning {
background-color: var(--warning-color);
color: white;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
header h1 {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
}
.input-group {
flex-direction: column;
}
.tab-nav {
overflow-x: auto;
white-space: nowrap;
}
.tab-btn {
flex: none;
}
.tab-content {
padding: 1rem;
}
}
/* 加载动画 */
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--gray-200);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 0.8s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--gray-100);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--gray-400);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gray-500);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-server"></i> DNS服务器管理中心</h1>
<p>高性能DNS服务器,支持规则屏蔽和Hosts管理</p>
</header>
<div class="tabs">
<div class="tab-nav">
<button class="tab-btn active" onclick="openTab(event, 'dashboard')">
<i class="fas fa-tachometer-alt"></i> 概览
</button>
<button class="tab-btn" onclick="openTab(event, 'block-rules')">
<i class="fas fa-ban"></i> 屏蔽规则
</button>
<button class="tab-btn" onclick="openTab(event, 'hosts')">
<i class="fas fa-list-ul"></i> Hosts管理
</button>
<button class="tab-btn" onclick="openTab(event, 'query')">
<i class="fas fa-search"></i> DNS查询
</button>
</div>
<!-- 概览面板 -->
<div id="dashboard" class="tab-content active">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h2>服务器状态</h2>
<div style="display: flex; gap: 1rem;">
<div style="background-color: white; padding: 0.5rem 1rem; border-radius: var(--border-radius-md); box-shadow: var(--shadow); display: flex; align-items: center; gap: 0.5rem;">
<i class="fas fa-ban" style="color: var(--primary-color);"></i>
<span style="font-size: 1rem; font-weight: 600;">规则: <span id="rules-count-inline">--</span></span>
</div>
<div style="background-color: white; padding: 0.5rem 1rem; border-radius: var(--border-radius-md); box-shadow: var(--shadow); display: flex; align-items: center; gap: 0.5rem;">
<i class="fas fa-file-alt" style="color: var(--primary-color);"></i>
<span style="font-size: 1rem; font-weight: 600;">Hosts: <span id="hosts-count-inline">--</span></span>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<i class="fas fa-question-circle"></i>
<div class="stat-value" id="query-count">--</div>
<div class="stat-label">DNS查询次数</div>
<div class="mini-chart-container">
<canvas id="query-chart"></canvas>
</div>
</div>
<div class="stat-card">
<i class="fas fa-times-circle"></i>
<div class="stat-value" id="blocked-count">--</div>
<div class="stat-label">屏蔽次数</div>
<div class="mini-chart-container">
<canvas id="blocked-chart"></canvas>
</div>
</div>
</div>
<h2 style="margin-bottom: 1.5rem; margin-top: 2rem;">TOP域名统计</h2>
<div class="grid-2">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-ban"></i> TOP 10 屏蔽域名</h3>
</div>
<div class="card-body">
<div id="top-blocked-domains" class="list-container">
<div class="empty-state">
<i class="fas fa-info-circle"></i>
<p>加载中...</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-globe"></i> TOP 10 解析域名</h3>
</div>
<div class="card-body">
<div id="top-resolved-domains" class="list-container">
<div class="empty-state">
<i class="fas fa-info-circle"></i>
<p>加载中...</p>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-chart-line"></i> 24小时屏蔽统计</h3>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="blockChart"></canvas>
</div>
</div>
</div>
<div class="status-info">
<h3 style="margin-bottom: 1rem; font-size: 1.25rem; color: var(--gray-700);">服务器信息</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="stat-card">
<i class="fas fa-ban"></i>
<div class="stat-value" id="rules-count">--</div>
<div class="stat-label">屏蔽规则数</div>
<div class="mini-chart-container">
<canvas id="rules-chart"></canvas>
</div>
</div>
<div class="stat-card">
<i class="fas fa-file-alt"></i>
<div class="stat-value" id="hosts-count">--</div>
<div class="stat-label">Hosts条目数</div>
<div class="mini-chart-container">
<canvas id="hosts-chart"></canvas>
</div>
</div>
</div>
<p><strong>服务器地址:</strong> <span id="server-address">--</span></p>
<p><strong>当前时间:</strong> <span id="current-time">--</span></p>
<p><strong>运行状态:</strong> <span class="badge badge-success">正常运行</span></p>
</div>
</div>
<!-- 屏蔽规则面板 -->
<div id="block-rules" class="tab-content">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-cog"></i> 屏蔽设置</h3>
</div>
<div class="card-body">
<div class="input-group">
<select id="block-method">
<option value="NXDOMAIN">返回NXDOMAIN</option>
<option value="refused">返回拒绝</option>
<option value="emptyIP">返回空IP (0.0.0.0)</option>
<option value="customIP">返回自定义IP</option>
</select>
<input type="text" id="custom-block-ip" placeholder="自定义IP地址" disabled>
<button id="save-block-settings" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</div>
<small style="display:block; margin-top:0.5rem; color:var(--gray-600); font-size:0.875rem;">
<strong>NXDOMAIN</strong>: 返回域名不存在错误<br>
<strong>refused</strong>: 返回查询拒绝错误<br>
<strong>emptyIP</strong>: 返回0.0.0.0<br>
<strong>customIP</strong>: 返回自定义IP地址
</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-plus-circle"></i> 添加屏蔽规则</h3>
</div>
<div class="card-body">
<div class="input-group">
<select id="rule-type">
<option value="domain">域名规则</option>
<option value="exception">排除规则</option>
<option value="regex">正则表达式</option>
<option value="wildcard">通配符规则</option>
<option value="start">URL开头</option>
<option value="end">URL结尾</option>
</select>
<input type="text" id="new-rule" placeholder="输入规则内容,例如:example.com">
<button id="add-rule-btn" class="btn-primary">
<i class="fas fa-plus"></i> 添加
</button>
</div>
<small style="display:block; margin-top:0.5rem; color:var(--gray-600); font-size:0.875rem;">支持AdGuardHome规则格式:域名规则(||example.com^)、排除规则(@@||example.com^)、正则规则(/regex/)、通配符规则(*example.com)等</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-list"></i> 规则列表</h3>
</div>
<div class="card-body">
<div id="rules-container" class="list-container">
<div class="empty-state">
<i class="fas fa-info-circle"></i>
<p>规则列表加载中...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Hosts管理面板 -->
<div id="hosts" class="tab-content">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-plus-circle"></i> 添加Hosts条目</h3>
</div>
<div class="card-body">
<div class="input-group">
<input type="text" id="hosts-ip" placeholder="IP地址,例如:127.0.0.1">
<input type="text" id="hosts-domain" placeholder="域名,例如:localhost">
<button id="add-hosts-btn" class="btn-primary">
<i class="fas fa-plus"></i> 添加
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-list"></i> 当前Hosts条目</h3>
</div>
<div class="card-body">
<div id="hosts-container" class="list-container">
<div class="empty-state">
<i class="fas fa-info-circle"></i>
<p>Hosts列表加载中...</p>
</div>
</div>
</div>
</div>
</div>
<!-- DNS查询面板 -->
<div id="query" class="tab-content">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-search"></i> DNS查询</h3>
</div>
<div class="card-body">
<div class="input-group">
<input type="text" id="query-domain" placeholder="输入要查询的域名">
<button id="query-btn" class="btn-primary">
<i class="fas fa-search"></i> 查询
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-code"></i> 查询结果</h3>
</div>
<div class="card-body">
<pre id="query-result-text">请输入域名并点击查询按钮</pre>
</div>
</div>
</div>
</div>
</div>
<script>
// 保存上一次的数据值,用于检测变化
let previousStats = {};
// 存储各卡片的小型图表实例
let miniCharts = {};
// 存储数据历史记录用于小型图表
let dataHistory = {
rules: Array(10).fill(0),
hosts: Array(10).fill(0),
query: Array(10).fill(0),
blocked: Array(10).fill(0)
};
// 标签页切换功能
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].className = tabcontent[i].className.replace(" active", "");
}
tablinks = document.getElementsByClassName("tab-btn");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tabName).className += " active";
evt.currentTarget.className += " active";
// 当切换到特定标签时加载数据
if (tabName === 'dashboard') {
loadDashboardData();
// 启动定时更新,但确保只启动一次
if (!updateTimer) {
startRealTimeUpdate();
}
} else {
// 切换到其他标签页时停止定时更新
stopRealTimeUpdate();
if (tabName === 'block-rules') {
loadRules();
} else if (tabName === 'hosts') {
loadHosts();
}
}
}
// 定时更新的定时器
let updateTimer = null;
const UPDATE_INTERVAL = 2000; // 降低间隔以便更快地响应数据变化
// 启动实时更新
function startRealTimeUpdate() {
// 确保完全清除之前的定时器
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
// 启动新的定时器,保持轮询机制,但在loadDashboardData内部判断是否需要更新
updateTimer = setInterval(loadDashboardData, UPDATE_INTERVAL);
}
// 停止实时更新
function stopRealTimeUpdate() {
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
}
// 检查数据是否变化并添加光晕效果
function checkAndAnimate(elementId, newValue) {
const element = document.getElementById(elementId);
const oldValue = previousStats[elementId] || 0;
// 检查值是否发生变化
if (newValue !== oldValue && oldValue !== 0) {
// 添加更新类触发动画
element.classList.add('update');
// 动画结束后移除类
setTimeout(() => {
element.classList.remove('update');
}, 1000);
}
// 更新存储的上一次值
previousStats[elementId] = newValue;
}
// 更新数据历史记录
function updateDataHistory(key, value) {
dataHistory[key].shift(); // 移除最旧的数据点
dataHistory[key].push(value); // 添加新的数据点
}
// 初始化小型图表
function initMiniCharts() {
const chartConfigs = {
'rules-chart': { label: '规则数', color: 'rgb(75, 192, 192)' },
'hosts-chart': { label: 'Hosts数', color: 'rgb(153, 102, 255)' },
'query-chart': { label: '查询数', color: 'rgb(255, 159, 64)' },
'blocked-chart': { label: '屏蔽数', color: 'rgb(255, 99, 132)' }
};
Object.entries(chartConfigs).forEach(([id, config]) => {
const ctx = document.getElementById(id).getContext('2d');
miniCharts[id] = new Chart(ctx, {
type: 'line',
data: {
labels: Array(10).fill(''),
datasets: [{
label: config.label,
data: Array(10).fill(0),
borderColor: config.color,
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#333',
borderWidth: 1,
cornerRadius: 4,
displayColors: false,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y;
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: false,
beginAtZero: true
}
},
animation: false,
interaction: {
intersect: false,
mode: 'index'
}
}
});
});
}
// 更新小型图表
function updateMiniChart(chartId, data) {
if (miniCharts[chartId]) {
miniCharts[chartId].data.datasets[0].data = data;
miniCharts[chartId].update();
}
}
// 页面加载完成时初始化
window.addEventListener('load', function() {
// 初始化小型图表
initMiniCharts();
// 加载仪表盘数据
if (document.getElementById('dashboard').classList.contains('active')) {
loadDashboardData();
// 确保只启动一次定时器
if (!updateTimer) {
startRealTimeUpdate();
}
}
});
// 页面卸载时清理定时器
window.addEventListener('beforeunload', function() {
stopRealTimeUpdate();
});
// 保存上一次完整的API数据,用于检测是否有任何变动
let previousFullData = null;
// 用于比较数据是否变化的辅助函数
function isDataChanged(newData, oldData) {
if (!oldData) return true;
// 检查关键统计数据是否有变化
const newRulesCount = (newData.shield && (newData.shield.domainRules + newData.shield.regexRules)) || 0;
const oldRulesCount = (oldData.shield && (oldData.shield.domainRules + oldData.shield.regexRules)) || 0;
const newHostsCount = (newData.shield && newData.shield.hostsRules) || 0;
const oldHostsCount = (oldData.shield && oldData.shield.hostsRules) || 0;
const newQueryCount = (newData.dns && newData.dns.Queries) || 0;
const oldQueryCount = (oldData.dns && oldData.dns.Queries) || 0;
const newBlockedCount = (newData.dns && newData.dns.Blocked) || 0;
const oldBlockedCount = (oldData.dns && oldData.dns.Blocked) || 0;
// 如果任何数据项发生变化,返回true
return newRulesCount !== oldRulesCount ||
newHostsCount !== oldHostsCount ||
newQueryCount !== oldQueryCount ||
newBlockedCount !== oldBlockedCount;
}
// 加载概览数据 - 仅在数据变动时更新页面
function loadDashboardData() {
// 更新当前时间
document.getElementById('current-time').textContent = new Date().toLocaleString('zh-CN');
fetch('/api/stats')
.then(response => {
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
return response.json();
})
.then(data => {
console.log('获取到统计数据:', data);
// 确保数据结构正确
if (!data || !data.shield) {
console.error('返回的数据结构不正确,缺少shield字段');
// 即使数据结构不正确,也尝试更新服务器地址
document.getElementById('server-address').textContent = window.location.hostname + ':8080';
return;
}
// 获取各项统计数据
const domainRules = data.shield.domainRules || 0;
const regexRules = data.shield.regexRules || 0;
const rulesCount = domainRules + regexRules;
const hostsCount = data.shield.hostsRules || 0;
const queryCount = (data.dns && data.dns.Queries) || 0;
const blockedCount = (data.dns && data.dns.Blocked) || 0;
console.log(`规则统计: 域名规则=${domainRules}, 正则规则=${regexRules}, 总计=${rulesCount}`);
console.log(`Hosts统计: 条目数=${hostsCount}`);
// 直接更新显示,同时更新顶部状态区域和统计卡片
document.getElementById('rules-count').textContent = rulesCount;
document.getElementById('hosts-count').textContent = hostsCount;
document.getElementById('query-count').textContent = queryCount;
// 更新顶部服务器状态区域的数据
document.getElementById('rules-count-inline').textContent = rulesCount;
document.getElementById('hosts-count-inline').textContent = hostsCount;
// 更新屏蔽次数显示,包含百分比
const blockedPercentage = queryCount > 0 ? Math.round((blockedCount / queryCount) * 100) : 0;
const blockedElement = document.getElementById('blocked-count');
if (blockedElement) {
blockedElement.innerHTML = blockedCount +
'<span style="position: absolute; top: -10px; right: -20px; ' +
'background-color: #ff6b6b; color: white; border-radius: 50%; padding: 2px 6px; ' +
'font-size: 0.7rem; font-weight: bold;">' + blockedPercentage + '%</span>';
}
document.getElementById('server-address').textContent = window.location.hostname + ':8080';
// 更新数据历史记录
updateDataHistory('rules', rulesCount);
updateDataHistory('hosts', hostsCount);
updateDataHistory('query', queryCount);
updateDataHistory('blocked', blockedCount);
// 更新小型图表
updateMiniChart('rules-chart', dataHistory.rules);
updateMiniChart('hosts-chart', dataHistory.hosts);
updateMiniChart('query-chart', dataHistory.query);
updateMiniChart('blocked-chart', dataHistory.blocked);
// 更新完整数据缓存
previousFullData = JSON.parse(JSON.stringify(data));
// 加载其他相关数据
loadChartData();
loadTopDomains();
})
.catch(error => {
console.error('加载统计数据失败:', error);
// 即使出错,也尝试更新服务器地址
document.getElementById('server-address').textContent = window.location.hostname + ':8080';
// 尝试直接从本地文件加载规则和hosts数量(备用方案)
try {
console.log('尝试备用方案加载规则和hosts数量...');
// 这里可以添加从其他接口或本地缓存获取数据的逻辑
} catch (e) {
console.error('备用方案也失败:', e);
}
});
}
// 保存上一次的图表数据,用于检测图表数据是否变化
let previousChartData = null;
// 加载图表数据并渲染 - 仅在数据变动时更新
function loadChartData() {
fetch('/api/hourly-stats')
.then(response => response.json())
.then(data => {
// 检查图表数据是否有变动
const dataChanged = !previousChartData ||
JSON.stringify(data.labels) !== JSON.stringify(previousChartData.labels) ||
JSON.stringify(data.data) !== JSON.stringify(previousChartData.data);
if (dataChanged) {
console.log('图表数据有变动,正在更新图表...');
renderBlockChart(data.labels, data.data);
// 更新缓存
previousChartData = { labels: [...data.labels], data: [...data.data] };
}
})
.catch(error => console.error('加载图表数据失败:', error));
}
// 渲染屏蔽统计图表
let blockChart = null;
function renderBlockChart(labels, data) {
const ctx = document.getElementById('blockChart').getContext('2d');
// 如果图表已存在,先销毁
if (blockChart) {
blockChart.destroy();
}
blockChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '屏蔽次数',
data: data,
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)',
borderWidth: 2,
pointBackgroundColor: 'rgb(255, 99, 132)',
pointRadius: 4,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
padding: 20,
font: {
size: 14
}
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#333',
borderWidth: 1,
cornerRadius: 6,
padding: 12,
displayColors: false,
callbacks: {
title: function(tooltipItems) {
return tooltipItems[0].label + ' 时';
},
label: function(context) {
return '屏蔽次数: ' + context.parsed.y;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
},
animation: {
duration: 1000
}
}
});
}
// 加载屏蔽规则
function loadRules() {
const rulesContainer = document.getElementById('rules-container');
rulesContainer.innerHTML = '<div class="empty-state"><div class="loader"></div><p>规则列表加载中...</p></div>';
// 从服务器获取真实规则列表
fetch('/api/shield')
.then(response => {
if (!response.ok) {
throw new Error('获取规则失败');
}
return response.json();
})
.then(data => {
rulesContainer.innerHTML = '';
// 合并所有规则类型
let allRules = [];
// 添加域名规则
if (Array.isArray(data.domainRules)) {
data.domainRules.forEach(rule => {
allRules.push({ rule: rule, type: 'domain', block: true });
});
}
// 添加排除规则
if (Array.isArray(data.domainExceptions)) {
data.domainExceptions.forEach(rule => {
allRules.push({ rule: rule, type: 'exception', block: false });
});
}
// 添加正则规则
if (Array.isArray(data.regexRules)) {
data.regexRules.forEach(rule => {
allRules.push({ rule: rule, type: 'regex', block: true });
});
}
// 添加正则排除规则
if (Array.isArray(data.regexExceptions)) {
data.regexExceptions.forEach(rule => {
allRules.push({ rule: rule, type: 'regex_exception', block: false });
});
}
// 如果没有规则,显示空状态
if (allRules.length === 0) {
rulesContainer.innerHTML = '<div class="empty-state"><i class="fas fa-info-circle"></i><p>暂无规则</p></div>';
return;
}
// 渲染所有规则
allRules.forEach(item => {
const ruleItem = document.createElement('div');
ruleItem.className = 'list-item';
// 根据规则类型添加不同的样式和图标
let icon = 'ban';
let title = '屏蔽规则';
let badgeClass = 'badge-danger';
let typeText = '域名';
if (item.type === 'exception') {
icon = 'check-circle';
title = '排除规则';
badgeClass = 'badge-success';
typeText = '排除';
} else if (item.type === 'regex' || item.type === 'regex_exception') {
icon = 'code';
title = item.type === 'regex' ? '正则屏蔽规则' : '正则排除规则';
badgeClass = item.type === 'regex' ? 'badge-primary' : 'badge-success';
typeText = '正则';
}
// 转义规则中的特殊字符,确保在HTML和JavaScript中正确处理
const escapedRule = item.rule.replace(/'/g, "\\'");
ruleItem.innerHTML = `
<div class="list-content">
<div class="list-title"><i class="fas fa-${icon}"></i> ${title}</div>
<div class="list-description">${item.rule}</div>
</div>
<div class="list-actions">
<span class="badge ${badgeClass}">${typeText}</span>
<button class="btn-danger btn-sm" onclick="deleteRule('${escapedRule}')">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`;
rulesContainer.appendChild(ruleItem);
});
})
.catch(error => {
console.error('加载规则失败:', error);
rulesContainer.innerHTML = '<div class="empty-state"><i class="fas fa-exclamation-circle"></i><p>加载规则失败</p></div>';
});
}
// 添加屏蔽规则
document.getElementById('add-rule-btn').addEventListener('click', function() {
const rule = document.getElementById('new-rule').value.trim();
const ruleType = document.getElementById('rule-type').value;
const btn = this;
const originalText = btn.innerHTML;
if (!rule) {
showNotification('warning', '请输入规则内容');
return;
}
// 显示加载状态
btn.innerHTML = '<div class="loader"></div> 添加中';
btn.disabled = true;
// 根据规则类型生成AdGuardHome格式的规则
let fullRule = '';
switch(ruleType) {
case 'domain':
// 标准域名规则: ||example.com^
fullRule = `||${rule}^`;
break;
case 'exception':
// 排除规则: @@||example.com^
fullRule = `@@||${rule}^`;
break;
case 'regex':
// 正则表达式规则: /pattern/
fullRule = `/${rule}/`;
break;
case 'wildcard':
// 通配符规则: *example.com
fullRule = `*${rule}`;
break;
case 'start':
// URL开头匹配: |http://example.com
fullRule = `|${rule}`;
break;
case 'end':
// URL结尾匹配: example.com|
fullRule = `${rule}|`;
break;
default:
fullRule = rule;
}
fetch('/api/shield', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ rule: fullRule })
})
.then(response => response.json())
.then(data => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
// 显示成功消息
showNotification('success', '规则添加成功');
// 清空输入框
document.getElementById('new-rule').value = '';
// 重新加载规则
loadRules();
})
.catch(error => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
console.error('添加规则失败:', error);
showNotification('danger', '添加规则失败,请稍后重试');
});
});
// 加载Hosts条目
function loadHosts() {
const hostsContainer = document.getElementById('hosts-container');
hostsContainer.innerHTML = '<div class="empty-state"><div class="loader"></div><p>Hosts列表加载中...</p></div>';
fetch('/api/shield/hosts')
.then(response => response.json())
.then(data => {
// 注意:这需要在shieldManager中添加一个获取所有hosts条目的方法
// 暂时返回统计信息
const hostsCount = data.hostsCount || 0;
if (hostsCount > 0) {
hostsContainer.innerHTML = `<div class="list-item">
<div class="list-content">
<div class="list-title">Hosts概览</div>
<div class="list-description">共 ${hostsCount} 个Hosts条目</div>
</div>
<div class="list-actions">
<button class="btn-outline btn-sm" onclick="location.reload()">刷新</button>
</div>
</div>`;
} else {
hostsContainer.innerHTML = '<div class="empty-state"><i class="fas fa-info-circle"></i><p>暂无Hosts条目</p></div>';
}
})
.catch(error => {
console.error('加载Hosts失败:', error);
hostsContainer.innerHTML = '<div class="empty-state"><i class="fas fa-exclamation-circle"></i><p>加载失败,请稍后重试</p></div>';
});
}
// 添加Hosts条目
document.getElementById('add-hosts-btn').addEventListener('click', function() {
const ip = document.getElementById('hosts-ip').value.trim();
const domain = document.getElementById('hosts-domain').value.trim();
const btn = this;
const originalText = btn.innerHTML;
if (!ip || !domain) {
showNotification('warning', '请输入IP地址和域名');
return;
}
// 显示加载状态
btn.innerHTML = '<div class="loader"></div> 添加中';
btn.disabled = true;
fetch('/api/shield/hosts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ip: ip, domain: domain })
})
.then(response => response.json())
.then(data => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
// 显示成功消息
showNotification('success', 'Hosts条目添加成功');
// 清空输入框
document.getElementById('hosts-ip').value = '';
document.getElementById('hosts-domain').value = '';
// 重新加载Hosts
loadHosts();
})
.catch(error => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
console.error('添加Hosts条目失败:', error);
showNotification('danger', '添加Hosts条目失败,请稍后重试');
});
});
// DNS查询
document.getElementById('query-btn').addEventListener('click', function() {
const domain = document.getElementById('query-domain').value.trim();
const btn = this;
const originalText = btn.innerHTML;
const resultElement = document.getElementById('query-result-text');
if (!domain) {
showNotification('warning', '请输入要查询的域名');
return;
}
// 显示加载状态
btn.innerHTML = '<div class="loader"></div> 查询中';
btn.disabled = true;
resultElement.textContent = '正在查询...';
fetch(`/api/query?domain=${encodeURIComponent(domain)}`)
.then(response => response.json())
.then(data => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
// 格式化显示结果
resultElement.textContent = JSON.stringify(data, null, 2);
})
.catch(error => {
// 重置按钮状态
btn.innerHTML = originalText;
btn.disabled = false;
console.error('查询失败:', error);
resultElement.textContent = '查询失败: ' + error.message;
showNotification('danger', '查询失败,请稍后重试');
});
});
// 删除规则函数
function deleteRule(rule) {
if (!confirm('确定要删除这条规则吗?')) {
return;
}
fetch('/api/shield', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ rule: rule })
})
.then(response => {
if (!response.ok) {
throw new Error('删除失败');
}
return response.json();
})
.then(data => {
// 显示成功消息
showNotification('success', '规则删除成功');
// 重新加载规则列表
loadRules();
})
.catch(error => {
console.error('删除规则失败:', error);
showNotification('danger', '删除规则失败,请稍后重试');
});
}
// 加载TOP域名数据
function loadTopDomains() {
// 加载TOP屏蔽域名
fetch('/api/top-blocked')
.then(response => response.json())
.then(data => {
const container = document.getElementById('top-blocked-domains');
container.innerHTML = '';
if (!data || data.length === 0) {
container.innerHTML = '<div class="empty-state"><i class="fas fa-info-circle"></i><p>暂无屏蔽域名统计</p></div>';
return;
}
data.forEach((item, index) => {
const listItem = document.createElement('div');
listItem.className = 'list-item';
listItem.innerHTML = `
<div class="list-content">
<div class="list-title">${index + 1}. ${item.domain}</div>
<div class="list-description">屏蔽次数: ${item.count}</div>
</div>
<div class="list-actions">
<span class="badge badge-danger">屏蔽</span>
</div>
`;
container.appendChild(listItem);
});
})
.catch(error => {
console.error('加载TOP屏蔽域名失败:', error);
document.getElementById('top-blocked-domains').innerHTML = '<div class="empty-state"><i class="fas fa-exclamation-circle"></i><p>加载失败</p></div>';
});
// 加载TOP解析域名
fetch('/api/top-resolved')
.then(response => response.json())
.then(data => {
const container = document.getElementById('top-resolved-domains');
container.innerHTML = '';
if (!data || data.length === 0) {
container.innerHTML = '<div class="empty-state"><i class="fas fa-info-circle"></i><p>暂无解析域名统计</p></div>';
return;
}
data.forEach((item, index) => {
const listItem = document.createElement('div');
listItem.className = 'list-item';
listItem.innerHTML = `
<div class="list-content">
<div class="list-title">${index + 1}. ${item.domain}</div>
<div class="list-description">解析次数: ${item.count}</div>
</div>
<div class="list-actions">
<span class="badge badge-success">解析</span>
</div>
`;
container.appendChild(listItem);
});
})
.catch(error => {
console.error('加载TOP解析域名失败:', error);
document.getElementById('top-resolved-domains').innerHTML = '<div class="empty-state"><i class="fas fa-exclamation-circle"></i><p>加载失败</p></div>';
});
}
// 初始化页面
window.onload = function() {
loadDashboardData();
// 每秒更新时间
setInterval(() => {
document.getElementById('current-time').textContent = new Date().toLocaleString('zh-CN');
}, 1000);
// 屏蔽方法选择事件
document.getElementById('block-method').addEventListener('change', function() {
document.getElementById('custom-block-ip').disabled = this.value !== 'customIP';
});
// 保存屏蔽设置按钮点击事件
document.getElementById('save-block-settings').addEventListener('click', saveBlockSettings);
// 加载当前屏蔽设置
loadBlockSettings();
};
// 加载当前屏蔽设置
function loadBlockSettings() {
fetch('/api/config', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.shield) {
document.getElementById('block-method').value = data.shield.blockMethod || 'NXDOMAIN';
document.getElementById('custom-block-ip').value = data.shield.customBlockIP || '';
document.getElementById('custom-block-ip').disabled = data.shield.blockMethod !== 'customIP';
}
})
.catch(error => {
console.error('加载屏蔽设置失败:', error);
});
}
// 保存屏蔽设置
function saveBlockSettings() {
const blockMethod = document.getElementById('block-method').value;
const customBlockIP = document.getElementById('custom-block-ip').value;
// 验证自定义IP
if (blockMethod === 'customIP' && customBlockIP) {
const ipRegex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
if (!ipRegex.test(customBlockIP)) {
showNotification('warning', '请输入有效的IP地址');
return;
}
}
fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
shield: {
blockMethod: blockMethod,
customBlockIP: customBlockIP
}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('success', '屏蔽设置已保存');
} else {
showNotification('danger', '保存失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('保存屏蔽设置失败:', error);
showNotification('danger', '保存失败: ' + error.message);
});
}
// 显示悬浮通知
function showNotification(type, message) {
// 创建通知元素
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
// 设置图标
let iconClass = 'info-circle';
if (type === 'success') iconClass = 'check-circle';
else if (type === 'danger') iconClass = 'exclamation-circle';
else if (type === 'warning') iconClass = 'exclamation-triangle';
// 设置通知内容
notification.innerHTML = `
<div class="notification-icon">
<i class="fas fa-${iconClass}"></i>
</div>
<div class="notification-content">${message}</div>
<button class="notification-close">
<i class="fas fa-times"></i>
</button>
`;
// 添加关闭事件
notification.querySelector('.notification-close').addEventListener('click', () => {
notification.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => notification.remove(), 300);
});
// 添加到页面
document.body.appendChild(notification);
// 3秒后自动关闭
setTimeout(() => {
notification.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
</script>
</body>
</html>