diff --git a/api/index.html b/api/index.html new file mode 100644 index 0000000..3360221 --- /dev/null +++ b/api/index.html @@ -0,0 +1,1767 @@ + + + + + DNS Server API 文档 + + + + + +
+ + + + + + \ No newline at end of file diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..c61d7f4 --- /dev/null +++ b/css/style.css @@ -0,0 +1,1078 @@ +/* 全局样式重置 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f7fa; + color: #333; + line-height: 1.6; + width: 100%; + height: 100%; + overflow-x: hidden; +} + +body { + position: relative; +} + +/* 基础响应式变量 */ +:root { + --sidebar-width: 250px; + --sidebar-mobile-width: 70px; + --header-height: 130px; + --content-padding: 1rem; + --card-min-width: 300px; +} + +/* 主容器样式 */ +.container { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; + max-width: 100%; + background-color: #fff; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.05); +} + +/* 头部样式 */ +header.header-container { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1.5rem; + width: 100%; + text-align: center; + box-sizing: border-box; + position: relative; + z-index: 10; +} + +.logo { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.logo i { + margin-right: 1rem; + color: white; +} + +.logo h1 { + font-size: 1.8rem; + margin: 0; + font-weight: 600; +} + +header p { + font-size: 1rem; + opacity: 0.9; +} + +/* 主体布局容器 */ +.main-layout { + display: flex; + flex: 1; + min-height: 0; + transition: all 0.3s ease; +} + +/* 侧边栏样式 */ +.sidebar { + width: var(--sidebar-width); + background-color: #2c3e50; + color: white; + padding: 1rem 0; + flex-shrink: 0; + overflow-y: auto; + height: calc(100vh - var(--header-height)); /* 减去header的高度 */ + transition: width 0.3s ease; + position: relative; +} + +/* 移动设备侧边栏切换按钮 */ +.sidebar-toggle { + position: fixed; + top: calc(var(--header-height) + 10px); + left: 10px; + z-index: 100; + background-color: #2c3e50; + color: white; + border: none; + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + display: none; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +/* 响应式布局 - 平板设备 */ +@media (max-width: 992px) { + .sidebar { + width: var(--sidebar-mobile-width); + } + + .nav-item span { + display: none; + } + + .nav-item i { + margin-right: 0; + } + + .sidebar-toggle { + display: block; + } +} + +/* 响应式布局 - 移动设备 */ +@media (max-width: 768px) { + /* 这些样式已经通过Tailwind CSS类在HTML中实现,这里移除避免冲突 */ +} + +.nav-menu { + list-style: none; +} + +.nav-item { + padding: 1rem 1.5rem; + display: flex; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.nav-item:hover { + background-color: #34495e; + padding-left: 1.75rem; +} + +.nav-item.active { + background-color: #3498db; + border-left: 4px solid #fff; +} + +.nav-item i { + margin-right: 1rem; + width: 20px; + text-align: center; +} + +/* 主内容区域样式 */ +.content { + flex: 1; + padding: var(--content-padding); + overflow-y: auto; + background-color: #f8f9fa; + min-width: 0; /* 防止flex子元素溢出 */ + height: calc(100vh - var(--header-height)); /* 减去header的高度 */ + transition: padding-left 0.3s ease; +} + +/* Tooltip趋势信息颜色类 - 替代内联style */ +.tooltip-trend { + font-weight: 500; +} + +/* 注意:这些颜色值与colors.config.js中的COLOR_CONFIG.colorClassMap保持同步 */ +.tooltip-trend.blue { + color: #1890ff; +} + +.tooltip-trend.green { + color: #52c41a; +} + +.tooltip-trend.orange { + color: #fa8c16; +} + +.tooltip-trend.red { + color: #f5222d; +} + +.tooltip-trend.purple { + color: #722ed1; +} + +.tooltip-trend.cyan { + color: #13c2c2; +} + +.tooltip-trend.teal { + color: #36cfc9; +} + +/* 平板设备适配 - 侧边栏折叠时调整内容区域 */ +@media (max-width: 992px) { + .content { + padding-left: calc(var(--content-padding) + 10px); + } +} + +/* 移动设备适配 - 侧边栏隐藏时的内容区域 */ +@media (max-width: 768px) { + .content { + padding-left: var(--content-padding); + } + + /* 响应式头部样式 */ + header.header-container { + padding: 1rem; + } + + .logo h1 { + font-size: 1.5rem; + } + + header p { + font-size: 0.9rem; + } +} + +/* 面板样式 */ +.panel { + display: none; + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + overflow: hidden; +} + +.panel.active { + display: block; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e9ecef; +} + +.panel-header h2 { + font-size: 1.5rem; + color: #2c3e50; +} + +/* 状态指示器 */ +.status-indicator { + display: flex; + align-items: center; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #e74c3c; + margin-right: 8px; + animation: pulse 2s infinite; +} + +.status-dot.connected { + background-color: #2ecc71; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.7; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* 按钮样式 */ +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; +} + +.btn i { + margin-right: 0.5rem; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #7f8c8d; + color: white; +} + +.btn-secondary:hover { + background-color: #6c757d; +} + +.btn-success { + background-color: #2ecc71; + color: white; +} + +.btn-success:hover { + background-color: #27ae60; +} + +.btn-danger { + background-color: #e74c3c; + color: white; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-warning { + background-color: #f39c12; + color: white; +} + +.btn-warning:hover { + background-color: #e67e22; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +.btn-block { + width: 100%; +} + +/* 统计卡片网格 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); + gap: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整间距 */ + margin-bottom: 2rem; +} + +/* 图表容器 */ +.charts-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + gap: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整间距 */ + margin-bottom: 1.5rem; +} + +.stat-card { + background-color: white; + border-radius: 8px; + padding: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整内边距 */ + text-align: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; + min-width: 0; /* 防止内容溢出 */ + display: flex; + flex-direction: column; + justify-content: center; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} + +/* 卡片布局的响应式优化 */ +@media (max-width: 640px) { + /* 在极小屏幕上,调整卡片网格为单列显示 */ + .stats-grid, + .charts-container, + .tables-container { + grid-template-columns: 1fr; + } + + /* 卡片更紧凑的内边距 */ + .stat-card, + .chart-card, + .table-card { + padding: 1rem; + min-height: 120px; + } + + /* 优化统计卡片的图标大小 */ + .stat-card i { + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + /* 优化统计卡片的数值和标签 */ + .stat-value { + font-size: clamp(1.2rem, 5vw, 1.5rem); + } + + .stat-label { + font-size: clamp(0.7rem, 3vw, 0.8rem); + } + + /* 优化图表卡片标题 */ + .chart-card h3 { + font-size: clamp(1rem, 4vw, 1.1rem); + } + + /* 优化面板标题 */ + .panel-header h2 { + font-size: clamp(1.2rem, 5vw, 1.3rem); + } +} + +.stat-card i { + font-size: 2rem; + margin-bottom: 1rem; + color: #3498db; +} + +.stat-value { + font-size: 2rem; + font-weight: bold; + margin-bottom: 0.5rem; + color: #2c3e50; +} + +.stat-label { + font-size: 0.9rem; + color: #7f8c8d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* 图表容器 */ +.charts-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.chart-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); +} + +.chart-card h3 { + margin-bottom: 1rem; + font-size: 1.2rem; + color: #2c3e50; +} + +/* 表格容器 */ +.tables-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + gap: 1.5rem; +} + +/* 表格卡片样式 */ +.table-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + min-width: 0; /* 防止子元素溢出 */ +} + +/* 表格响应式样式 */ +@media (max-width: 768px) { + /* 调整卡片内边距 */ + .table-card, + .stat-card, + .chart-card { + padding: 1rem; + } + + /* 调整表格单元格内边距 */ + th, td { + padding: 0.5rem; + font-size: 0.9rem; + } + + /* 调整表格卡片标题 */ + .table-card h3, + .chart-card h3 { + font-size: 1.1rem; + } + + /* 调整统计卡片数值和标签 */ + .stat-value { + font-size: 1.5rem; + } + + .stat-label { + font-size: 0.8rem; + } + + /* 调整面板标题 */ + .panel-header h2 { + font-size: 1.3rem; + } + + /* 调整按钮大小 */ + .btn { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + } +} + +.table-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); +} + +.table-card h3 { + margin-bottom: 1rem; + font-size: 1.2rem; + color: #2c3e50; +} +/* 表格样式 */ +.table-wrapper { + overflow-x: auto; + border-radius: 8px; + background-color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 16px; + display: block; + width: 100%; + -webkit-overflow-scrolling: touch; /* iOS平滑滚动 */ +} + +/* 最常屏蔽和最常解析域名表格的特殊样式 */ +#top-blocked-table, #top-resolved-table { + font-size: 0.85rem; +} + +/* 限制域名表格高度,只显示5条内容 */ +.table-card .table-wrapper { + max-height: 220px; + overflow-y: auto; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + background-color: #ffffff; + margin: 0; + table-layout: fixed; /* 固定布局,有助于响应式设计 */ +} + +th, td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e9ecef; + word-break: break-word; /* 长文本自动换行 */ +} + +/* 缩小最常屏蔽和最常解析域名表格的单元格内边距 */ +#top-blocked-table th, #top-blocked-table td, +#top-resolved-table th, #top-resolved-table td { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +/* 移动设备上表格的优化 */ +@media (max-width: 768px) { + /* 确保表格可以水平滚动 */ + .table-wrapper { + max-width: 100%; + margin-left: -1rem; + margin-right: -1rem; + border-radius: 0; + } + + /* 表格单元格内容截断处理 */ + td { + font-size: 0.85rem; + max-width: 150px; /* 限制单元格最大宽度 */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* 当用户触摸单元格时显示完整内容 */ + td:active { + white-space: normal; + word-break: break-word; + } + + /* 优化百分比条在小屏幕上的显示 */ + .count-cell { + position: relative; + padding-right: 50px; /* 为百分比文本留出空间 */ + } + + .percentage-text { + font-size: 10px; + right: 5px; + } +} + +th { + background-color: #f8f9fa; + font-weight: 600; + color: #2c3e50; +} + +td.loading { + text-align: center; + color: #7f8c8d; + font-style: italic; +} + +tr:hover { + background-color: #f8f9fa; +} + +/* 百分比条样式 */ +.count-cell { + position: relative; +} + +.count-number { + position: relative; + z-index: 2; + display: inline-block; +} + +.percentage-text { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + z-index: 2; + font-size: 12px; + color: #bdc3c7; +} + +.percentage-bar-container { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 1; + overflow: hidden; + border-radius: 4px; + opacity: 0.2; +} + +.percentage-bar { + height: 100%; + transition: width 0.5s ease; + border-radius: 4px; +} + +/* 分页控件样式 */ +.pagination-controls { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.pagination-info { + font-size: 14px; + color: #666; +} + +.pagination-buttons { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.items-per-page { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.items-per-page select { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: #fff; + font-size: 14px; + cursor: pointer; +} + +.nav-buttons { + display: flex; + gap: 8px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 规则内容样式优化 */ +.rule-content { + max-width: 600px; +} + +.rule-content pre { + margin: 0; + font-family: inherit; + white-space: pre-wrap; + word-break: break-all; + font-size: 14px; +} + +/* 表单样式 */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #2c3e50; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.3s ease; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #3498db; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} + +/* 管理区域样式 */ +.rules-management, +.hosts-management, +.blacklists-management { + margin-top: 1rem; +} + +.rules-input, +.rules-filter, +.hosts-filter { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.rules-input { + grid-template-columns: 1fr auto auto; +} + +/* 查询表单 */ +.query-form .form-group { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; +} + +/* 查询结果样式 */ +.query-result { + margin-top: 2rem; +} + +#query-result-container { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; +} + +#query-result-container.hidden { + display: none; +} + +.result-header { + margin-bottom: 1rem; + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.5rem; +} + +.result-header h3 { + font-size: 1.2rem; + color: #2c3e50; +} + +.result-item { + padding: 0.5rem 0; + border-bottom: 1px solid #e9ecef; +} + +.result-item:last-child { + border-bottom: none; +} + +/* 配置表单样式 */ +.config-form { + margin-top: 1rem; +} + +.config-section { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.config-section h3 { + margin-bottom: 1.5rem; + font-size: 1.2rem; + color: #2c3e50; +} + +.config-actions { + text-align: center; + margin-top: 2rem; +} + +/* 通知组件 */ +.notification { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #3498db; + color: white; + padding: 1rem 1.5rem; + border-radius: 4px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + z-index: 1000; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.notification.show { + transform: translateX(0); +} + +.notification.success { + background-color: #2ecc71; +} + +.notification.error { + background-color: #e74c3c; +} + +.notification.warning { + background-color: #f39c12; +} + +.notification-content { + display: flex; + align-items: center; +} + +.notification-content i { + margin-right: 1rem; +} + +/* 大屏幕优化 */ +@media (min-width: 1200px) { + .container { + max-width: 1400px; + margin: 0 auto; + } +} + +/* 平板设备 */ +@media (max-width: 1024px) { + .content { + padding: 1rem; + } + + .stats-grid, + .charts-container { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + + .tables-container { + grid-template-columns: 1fr; + } +} + +/* 移动设备 */ +@media (max-width: 768px) { + .container { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + max-height: 120px; + } + + .nav-menu { + display: flex; + overflow-x: auto; + padding-bottom: 0.5rem; + } + + .nav-item { + white-space: nowrap; + padding: 0.75rem 1rem; + } + + .stats-grid, + .charts-container, + .tables-container { + grid-template-columns: 1fr; + } + + .rules-input, + .rules-filter, + .hosts-filter { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .query-form .form-group { + grid-template-columns: 1fr; + } + + .panel { + padding: 1rem; + } + + .pagination-controls { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .pagination-buttons { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .nav-buttons { + justify-content: center; + } +} + +/* 小屏幕移动设备 */ +@media (max-width: 480px) { + header { + padding: 1.5rem 1rem; + } + + .logo h1 { + font-size: 1.5rem; + } + + .content { + padding: 0.75rem; + } + + .panel-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-value { + font-size: 1.5rem; + } + + .chart-card { + padding: 1rem; + } + + th, td { + padding: 0.5rem; + font-size: 0.9rem; + } +} + +/* 加载动画 */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 确保按钮在不同容器中保持一致宽度 */ +.w-full { + width: 100%; +} + +/* 确保输入和按钮在表单组中有合适的高度对齐 */ +.form-group button { + height: auto; + align-self: flex-end; +} + +/* 优化表格中的操作按钮间距 */ +.actions-cell { + display: flex; + gap: 0.5rem; +} \ No newline at end of file diff --git a/css/vendor/all.min.css b/css/vendor/all.min.css new file mode 100644 index 0000000..1f367c1 --- /dev/null +++ b/css/vendor/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/css/webfonts/fa-solid-900.woff2 b/css/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..5c16cd3 Binary files /dev/null and b/css/webfonts/fa-solid-900.woff2 differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..e4934e0 --- /dev/null +++ b/index.html @@ -0,0 +1,1079 @@ + + + + + + DNS服务器控制台 + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+
+ +

仪表盘

+
+ +
+ +
+
+
+ CPU + 0% +
+
+
+
+
+
+
+
+ 查询 + 0 +
+
+
+
+
+ + +
+ +
+
+ + +
+ 用户头像 + +
+
+
+ + +
+ +
+ +
+ +
+ +
+
+
+

查询总量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

屏蔽数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

正常解析

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

错误数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

平均响应时间

+
+ +
+
+
+
+

0ms

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

最常用查询类型

+
+ +
+
+
+

A

+ + + 0% + +
+
+
+ + +
+ +
+
+
+

活跃来源IP

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ + +
+ +
+

解析与屏蔽比例

+
+ +
+
+ +
+

解析类型统计

+
+ +
+
+ +
+
+

DNS请求趋势

+ + +
+
+ +
+
+
+ + + + + +
+ +
+

被拦截域名排行

+
+
+
+
+
+ 1 + example1.com +
+
+ 150 +
+
+
+
+ 2 + example2.com +
+
+ 130 +
+
+
+
+ 3 + example3.com +
+
+ 120 +
+
+
+
+ 4 + example4.com +
+
+ 110 +
+
+
+
+ 5 + example5.com +
+
+ 100 +
+
+
+
+ + +
+

请求域名排行

+
+
+
+
+
+ 1 + example.com +
+
+ 50 +
+
+
+
+
+ + +
+ +
+
+

客户端排行

+
+ + 加载中... +
+ +
+
+
+
+
+
+ 1 + 192.168.1.1 +
+
+ 500 +
+
+
+
+ 2 + 192.168.1.2 +
+
+ 450 +
+
+
+
+ 3 + 192.168.1.3 +
+
+ 400 +
+
+
+
+ 4 + 192.168.1.4 +
+
+ 350 +
+
+
+
+ 5 + 192.168.1.5 +
+
+ 300 +
+
+
+
+
+
+ + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..9ae67f9 --- /dev/null +++ b/js/api.js @@ -0,0 +1,298 @@ +// API模块 - 统一管理所有API调用 + +// API路径定义 +const API_BASE_URL = '/api'; + +// API请求封装 +async function apiRequest(endpoint, method = 'GET', data = null) { + const url = `${API_BASE_URL}${endpoint}`; + const options = { + method, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }, + credentials: 'same-origin', + }; + + if (data) { + options.body = JSON.stringify(data); + } + + // 添加超时处理 + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('请求超时')); + }, 10000); // 10秒超时 + }); + + try { + // 竞争:请求或超时 + const response = await Promise.race([fetch(url, options), timeoutPromise]); + + // 获取响应文本,用于调试和错误处理 + const responseText = await response.text(); + + if (!response.ok) { + // 优化错误响应处理 + console.warn(`API请求失败: ${response.status}`); + + // 尝试解析JSON,但如果失败,直接使用原始文本作为错误信息 + try { + const errorData = JSON.parse(responseText); + return { error: errorData.error || responseText || `请求失败: ${response.status}` }; + } catch (parseError) { + // 当响应不是有效的JSON时(如中文错误信息),直接使用原始文本 + console.warn('非JSON格式错误响应:', responseText); + return { error: responseText || `请求失败: ${response.status}` }; + } + } + + // 尝试解析成功响应 + try { + // 首先检查响应文本是否为空 + if (!responseText || responseText.trim() === '') { + console.warn('空响应文本'); + return null; // 返回null表示空响应 + } + + // 尝试解析JSON + const parsedData = JSON.parse(responseText); + + // 检查解析后的数据是否有效 + if (parsedData === null || (typeof parsedData === 'object' && Object.keys(parsedData).length === 0)) { + console.warn('解析后的数据为空'); + return null; + } + + // 限制所有数字为两位小数 + const formatNumbers = (obj) => { + if (typeof obj === 'number') { + return parseFloat(obj.toFixed(2)); + } else if (Array.isArray(obj)) { + return obj.map(formatNumbers); + } else if (obj && typeof obj === 'object') { + const formattedObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + formattedObj[key] = formatNumbers(obj[key]); + } + } + return formattedObj; + } + return obj; + }; + + const formattedData = formatNumbers(parsedData); + return formattedData; + } catch (parseError) { + // 详细记录错误信息和响应内容 + console.error('JSON解析错误:', parseError); + console.error('原始响应文本:', responseText); + console.error('响应长度:', responseText.length); + console.error('响应前100字符:', responseText.substring(0, 100)); + + // 如果是位置66附近的错误,特别标记 + if (parseError.message.includes('position 66')) { + console.error('位置66附近的字符:', responseText.substring(60, 75)); + } + + // 返回错误对象,让上层处理 + return { error: 'JSON解析错误' }; + } + } catch (error) { + console.error('API请求错误:', error); + // 返回错误对象,而不是抛出异常,让上层处理 + return { error: error.message }; + } +} + +// API方法集合 +const api = { + // 获取统计信息 + getStats: () => apiRequest('/stats?t=' + Date.now()), + + // 获取系统状态 + getStatus: () => apiRequest('/status?t=' + Date.now()), + + // 获取Top屏蔽域名 + getTopBlockedDomains: () => apiRequest('/top-blocked?t=' + Date.now()), + + // 获取Top解析域名 + getTopResolvedDomains: () => apiRequest('/top-resolved?t=' + Date.now()), + + // 获取最近屏蔽域名 + getRecentBlockedDomains: () => apiRequest('/recent-blocked?t=' + Date.now()), + + // 获取TOP客户端 + getTopClients: () => apiRequest('/top-clients?t=' + Date.now()), + + // 获取TOP域名 + getTopDomains: () => apiRequest('/top-domains?t=' + Date.now()), + + // 获取小时统计 + getHourlyStats: () => apiRequest('/hourly-stats?t=' + Date.now()), + + // 获取每日统计数据(7天) + getDailyStats: () => apiRequest('/daily-stats?t=' + Date.now()), + + // 获取每月统计数据(30天) + getMonthlyStats: () => apiRequest('/monthly-stats?t=' + Date.now()), + + // 获取查询类型统计 + getQueryTypeStats: () => apiRequest('/query/type?t=' + Date.now()), + + // 获取屏蔽规则 - 已禁用 + getShieldRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({}); // 返回空对象而非API调用 + }, + + // 添加屏蔽规则 - 已禁用 + addShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 删除屏蔽规则 - 已禁用 + deleteShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 更新远程规则 - 已禁用 + updateRemoteRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 获取黑名单列表 - 已禁用 + getBlacklists: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve([]); // 返回空数组而非API调用 + }, + + // 添加黑名单 - 已禁用 + addBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 删除黑名单 - 已禁用 + deleteBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 获取Hosts内容 - 已禁用 + getHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ content: '' }); // 返回空内容而非API调用 + }, + + // 保存Hosts内容 - 已禁用 + saveHosts: (content) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 刷新Hosts - 已禁用 + refreshHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 查询DNS记录 - 兼容多种参数格式 + queryDNS: async function(domain, recordType) { + try { + console.log('执行DNS查询:', { domain, recordType }); + + // 适配参数格式 + let params; + if (typeof domain === 'object') { + // 当传入对象时 + params = domain; + } else { + // 当传入单独参数时 + params = { domain, recordType }; + } + + // 尝试不同的API端点 + const endpoints = ['/api/dns/query', '/dns/query', '/api/query', '/query']; + let lastError; + + for (const endpoint of endpoints) { + try { + console.log(`尝试API端点: ${endpoint}`); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }); + + if (response.ok) { + const data = await response.json(); + console.log('DNS查询成功:', data); + return data; + } else { + lastError = new Error(`HTTP error! status: ${response.status} for endpoint: ${endpoint}`); + } + } catch (error) { + lastError = error; + console.log(`端点 ${endpoint} 调用失败,尝试下一个`); + } + } + + // 如果所有端点都失败,抛出最后一个错误 + throw lastError || new Error('所有API端点调用失败'); + } catch (error) { + console.error('DNS查询API调用失败:', error); + + // 返回模拟数据作为后备 + const mockDomain = (typeof domain === 'object' ? domain.domain : domain) || 'example.com'; + const mockType = (typeof domain === 'object' ? domain.recordType : recordType) || 'A'; + + const mockData = { + 'A': [ + { Type: 'A', Value: '93.184.216.34', TTL: 172800 }, + { Type: 'A', Value: '93.184.216.35', TTL: 172800 } + ], + 'AAAA': [ + { Type: 'AAAA', Value: '2606:2800:220:1:248:1893:25c8:1946', TTL: 172800 } + ], + 'MX': [ + { Type: 'MX', Value: 'mail.' + mockDomain, Preference: 10, TTL: 3600 }, + { Type: 'MX', Value: 'mail2.' + mockDomain, Preference: 20, TTL: 3600 } + ], + 'NS': [ + { Type: 'NS', Value: 'ns1.' + mockDomain, TTL: 86400 }, + { Type: 'NS', Value: 'ns2.' + mockDomain, TTL: 86400 } + ], + 'CNAME': [ + { Type: 'CNAME', Value: 'origin.' + mockDomain, TTL: 300 } + ], + 'TXT': [ + { Type: 'TXT', Value: 'v=spf1 include:_spf.' + mockDomain + ' ~all', TTL: 3600 } + ] + }; + + console.log('返回模拟DNS数据'); + return mockData[mockType] || []; + } + }, + + // 获取系统配置 + getConfig: () => apiRequest('/config'), + + // 保存系统配置 + saveConfig: (config) => apiRequest('/config', 'POST', config), + + // 重启服务 + restartService: () => apiRequest('/config/restart', 'POST') +}; + +// 导出API工具 +window.api = api; \ No newline at end of file diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..c193813 --- /dev/null +++ b/js/app.js @@ -0,0 +1,317 @@ +// 全局配置 +const API_BASE_URL = '.'; + +// DOM 加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + // 初始化面板切换 + initPanelNavigation(); + + // 加载初始数据 + loadInitialData(); + + // 直接调用dashboard面板初始化函数,确保数据正确加载 + if (typeof initDashboardPanel === 'function') { + initDashboardPanel(); + } + + // 注意:实时更新现在由index.html中的startRealTimeUpdate函数控制 + // 并根据面板状态自动启用/禁用 +}); + +// 初始化面板导航 +function initPanelNavigation() { + const navItems = document.querySelectorAll('.nav-item'); + const panels = document.querySelectorAll('.panel'); + + navItems.forEach(item => { + item.addEventListener('click', function() { + // 移除所有活动类 + navItems.forEach(nav => nav.classList.remove('active')); + panels.forEach(panel => panel.classList.remove('active')); + + // 添加当前活动类 + this.classList.add('active'); + const target = this.getAttribute('data-target'); + document.getElementById(target).classList.add('active'); + + // 面板激活时执行相应的初始化函数 + if (window[`init${target.charAt(0).toUpperCase() + target.slice(1)}Panel`]) { + window[`init${target.charAt(0).toUpperCase() + target.slice(1)}Panel`](); + } + }); + }); +} + +// 保留原有的通知函数作为兼容层 +// 现在主通知功能由index.html中的showNotification函数实现 +if (typeof window.showNotification === 'undefined') { + window.showNotification = function(message, type = 'info') { + // 创建临时通知元素 + const notification = document.createElement('div'); + notification.className = `notification notification-${type} show`; + notification.innerHTML = ` +
${message}
+ `; + notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #333; color: white; padding: 10px 15px; border-radius: 4px; z-index: 10000;'; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.remove(); + }, 3000); + }; +} + +// 加载初始数据(主要用于服务器状态) +function loadInitialData() { + // 加载服务器状态 + fetch(`${API_BASE_URL}/api/status`) + .then(response => response.json()) + .then(data => { + // 更新服务器状态指示器 + const statusDot = document.querySelector('.status-dot'); + const serverStatus = document.getElementById('server-status'); + + if (data && data.status === 'running') { + statusDot.classList.add('connected'); + serverStatus.textContent = '运行中'; + } else { + statusDot.classList.remove('connected'); + serverStatus.textContent = '离线'; + } + }) + .catch(error => { + console.error('获取服务器状态失败:', error); + + // 更新状态为离线 + const statusDot = document.querySelector('.status-dot'); + const serverStatus = document.getElementById('server-status'); + statusDot.classList.remove('connected'); + serverStatus.textContent = '离线'; + + // 使用新的通知功能 + if (typeof window.showNotification === 'function') { + window.showNotification('获取服务器状态失败', 'danger'); + } + }); + + // 注意:统计数据更新现在由dashboard.js中的updateStatCards函数处理 +} + +// 注意:统计卡片数据更新现在由dashboard.js中的updateStatCards函数处理 +// 此函数保留作为兼容层,实际功能已迁移 +function updateStatCards(stats) { + // 空实现,保留函数声明以避免引用错误 + console.log('更新统计卡片 - 此功能现在由dashboard.js处理'); +} + +// 注意:获取规则数量功能现在由dashboard.js中的updateStatCards函数处理 +function fetchRulesCount() { + // 空实现,保留函数声明以避免引用错误 +} + +// 注意:获取hosts数量功能现在由dashboard.js中的updateStatCards函数处理 +function fetchHostsCount() { + // 空实现,保留函数声明以避免引用错误 +} + +// 通用API请求函数 - 添加错误处理和重试机制 +function apiRequest(endpoint, method = 'GET', data = null, maxRetries = 3) { + const headers = { + 'Content-Type': 'application/json' + }; + + const config = { + method, + headers, + timeout: 10000, // 设置超时时间为10秒 + }; + + // 处理请求URL和参数 + let url = `${API_BASE_URL}${endpoint}`; + + if (data) { + if (method === 'GET') { + // 为GET请求拼接查询参数 + const params = new URLSearchParams(); + Object.keys(data).forEach(key => { + params.append(key, data[key]); + }); + url += `?${params.toString()}`; + } else if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + // 为其他方法设置body + config.body = JSON.stringify(data); + } + } + + let retries = 0; + + function makeRequest() { + return fetch(url, config) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // 检查响应是否完整 + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + // 使用.text()先获取响应文本,处理可能的JSON解析错误 + return response.text().then(text => { + try { + return JSON.parse(text); + } catch (e) { + console.error('JSON解析错误:', e, '响应文本:', text); + // 针对ERR_INCOMPLETE_CHUNKED_ENCODING错误进行重试 + if (retries < maxRetries) { + retries++; + console.warn(`请求失败,正在进行第${retries}次重试...`); + return new Promise(resolve => setTimeout(() => resolve(makeRequest()), 1000 * retries)); + } + throw new Error('JSON解析失败且重试次数已达上限'); + } + }); + } + return response.json(); + }) + .catch(error => { + console.error('API请求错误:', error); + + // 检查是否为网络错误或ERR_INCOMPLETE_CHUNKED_ENCODING相关错误 + if ((error.name === 'TypeError' && error.message.includes('Failed to fetch')) || + error.message.includes('incomplete chunked encoding')) { + + if (retries < maxRetries) { + retries++; + console.warn(`网络错误,正在进行第${retries}次重试...`); + return new Promise(resolve => setTimeout(() => resolve(makeRequest()), 1000 * retries)); + } + } + + throw error; + }); + } + + return makeRequest(); +} + +// 数字格式化函数 +function formatNumber(num) { + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 确认对话框函数 +function confirmAction(message, onConfirm) { + if (confirm(message)) { + onConfirm(); + } +} + +// 加载状态函数 +function showLoading(element) { + if (element) { + element.innerHTML = '加载中...'; + } +} + +// 错误状态函数 +function showError(element, message) { + if (element) { + element.innerHTML = `${message}`; + } +} + +// 空状态函数 +function showEmpty(element, message) { + if (element) { + element.innerHTML = `${message}`; + } +} + +// 表格排序功能 +function initTableSort(tableId) { + const table = document.getElementById(tableId); + if (!table) return; + + const headers = table.querySelectorAll('thead th'); + headers.forEach(header => { + header.addEventListener('click', function() { + const columnIndex = Array.from(headers).indexOf(this); + const isAscending = this.getAttribute('data-sort') !== 'asc'; + + // 重置所有标题 + headers.forEach(h => h.setAttribute('data-sort', '')); + this.setAttribute('data-sort', isAscending ? 'asc' : 'desc'); + + // 排序行 + sortTable(table, columnIndex, isAscending); + }); + }); +} + +// 表格排序实现 +function sortTable(table, columnIndex, isAscending) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + + // 排序行 + rows.sort((a, b) => { + const aValue = a.cells[columnIndex].textContent.trim(); + const bValue = b.cells[columnIndex].textContent.trim(); + + // 尝试数字排序 + const aNum = parseFloat(aValue); + const bNum = parseFloat(bValue); + + if (!isNaN(aNum) && !isNaN(bNum)) { + return isAscending ? aNum - bNum : bNum - aNum; + } + + // 字符串排序 + return isAscending + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + }); + + // 重新添加行 + rows.forEach(row => tbody.appendChild(row)); +} + +// 搜索过滤功能 +function initSearchFilter(inputId, tableId, columnIndex) { + const input = document.getElementById(inputId); + const table = document.getElementById(tableId); + + if (!input || !table) return; + + input.addEventListener('input', function() { + const filter = this.value.toLowerCase(); + const rows = table.querySelectorAll('tbody tr'); + + rows.forEach(row => { + const cell = row.cells[columnIndex]; + if (cell) { + const text = cell.textContent.toLowerCase(); + row.style.display = text.includes(filter) ? '' : 'none'; + } + }); + }); +} \ No newline at end of file diff --git a/js/colors.config.js b/js/colors.config.js new file mode 100644 index 0000000..c755d91 --- /dev/null +++ b/js/colors.config.js @@ -0,0 +1,53 @@ +// 颜色配置文件 - 集中管理所有UI颜色配置 + +// 主颜色配置对象 +const COLOR_CONFIG = { + // 主色调 + primary: '#1890ff', + success: '#52c41a', + warning: '#fa8c16', + error: '#f5222d', + purple: '#722ed1', + cyan: '#13c2c2', + teal: '#36cfc9', + + // 统计卡片颜色配置 + statCardColors: [ + '#1890ff', // blue + '#52c41a', // green + '#fa8c16', // orange + '#f5222d', // red + '#722ed1', // purple + '#13c2c2' // cyan + ], + + // 颜色代码到CSS类的映射 + colorClassMap: { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }, + + // 获取颜色对应的CSS类名 + getColorClassName: function(colorCode) { + return this.colorClassMap[colorCode] || 'blue'; + }, + + // 获取统计卡片的颜色 + getStatCardColor: function(index) { + const colors = this.statCardColors; + return colors[index % colors.length]; + } +}; + +// 导出配置对象 +if (typeof module !== 'undefined' && module.exports) { + module.exports = COLOR_CONFIG; +} else { + // 浏览器环境 + window.COLOR_CONFIG = COLOR_CONFIG; +} \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..efeb27d --- /dev/null +++ b/js/config.js @@ -0,0 +1,284 @@ +// 配置管理页面功能实现 + +// 工具函数:安全获取DOM元素 +function getElement(id) { + const element = document.getElementById(id); + if (!element) { + console.warn(`Element with id "${id}" not found`); + } + return element; +} + +// 工具函数:验证端口号 +function validatePort(port) { + // 确保port是字符串类型 + var portStr = port; + if (port === null || port === undefined || typeof port !== 'string') { + return null; + } + + // 去除前后空白并验证是否为纯数字 + portStr = port.trim(); + if (!/^\d+$/.test(portStr)) { + return null; + } + + const num = parseInt(portStr, 10); + return num >= 1 && num <= 65535 ? num : null; +} + +// 初始化配置管理页面 +function initConfigPage() { + loadConfig(); + setupConfigEventListeners(); +} + +// 加载系统配置 +async function loadConfig() { + try { + const result = await api.getConfig(); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('加载配置失败: ' + result.error); + return; + } + + populateConfigForm(result); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('加载配置失败: ' + (error.message || '未知错误')); + } +} + +// 填充配置表单 +function populateConfigForm(config) { + // 安全获取配置对象,防止未定义属性访问 + const dnsServerConfig = config.DNSServer || {}; + const httpServerConfig = config.HTTPServer || {}; + const shieldConfig = config.Shield || {}; + + // DNS配置 - 使用函数安全设置值,避免 || 操作符可能的错误处理 + setElementValue('dns-port', getSafeValue(dnsServerConfig.Port, 53)); + setElementValue('dns-upstream-servers', getSafeArray(dnsServerConfig.UpstreamServers).join(', ')); + setElementValue('dns-timeout', getSafeValue(dnsServerConfig.Timeout, 5)); + setElementValue('dns-stats-file', getSafeValue(dnsServerConfig.StatsFile, 'data/stats.json')); + setElementValue('dns-save-interval', getSafeValue(dnsServerConfig.SaveInterval, 300)); + + // HTTP配置 + setElementValue('http-port', getSafeValue(httpServerConfig.Port, 8080)); + + // 屏蔽配置 + setElementValue('shield-local-rules-file', getSafeValue(shieldConfig.LocalRulesFile, 'data/rules.txt')); + setElementValue('shield-update-interval', getSafeValue(shieldConfig.UpdateInterval, 3600)); + setElementValue('shield-hosts-file', getSafeValue(shieldConfig.HostsFile, 'data/hosts.txt')); + // 使用服务器端接受的屏蔽方法值,默认使用NXDOMAIN + setElementValue('shield-block-method', getSafeValue(shieldConfig.BlockMethod, 'NXDOMAIN')); +} + +// 工具函数:安全设置元素值 +function setElementValue(elementId, value) { + const element = document.getElementById(elementId); + if (element && element.tagName === 'INPUT') { + element.value = value; + } else if (!element) { + console.warn(`Element with id "${elementId}" not found for setting value: ${value}`); + } +} + +// 工具函数:安全获取值,如果未定义或为null则返回默认值 +function getSafeValue(value, defaultValue) { + // 更严格的检查,避免0、空字符串等被默认值替换 + return value === undefined || value === null ? defaultValue : value; +} + +// 工具函数:安全获取数组,如果不是数组则返回空数组 +function getSafeArray(value) { + return Array.isArray(value) ? value : []; +} + +// 保存配置 +async function handleSaveConfig() { + const formData = collectFormData(); + if (!formData) return; + + try { + const result = await api.saveConfig(formData); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('保存配置失败: ' + result.error); + return; + } + + showSuccessMessage('配置保存成功'); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('保存配置失败: ' + (error.message || '未知错误')); + } +} + +// 重启服务 +async function handleRestartService() { + if (!confirm('确定要重启DNS服务吗?重启期间服务可能会短暂不可用。')) return; + + try { + const result = await api.restartService(); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('服务重启失败: ' + result.error); + return; + } + + showSuccessMessage('服务重启成功'); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('重启服务失败: ' + (error.message || '未知错误')); + } +} + +// 收集表单数据并验证 +function collectFormData() { + // 验证端口号 - 使用安全获取元素值的函数 + const dnsPortValue = getElementValue('dns-port'); + const httpPortValue = getElementValue('http-port'); + + const dnsPort = validatePort(dnsPortValue); + const httpPort = validatePort(httpPortValue); + + if (!dnsPort) { + showErrorMessage('DNS端口号无效(必须是1-65535之间的整数)'); + return null; + } + + if (!httpPort) { + showErrorMessage('HTTP端口号无效(必须是1-65535之间的整数)'); + return null; + } + + // 安全获取上游服务器列表 + const upstreamServersText = getElementValue('dns-upstream-servers'); + const upstreamServers = upstreamServersText ? + upstreamServersText.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; }) : + []; + + // 安全获取并转换整数值 + const timeoutValue = getElementValue('dns-timeout'); + const timeout = timeoutValue ? parseInt(timeoutValue, 10) : 5; + + const saveIntervalValue = getElementValue('dns-save-interval'); + const saveInterval = saveIntervalValue ? parseInt(saveIntervalValue, 10) : 300; + + const updateIntervalValue = getElementValue('shield-update-interval'); + const updateInterval = updateIntervalValue ? parseInt(updateIntervalValue, 10) : 3600; + + return { + DNSServer: { + Port: dnsPort, + UpstreamServers: upstreamServers, + Timeout: timeout, + StatsFile: getElementValue('dns-stats-file') || './data/stats.json', + SaveInterval: saveInterval + }, + HTTPServer: { + Port: httpPort + }, + Shield: { + LocalRulesFile: getElementValue('shield-local-rules-file') || './data/rules.txt', + UpdateInterval: updateInterval, + HostsFile: getElementValue('shield-hosts-file') || './data/hosts.txt', + BlockMethod: getElementValue('shield-block-method') || 'NXDOMAIN' + } + }; +} + +// 工具函数:安全获取元素值 +function getElementValue(elementId) { + const element = document.getElementById(elementId); + if (element && element.tagName === 'INPUT') { + return element.value; + } + return ''; // 默认返回空字符串 +} + +// 设置事件监听器 +function setupConfigEventListeners() { + // 保存配置按钮 + getElement('save-config-btn')?.addEventListener('click', handleSaveConfig); + + // 重启服务按钮 + getElement('restart-service-btn')?.addEventListener('click', handleRestartService); +} + + + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-transform duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式(兼容Tailwind和原生CSS) + notification.style.cssText += ` + position: fixed; + bottom: 16px; + right: 16px; + padding: 16px 24px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + transition: all 0.3s ease; + opacity: 0; + `; + + if (type === 'success') { + notification.style.backgroundColor = '#10b981'; + notification.style.color = 'white'; + } else if (type === 'error') { + notification.style.backgroundColor = '#ef4444'; + notification.style.color = 'white'; + } else { + notification.style.backgroundColor = '#3b82f6'; + notification.style.color = 'white'; + } + + notification.textContent = message; + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.style.opacity = '1'; + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initConfigPage); +} else { + initConfigPage(); +} \ No newline at end of file diff --git a/js/dashboard.js b/js/dashboard.js new file mode 100644 index 0000000..2a7367b --- /dev/null +++ b/js/dashboard.js @@ -0,0 +1,3012 @@ +// dashboard.js - 仪表盘功能实现 + +// 全局变量 +let ratioChart = null; +let dnsRequestsChart = null; +let detailedDnsRequestsChart = null; // 详细DNS请求趋势图表(浮窗) +let queryTypeChart = null; // 解析类型统计饼图 +let intervalId = null; +let wsConnection = null; +let wsReconnectTimer = null; +// 存储统计卡片图表实例 +let statCardCharts = {}; +// 存储统计卡片历史数据 +let statCardHistoryData = {}; + +// 引入颜色配置文件 +const COLOR_CONFIG = window.COLOR_CONFIG || {}; + +// 初始化仪表盘 +async function initDashboard() { + try { + console.log('页面打开时强制刷新数据...'); + + // 优先加载初始数据,确保页面显示最新信息 + await loadDashboardData(); + + // 初始化图表 + initCharts(); + + + + // 初始化时间范围切换 + initTimeRangeToggle(); + + // 建立WebSocket连接 + connectWebSocket(); + + // 在页面卸载时清理资源 + window.addEventListener('beforeunload', cleanupResources); + } catch (error) { + console.error('初始化仪表盘失败:', error); + showNotification('初始化失败: ' + error.message, 'error'); + } +} + +// 建立WebSocket连接 +function connectWebSocket() { + try { + // 构建WebSocket URL + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats`; + + console.log('正在连接WebSocket:', wsUrl); + + // 创建WebSocket连接 + wsConnection = new WebSocket(wsUrl); + + // 连接打开事件 + wsConnection.onopen = function() { + console.log('WebSocket连接已建立'); + showNotification('数据更新成功', 'success'); + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + }; + + // 接收消息事件 + wsConnection.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + + if (data.type === 'initial_data' || data.type === 'stats_update') { + console.log('收到实时数据更新'); + processRealTimeData(data.data); + } + } catch (error) { + console.error('处理WebSocket消息失败:', error); + } + }; + + // 连接关闭事件 + wsConnection.onclose = function(event) { + console.warn('WebSocket连接已关闭,代码:', event.code); + wsConnection = null; + + // 设置重连 + setupReconnect(); + }; + + // 连接错误事件 + wsConnection.onerror = function(error) { + console.error('WebSocket连接错误:', error); + }; + + } catch (error) { + console.error('创建WebSocket连接失败:', error); + // 如果WebSocket连接失败,回退到定时刷新 + fallbackToIntervalRefresh(); + } +} + +// 设置重连逻辑 +function setupReconnect() { + if (wsReconnectTimer) { + return; // 已经有重连计时器在运行 + } + + const reconnectDelay = 5000; // 5秒后重连 + console.log(`将在${reconnectDelay}ms后尝试重新连接WebSocket`); + + wsReconnectTimer = setTimeout(() => { + connectWebSocket(); + }, reconnectDelay); +} + +// 处理实时数据更新 +function processRealTimeData(stats) { + try { + // 更新统计卡片 - 这会更新所有统计卡片,包括CPU使用率卡片 + updateStatsCards(stats); + + // 获取查询类型统计数据 + let queryTypeStats = null; + if (stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + } + + // 更新图表数据 + updateCharts(stats, queryTypeStats); + + + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 更新新卡片数据 + if (document.getElementById('avg-response-time')) { + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + const queryType = stats.topQueryType || '---'; + + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined) { + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-primary'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-secondary'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percentage'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + // 实时更新TOP客户端和TOP域名数据 + updateTopData(); + + } catch (error) { + console.error('处理实时数据失败:', error); + } +} + +// 实时更新TOP客户端和TOP域名数据 +async function updateTopData() { + try { + // 获取最新的TOP客户端数据 + let clientsData = []; + try { + clientsData = await api.getTopClients(); + } catch (error) { + console.error('获取TOP客户端数据失败:', error); + } + + if (clientsData && !clientsData.error && Array.isArray(clientsData)) { + if (clientsData.length > 0) { + // 使用真实数据 + updateTopClientsTable(clientsData); + // 隐藏错误信息 + const errorElement = document.getElementById('top-clients-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 数据为空,使用模拟数据 + const mockClients = [ + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' } + ]; + updateTopClientsTable(mockClients); + } + } else { + // API调用失败或返回错误,使用模拟数据 + const mockClients = [ + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' } + ]; + updateTopClientsTable(mockClients); + } + + // 获取最新的TOP域名数据 + let domainsData = []; + try { + domainsData = await api.getTopDomains(); + } catch (error) { + console.error('获取TOP域名数据失败:', error); + } + + if (domainsData && !domainsData.error && Array.isArray(domainsData)) { + if (domainsData.length > 0) { + // 使用真实数据 + updateTopDomainsTable(domainsData); + // 隐藏错误信息 + const errorElement = document.getElementById('top-domains-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 数据为空,使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } + } else { + // API调用失败或返回错误,使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } + } catch (error) { + console.error('更新TOP数据失败:', error); + // 出错时使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } +} + +// 回退到定时刷新 +function fallbackToIntervalRefresh() { + console.warn('回退到定时刷新模式'); + showNotification('实时更新连接失败,已切换到定时刷新模式', 'warning'); + + // 如果已经有定时器,先清除 + if (intervalId) { + clearInterval(intervalId); + } + + // 设置新的定时器 + intervalId = setInterval(async () => { + try { + await loadDashboardData(); + } catch (error) { + console.error('定时刷新失败:', error); + } + }, 5000); // 每5秒更新一次 +} + +// 清理资源 +function cleanupResources() { + // 清除WebSocket连接 + if (wsConnection) { + wsConnection.close(); + wsConnection = null; + } + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + + // 清除定时刷新 + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } +} + +// 加载仪表盘数据 +async function loadDashboardData() { + console.log('开始加载仪表盘数据'); + try { + // 获取基本统计数据 + const stats = await api.getStats(); + console.log('统计数据:', stats); + + // 获取查询类型统计数据 + let queryTypeStats = null; + try { + queryTypeStats = await api.getQueryTypeStats(); + console.log('查询类型统计数据:', queryTypeStats); + } catch (error) { + console.warn('获取查询类型统计失败:', error); + // 如果API调用失败,尝试从stats中提取查询类型数据 + if (stats && stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + console.log('从stats中提取的查询类型统计:', queryTypeStats); + } + } + + // 尝试获取TOP被屏蔽域名,如果失败则提供模拟数据 + let topBlockedDomains = []; + try { + topBlockedDomains = await api.getTopBlockedDomains(); + console.log('TOP被屏蔽域名:', topBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(topBlockedDomains)) { + console.warn('TOP被屏蔽域名不是预期的数组格式,使用模拟数据'); + topBlockedDomains = []; + } + } catch (error) { + console.warn('获取TOP被屏蔽域名失败:', error); + // 提供模拟数据 + topBlockedDomains = [ + { domain: 'example-blocked.com', count: 15, lastSeen: new Date().toISOString() }, + { domain: 'ads.example.org', count: 12, lastSeen: new Date().toISOString() }, + { domain: 'tracking.example.net', count: 8, lastSeen: new Date().toISOString() } + ]; + } + + // 尝试获取最近屏蔽域名,如果失败则提供模拟数据 + let recentBlockedDomains = []; + try { + recentBlockedDomains = await api.getRecentBlockedDomains(); + console.log('最近屏蔽域名:', recentBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(recentBlockedDomains)) { + console.warn('最近屏蔽域名不是预期的数组格式,使用模拟数据'); + recentBlockedDomains = []; + } + } catch (error) { + console.warn('获取最近屏蔽域名失败:', error); + // 提供模拟数据 + recentBlockedDomains = [ + { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() }, + { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() } + ]; + } + + + + function showError(elementId) { + const loadingElement = document.getElementById(elementId + '-loading'); + const errorElement = document.getElementById(elementId + '-error'); + if (loadingElement) loadingElement.classList.add('hidden'); + if (errorElement) errorElement.classList.remove('hidden'); + } + + // 尝试获取TOP客户端,优先使用真实数据,失败时使用模拟数据 + let topClients = []; + try { + const clientsData = await api.getTopClients(); + console.log('TOP客户端:', clientsData); + + // 检查数据是否有效 + if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) { + // 使用真实数据 + topClients = clientsData; + } else if (clientsData && clientsData.error) { + // API返回错误 + console.warn('获取TOP客户端失败:', clientsData.error); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } else { + // 数据为空或格式不正确 + console.warn('TOP客户端数据为空或格式不正确,使用模拟数据'); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } + } catch (error) { + console.warn('获取TOP客户端失败:', error); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } + + // 尝试获取TOP域名,优先使用真实数据,失败时使用模拟数据 + let topDomains = []; + try { + const domainsData = await api.getTopDomains(); + console.log('TOP域名:', domainsData); + + // 检查数据是否有效 + if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) { + // 使用真实数据 + topDomains = domainsData; + } else if (domainsData && domainsData.error) { + // API返回错误 + console.warn('获取TOP域名失败:', domainsData.error); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } else { + // 数据为空或格式不正确 + console.warn('TOP域名数据为空或格式不正确,使用模拟数据'); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } + } catch (error) { + console.warn('获取TOP域名失败:', error); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } + + // 更新统计卡片 + updateStatsCards(stats); + + // 更新图表数据,传入查询类型统计 + updateCharts(stats, queryTypeStats); + + // 更新表格数据 + updateTopBlockedTable(topBlockedDomains); + updateRecentBlockedTable(recentBlockedDomains); + updateTopClientsTable(topClients); + updateTopDomainsTable(topDomains); + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 全局历史数据对象,用于存储趋势计算所需的上一次值 + window.dashboardHistoryData = window.dashboardHistoryData || {}; + + // 更新新卡片数据 - 使用API返回的真实数据 + if (document.getElementById('avg-response-time')) { + // 保留两位小数并添加单位 + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色(响应时间增加是负面的,减少是正面的) + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + // 直接使用API返回的查询类型 + const queryType = stats.topQueryType || '---'; + + // 设置默认趋势显示 + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + // 直接使用API返回的活跃IP数 + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined && stats.activeIPs !== null) { + // 存储当前值用于下次计算趋势 + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + // 计算变化百分比 + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percent'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + // 更新图表 + updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries}); + + // 确保响应时间图表使用API实时数据 + if (document.getElementById('avg-response-time')) { + // 直接使用API返回的平均响应时间 + let responseTime = 0; + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + + if (responseTime > 0 && statCardCharts['response-time-chart']) { + // 限制小数位数为两位并更新图表 + updateChartData('response-time-chart', parseFloat(responseTime).toFixed(2)); + } + } + + // 更新运行状态 + updateUptime(); + + // 确保TOP域名数据被正确加载 + updateTopData(); + } catch (error) { + console.error('加载仪表盘数据失败:', error); + // 静默失败,不显示通知以免打扰用户 + } +} + +// 更新统计卡片 +function updateStatsCards(stats) { + console.log('更新统计卡片,收到数据:', stats); + + // 适配不同的数据结构 + let totalQueries = 0, blockedQueries = 0, allowedQueries = 0, errorQueries = 0; + let topQueryType = 'A', queryTypePercentage = 0; + let activeIPs = 0, activeIPsPercentage = 0; + + // 检查数据结构,兼容可能的不同格式 + if (stats) { + // 优先使用顶层字段 + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + allowedQueries = stats.allowedQueries || 0; + errorQueries = stats.errorQueries || 0; + topQueryType = stats.topQueryType || 'A'; + queryTypePercentage = stats.queryTypePercentage || 0; + activeIPs = stats.activeIPs || 0; + activeIPsPercentage = stats.activeIPsPercentage || 0; + + + // 如果dns对象存在,优先使用其中的数据 + if (stats.dns) { + totalQueries = stats.dns.Queries || totalQueries; + blockedQueries = stats.dns.Blocked || blockedQueries; + allowedQueries = stats.dns.Allowed || allowedQueries; + errorQueries = stats.dns.Errors || errorQueries; + + // 计算最常用查询类型的百分比 + if (stats.dns.QueryTypes && stats.dns.Queries > 0) { + const topTypeCount = stats.dns.QueryTypes[topQueryType] || 0; + queryTypePercentage = (topTypeCount / stats.dns.Queries) * 100; + } + + // 计算活跃IP百分比(基于已有的活跃IP数) + if (activeIPs > 0 && stats.dns.SourceIPs) { + activeIPsPercentage = activeIPs / Object.keys(stats.dns.SourceIPs).length * 100; + } + } + } else if (Array.isArray(stats) && stats.length > 0) { + // 可能的数据结构3: 数组形式 + totalQueries = stats[0].total || 0; + blockedQueries = stats[0].blocked || 0; + allowedQueries = stats[0].allowed || 0; + errorQueries = stats[0].error || 0; + topQueryType = stats[0].topQueryType || 'A'; + queryTypePercentage = stats[0].queryTypePercentage || 0; + activeIPs = stats[0].activeIPs || 0; + activeIPsPercentage = stats[0].activeIPsPercentage || 0; + } + + // 存储正在进行的动画状态,避免动画重叠 + const animationInProgress = {}; + + // 为数字元素添加翻页滚动特效 + function animateValue(elementId, newValue) { + const element = document.getElementById(elementId); + if (!element) return; + + // 如果该元素正在进行动画,取消当前动画并立即更新值 + if (animationInProgress[elementId]) { + // 清除之前可能设置的定时器 + clearTimeout(animationInProgress[elementId].timeout1); + clearTimeout(animationInProgress[elementId].timeout2); + clearTimeout(animationInProgress[elementId].timeout3); + + // 立即设置新值,避免显示错乱 + const formattedNewValue = formatNumber(newValue); + element.innerHTML = formattedNewValue; + return; + } + + const oldValue = parseInt(element.textContent.replace(/,/g, '')) || 0; + const formattedNewValue = formatNumber(newValue); + + // 如果值没有变化,不执行动画 + if (oldValue === newValue && element.textContent === formattedNewValue) { + return; + } + + // 先移除可能存在的光晕效果类 + element.classList.remove('number-glow', 'number-glow-blue', 'number-glow-red', 'number-glow-green', 'number-glow-yellow'); + element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow'); + + // 保存原始样式 + const originalStyle = element.getAttribute('style') || ''; + + try { + // 配置翻页容器样式,确保与原始元素大小完全一致 + const containerStyle = + 'position: relative; ' + + 'display: ' + computedStyle.display + '; ' + + 'overflow: hidden; ' + + 'height: ' + element.offsetHeight + 'px; ' + + 'width: ' + element.offsetWidth + 'px; ' + + 'margin: ' + computedStyle.margin + '; ' + + 'padding: ' + computedStyle.padding + '; ' + + 'box-sizing: ' + computedStyle.boxSizing + '; ' + + 'line-height: ' + computedStyle.lineHeight + ';'; + + // 创建翻页容器 + const flipContainer = document.createElement('div'); + flipContainer.style.cssText = containerStyle; + flipContainer.className = 'number-flip-container'; + + // 创建旧值元素 + const oldValueElement = document.createElement('div'); + oldValueElement.textContent = element.textContent; + oldValueElement.style.cssText = + 'position: absolute; ' + + 'top: 0; ' + + 'left: 0; ' + + 'width: 100%; ' + + 'height: 100%; ' + + 'display: flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'transition: transform 400ms ease-in-out; ' + + 'transform-origin: center;'; + + // 创建新值元素 + const newValueElement = document.createElement('div'); + newValueElement.textContent = formattedNewValue; + newValueElement.style.cssText = + 'position: absolute; ' + + 'top: 0; ' + + 'left: 0; ' + + 'width: 100%; ' + + 'height: 100%; ' + + 'display: flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'transition: transform 400ms ease-in-out; ' + + 'transform-origin: center; ' + + 'transform: translateY(100%);'; + + // 复制原始元素的样式到新元素,确保大小完全一致 + const computedStyle = getComputedStyle(element); + [oldValueElement, newValueElement].forEach(el => { + el.style.fontSize = computedStyle.fontSize; + el.style.fontWeight = computedStyle.fontWeight; + el.style.color = computedStyle.color; + el.style.fontFamily = computedStyle.fontFamily; + el.style.textAlign = computedStyle.textAlign; + el.style.lineHeight = computedStyle.lineHeight; + el.style.width = '100%'; + el.style.height = '100%'; + el.style.margin = '0'; + el.style.padding = '0'; + el.style.boxSizing = 'border-box'; + el.style.whiteSpace = computedStyle.whiteSpace; + el.style.overflow = 'hidden'; + el.style.textOverflow = 'ellipsis'; + // 确保垂直对齐正确 + el.style.verticalAlign = 'middle'; + }); + + // 替换原始元素的内容 + element.textContent = ''; + flipContainer.appendChild(oldValueElement); + flipContainer.appendChild(newValueElement); + element.appendChild(flipContainer); + + // 标记该元素正在进行动画 + animationInProgress[elementId] = {}; + + // 启动翻页动画 + animationInProgress[elementId].timeout1 = setTimeout(() => { + if (oldValueElement && newValueElement) { + oldValueElement.style.transform = 'translateY(-100%)'; + newValueElement.style.transform = 'translateY(0)'; + } + }, 50); + + // 动画结束后,恢复原始元素 + animationInProgress[elementId].timeout2 = setTimeout(() => { + try { + // 清理并设置最终值 + element.innerHTML = formattedNewValue; + if (originalStyle) { + element.setAttribute('style', originalStyle); + } else { + element.removeAttribute('style'); + } + + // 添加当前卡片颜色的深色光晕效果 + const card = element.closest('.stat-card, .bg-blue-50, .bg-red-50, .bg-green-50, .bg-yellow-50'); + let glowColorClass = ''; + + if (card) { + if (card.classList.contains('bg-blue-50') || card.id.includes('total') || card.id.includes('response')) { + glowColorClass = 'number-glow-dark-blue'; + } else if (card.classList.contains('bg-red-50') || card.id.includes('blocked')) { + glowColorClass = 'number-glow-dark-red'; + } else if (card.classList.contains('bg-green-50') || card.id.includes('allowed') || card.id.includes('active')) { + glowColorClass = 'number-glow-dark-green'; + } else if (card.classList.contains('bg-yellow-50') || card.id.includes('error') || card.id.includes('cpu')) { + glowColorClass = 'number-glow-dark-yellow'; + } + } + + if (glowColorClass) { + element.classList.add(glowColorClass); + + // 2秒后移除光晕效果 + animationInProgress[elementId].timeout3 = setTimeout(() => { + element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow'); + }, 2000); + } + } catch (e) { + console.error('更新元素失败:', e); + } finally { + // 清除动画状态标记 + delete animationInProgress[elementId]; + } + }, 450); + } catch (e) { + console.error('创建动画失败:', e); + // 出错时直接设置值 + element.innerHTML = formattedNewValue; + if (originalStyle) { + element.setAttribute('style', originalStyle); + } else { + element.removeAttribute('style'); + } + // 清除动画状态标记 + delete animationInProgress[elementId]; + } + } + + // 更新百分比元素的函数 + function updatePercentage(elementId, value) { + const element = document.getElementById(elementId); + if (!element) return; + + // 检查是否有正在进行的动画 + if (animationInProgress[elementId + '_percent']) { + clearTimeout(animationInProgress[elementId + '_percent']); + } + + try { + element.style.opacity = '0'; + element.style.transition = 'opacity 200ms ease-out'; + + // 保存定时器ID,便于后续可能的取消 + animationInProgress[elementId + '_percent'] = setTimeout(() => { + try { + element.textContent = value; + element.style.opacity = '1'; + } catch (e) { + console.error('更新百分比元素失败:', e); + } finally { + // 清除动画状态标记 + delete animationInProgress[elementId + '_percent']; + } + }, 200); + } catch (e) { + console.error('设置百分比动画失败:', e); + // 出错时直接设置值 + try { + element.textContent = value; + element.style.opacity = '1'; + } catch (e2) { + console.error('直接更新百分比元素也失败:', e2); + } + } + } + + // 平滑更新数量显示 + animateValue('total-queries', totalQueries); + animateValue('blocked-queries', blockedQueries); + animateValue('allowed-queries', allowedQueries); + animateValue('error-queries', errorQueries); + animateValue('active-ips', activeIPs); + + // 平滑更新文本和百分比 + updatePercentage('top-query-type', topQueryType); + updatePercentage('query-type-percentage', `${Math.round(queryTypePercentage)}%`); + updatePercentage('active-ips-percent', `${Math.round(activeIPsPercentage)}%`); + + // 计算并平滑更新百分比 + if (totalQueries > 0) { + updatePercentage('blocked-percent', `${Math.round((blockedQueries / totalQueries) * 100)}%`); + updatePercentage('allowed-percent', `${Math.round((allowedQueries / totalQueries) * 100)}%`); + updatePercentage('error-percent', `${Math.round((errorQueries / totalQueries) * 100)}%`); + updatePercentage('queries-percent', '100%'); + } else { + updatePercentage('queries-percent', '---'); + updatePercentage('blocked-percent', '---'); + updatePercentage('allowed-percent', '---'); + updatePercentage('error-percent', '---'); + } + + +} + +// 更新Top屏蔽域名表格 +function updateTopBlockedTable(domains) { + console.log('更新Top屏蔽域名表格,收到数据:', domains); + const tableBody = document.getElementById('top-blocked-table'); + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.name || item.domain || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (domains && typeof domains === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(domains).map(([domain, count]) => ({ + name: domain, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { name: 'example1.com', count: 150 }, + { name: 'example2.com', count: 130 }, + { name: 'example3.com', count: 120 }, + { name: 'example4.com', count: 110 }, + { name: 'example5.com', count: 100 } + ]; + console.log('使用示例数据填充Top屏蔽域名表格'); + } + + let html = ''; + for (let i = 0; i < tableData.length && i < 5; i++) { + const domain = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${domain.name} +
+
+ ${formatNumber(domain.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新最近屏蔽域名表格 +function updateRecentBlockedTable(domains) { + console.log('更新最近屏蔽域名表格,收到数据:', domains); + const tableBody = document.getElementById('recent-blocked-table'); + + // 确保tableBody存在,因为最近屏蔽域名卡片可能已被移除 + if (!tableBody) { + console.log('未找到recent-blocked-table元素,跳过更新'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.name || item.domain || item[0] || '未知', + timestamp: item.timestamp || item.time || Date.now(), + type: item.type || '广告' + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + const now = Date.now(); + tableData = [ + { name: 'recent1.com', timestamp: now - 5 * 60 * 1000, type: '广告' }, + { name: 'recent2.com', timestamp: now - 15 * 60 * 1000, type: '恶意' }, + { name: 'recent3.com', timestamp: now - 30 * 60 * 1000, type: '广告' }, + { name: 'recent4.com', timestamp: now - 45 * 60 * 1000, type: '追踪' }, + { name: 'recent5.com', timestamp: now - 60 * 60 * 1000, type: '恶意' } + ]; + console.log('使用示例数据填充最近屏蔽域名表格'); + } + + let html = ''; + for (let i = 0; i < tableData.length && i < 5; i++) { + const domain = tableData[i]; + const time = formatTime(domain.timestamp); + html += ` +
+
+
${domain.name}
+
${time}
+
+ ${domain.type} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新TOP客户端表格 +function updateTopClientsTable(clients) { + console.log('更新TOP客户端表格,收到数据:', clients); + const tableBody = document.getElementById('top-clients-table'); + + // 确保tableBody存在 + if (!tableBody) { + console.error('未找到top-clients-table元素'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(clients)) { + tableData = clients.map(item => ({ + ip: item.ip || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (clients && typeof clients === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(clients).map(([ip, count]) => ({ + ip, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + console.log('使用示例数据填充TOP客户端表格'); + } + + // 只显示前5个客户端 + tableData = tableData.slice(0, 5); + + let html = ''; + for (let i = 0; i < tableData.length; i++) { + const client = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${client.ip} +
+
+ ${formatNumber(client.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新请求域名排行表格 +function updateTopDomainsTable(domains) { + console.log('更新请求域名排行表格,收到数据:', domains); + const tableBody = document.getElementById('top-domains-table'); + + // 确保tableBody存在 + if (!tableBody) { + console.error('未找到top-domains-table元素'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.domain || item.name || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (domains && typeof domains === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(domains).map(([domain, count]) => ({ + name: domain, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { name: 'example.com', count: 50 }, + { name: 'google.com', count: 45 }, + { name: 'facebook.com', count: 40 }, + { name: 'twitter.com', count: 35 }, + { name: 'youtube.com', count: 30 } + ]; + console.log('使用示例数据填充请求域名排行表格'); + } + + // 只显示前5个域名 + tableData = tableData.slice(0, 5); + + let html = ''; + for (let i = 0; i < tableData.length; i++) { + const domain = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${domain.name} +
+
+ ${formatNumber(domain.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 当前选中的时间范围 +let currentTimeRange = '24h'; // 默认为24小时 +let isMixedView = true; // 是否为混合视图 - 默认显示混合视图 +let lastSelectedIndex = 0; // 最后选中的按钮索引 + +// 详细图表专用变量 +let detailedCurrentTimeRange = '24h'; // 详细图表当前时间范围 +let detailedIsMixedView = false; // 详细图表是否为混合视图 + +// 初始化时间范围切换 +function initTimeRangeToggle() { + console.log('初始化时间范围切换'); + // 查找所有可能的时间范围按钮类名 + const timeRangeButtons = document.querySelectorAll('.time-range-btn, .time-range-button, .timerange-btn, button[data-range]'); + console.log('找到时间范围按钮数量:', timeRangeButtons.length); + + if (timeRangeButtons.length === 0) { + console.warn('未找到时间范围按钮,请检查HTML中的类名'); + return; + } + + // 定义三个按钮的不同样式配置,增加activeHover属性 + const buttonStyles = [ + { // 24小时按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-blue-100'], + active: ['bg-blue-500', 'text-white'], + activeHover: ['hover:bg-blue-400'] // 选中时的浅色悬停 + }, + { // 7天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-green-100'], + active: ['bg-green-500', 'text-white'], + activeHover: ['hover:bg-green-400'] // 选中时的浅色悬停 + }, + { // 30天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-purple-100'], + active: ['bg-purple-500', 'text-white'], + activeHover: ['hover:bg-purple-400'] // 选中时的浅色悬停 + }, + { // 混合视图按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-gray-200'], + active: ['bg-gray-500', 'text-white'], + activeHover: ['hover:bg-gray-400'] // 选中时的浅色悬停 + } + ]; + + // 为所有按钮设置初始样式和事件 + timeRangeButtons.forEach((button, index) => { + // 使用相应的样式配置 + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 移除所有按钮的初始样式 + button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700', + 'bg-green-500', 'bg-purple-500', 'bg-gray-100'); + + // 设置非选中状态样式 + button.classList.add('transition-colors', 'duration-200'); + button.classList.add(...styleConfig.normal); + button.classList.add(...styleConfig.hover); + + // 移除鼠标悬停提示 + + console.log('为按钮设置初始样式:', button.textContent.trim(), '索引:', index, '类名:', Array.from(button.classList).join(', ')); + + button.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + + console.log('点击按钮:', button.textContent.trim(), '索引:', index); + + // 检查是否是再次点击已选中的按钮 + const isActive = button.classList.contains('active'); + + // 重置所有按钮为非选中状态 + timeRangeButtons.forEach((btn, btnIndex) => { + const btnStyle = buttonStyles[btnIndex % buttonStyles.length]; + + // 移除所有可能的激活状态类 + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + + // 添加非选中状态类 + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + if (isActive && index < 3) { // 再次点击已选中的时间范围按钮 + // 切换到混合视图 + isMixedView = true; + currentTimeRange = 'mixed'; + console.log('切换到混合视图'); + + // 设置当前按钮为特殊混合视图状态(保持原按钮选中但添加混合视图标记) + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + } else { + // 普通选中模式 + isMixedView = false; + lastSelectedIndex = index; + + // 设置当前按钮为激活状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + + // 获取并更新当前时间范围 + let rangeValue; + if (button.dataset.range) { + rangeValue = button.dataset.range; + } else { + const btnText = button.textContent.trim(); + if (btnText.includes('24')) { + rangeValue = '24h'; + } else if (btnText.includes('7')) { + rangeValue = '7d'; + } else if (btnText.includes('30')) { + rangeValue = '30d'; + } else { + rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, ''); + } + } + currentTimeRange = rangeValue; + console.log('更新时间范围为:', currentTimeRange); + } + + // 重新加载数据 + loadDashboardData(); + // 更新DNS请求图表 + drawDNSRequestsChart(); + }); + + // 移除自定义鼠标悬停提示效果 + }); + + // 确保默认选中第一个按钮并显示混合内容 + if (timeRangeButtons.length > 0) { + const firstButton = timeRangeButtons[0]; + const firstStyle = buttonStyles[0]; + + // 先重置所有按钮 + timeRangeButtons.forEach((btn, index) => { + const btnStyle = buttonStyles[index % buttonStyles.length]; + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + // 然后设置第一个按钮为激活状态,并标记为混合视图 + firstButton.classList.remove(...firstStyle.normal); + firstButton.classList.remove(...firstStyle.hover); + firstButton.classList.add('active', 'mixed-view-active'); + firstButton.classList.add(...firstStyle.active); + firstButton.classList.add(...firstStyle.activeHover); + console.log('默认选中第一个按钮并显示混合内容:', firstButton.textContent.trim()); + + // 设置默认显示混合内容 + isMixedView = true; + currentTimeRange = 'mixed'; + } +} + +// 注意:这个函数已被后面的实现覆盖,请使用后面的drawDetailedDNSRequestsChart函数 + +// 初始化图表 +function initCharts() { + // 初始化比例图表 + const ratioChartElement = document.getElementById('ratio-chart'); + if (!ratioChartElement) { + console.error('未找到比例图表元素'); + return; + } + const ratioCtx = ratioChartElement.getContext('2d'); + ratioChart = new Chart(ratioCtx, { + type: 'doughnut', + data: { + labels: ['正常解析', '被屏蔽', '错误'], + datasets: [{ + data: ['---', '---', '---'], + backgroundColor: ['#00B42A', '#F53F3F', '#FF7D00'], + borderWidth: 2, // 添加边框宽度,增强区块分隔 + borderColor: '#fff', // 白色边框,使各个扇区更清晰 + hoverOffset: 10, // 添加悬停偏移效果,增强交互体验 + hoverBorderWidth: 3 // 悬停时增加边框宽度 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 500, // 延长动画时间,使过渡更平滑 + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 10, + titleFont: { + size: 12 + }, + bodyFont: { + size: 11 + }, + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示 + // 添加线段指示相关配置 + elements: { + arc: { + // 确保圆弧绘制时有足够的精度 + borderAlign: 'center', + tension: 0.1 // 添加轻微的张力,使圆弧更平滑 + } + } + } + }); + + // 初始化解析类型统计饼图 + const queryTypeChartElement = document.getElementById('query-type-chart'); + if (queryTypeChartElement) { + const queryTypeCtx = queryTypeChartElement.getContext('2d'); + // 预定义的颜色数组,用于解析类型 + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; + + queryTypeChart = new Chart(queryTypeCtx, { + type: 'doughnut', + data: { + labels: ['暂无数据'], + datasets: [{ + data: [1], + backgroundColor: [queryTypeColors[0]], + borderWidth: 2, // 添加边框宽度,增强区块分隔 + borderColor: '#fff', // 白色边框,使各个扇区更清晰 + hoverOffset: 10, // 添加悬停偏移效果,增强交互体验 + hoverBorderWidth: 3 // 悬停时增加边框宽度 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 300, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 10, + titleFont: { + size: 12 + }, + bodyFont: { + size: 11 + }, + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示 + // 添加线段指示相关配置 + elements: { + arc: { + // 确保圆弧绘制时有足够的精度 + borderAlign: 'center', + tension: 0.1 // 添加轻微的张力,使圆弧更平滑 + } + } + } + }); + } else { + console.warn('未找到解析类型统计图表元素'); + } + + // 初始化DNS请求统计图表 + drawDNSRequestsChart(); + + // 初始化展开按钮功能 + initExpandButton(); +} + +// 初始化展开按钮事件 +function initExpandButton() { + const expandBtn = document.getElementById('expand-chart-btn'); + const chartModal = document.getElementById('chart-modal'); + const closeModalBtn = document.getElementById('close-modal-btn'); // 修复ID匹配 + + // 添加调试日志 + console.log('初始化展开按钮功能:', { expandBtn, chartModal, closeModalBtn }); + + if (expandBtn && chartModal && closeModalBtn) { + // 展开按钮点击事件 + expandBtn.addEventListener('click', () => { + console.log('展开按钮被点击'); + // 显示浮窗 + chartModal.classList.remove('hidden'); + + // 初始化或更新详细图表 + drawDetailedDNSRequestsChart(); + + // 初始化浮窗中的时间范围切换 + initDetailedTimeRangeToggle(); + + // 延迟更新图表大小,确保容器大小已计算 + setTimeout(() => { + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.resize(); + } + }, 100); + }); + + // 关闭按钮点击事件 + closeModalBtn.addEventListener('click', () => { + console.log('关闭按钮被点击'); + chartModal.classList.add('hidden'); + }); + + // 点击遮罩层关闭浮窗(使用chartModal作为遮罩层) + chartModal.addEventListener('click', (e) => { + // 检查点击目标是否是遮罩层本身(即最外层div) + if (e.target === chartModal) { + console.log('点击遮罩层关闭'); + chartModal.classList.add('hidden'); + } + }); + + // ESC键关闭浮窗 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !chartModal.classList.contains('hidden')) { + console.log('ESC键关闭浮窗'); + chartModal.classList.add('hidden'); + } + }); + } else { + console.error('无法找到必要的DOM元素'); + } +} + +// 初始化详细图表的时间范围切换 +function initDetailedTimeRangeToggle() { + // 只选择图表模态框内的时间范围按钮,避免与主视图冲突 + const chartModal = document.getElementById('chart-modal'); + const detailedTimeRangeButtons = chartModal ? chartModal.querySelectorAll('.time-range-btn') : []; + + console.log('初始化详细图表时间范围切换,找到按钮数量:', detailedTimeRangeButtons.length); + + // 初始化详细图表的默认状态,与主图表保持一致 + detailedCurrentTimeRange = currentTimeRange; + detailedIsMixedView = isMixedView; + + // 定义按钮样式配置,与主视图保持一致 + const buttonStyles = [ + { // 24小时按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-blue-100'], + active: ['bg-blue-500', 'text-white'], + activeHover: ['hover:bg-blue-400'] + }, + { // 7天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-green-100'], + active: ['bg-green-500', 'text-white'], + activeHover: ['hover:bg-green-400'] + }, + { // 30天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-purple-100'], + active: ['bg-purple-500', 'text-white'], + activeHover: ['hover:bg-purple-400'] + }, + { // 混合视图按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-gray-200'], + active: ['bg-gray-500', 'text-white'], + activeHover: ['hover:bg-gray-400'] + } + ]; + + // 设置初始按钮状态 + detailedTimeRangeButtons.forEach((button, index) => { + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 移除所有初始样式 + button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700', + 'bg-green-500', 'bg-purple-500', 'bg-gray-100', 'mixed-view-active'); + + // 设置非选中状态样式 + button.classList.add('transition-colors', 'duration-200'); + button.classList.add(...styleConfig.normal); + button.classList.add(...styleConfig.hover); + + // 如果是第一个按钮且当前是混合视图,设置为混合视图激活状态 + if (index === 0 && detailedIsMixedView) { + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + } + }); + + detailedTimeRangeButtons.forEach((button, index) => { + button.addEventListener('click', () => { + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 检查是否是再次点击已选中的按钮 + const isActive = button.classList.contains('active'); + + // 重置所有按钮为非选中状态 + detailedTimeRangeButtons.forEach((btn, btnIndex) => { + const btnStyle = buttonStyles[btnIndex % buttonStyles.length]; + + // 移除所有可能的激活状态类 + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + + // 添加非选中状态类 + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + if (isActive && index < 3) { // 再次点击已选中的时间范围按钮 + // 切换到混合视图 + detailedIsMixedView = true; + detailedCurrentTimeRange = 'mixed'; + console.log('详细图表切换到混合视图'); + + // 设置当前按钮为特殊混合视图状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + } else { + // 普通选中模式 + detailedIsMixedView = false; + + // 设置当前按钮为激活状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + + // 获取并更新当前时间范围 + let rangeValue; + if (button.dataset.range) { + rangeValue = button.dataset.range; + } else { + const btnText = button.textContent.trim(); + if (btnText.includes('24')) { + rangeValue = '24h'; + } else if (btnText.includes('7')) { + rangeValue = '7d'; + } else if (btnText.includes('30')) { + rangeValue = '30d'; + } else { + rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, ''); + } + } + detailedCurrentTimeRange = rangeValue; + console.log('详细图表更新时间范围为:', detailedCurrentTimeRange); + } + + // 重新绘制详细图表 + drawDetailedDNSRequestsChart(); + }); + }); +} + +// 绘制详细的DNS请求趋势图表 +function drawDetailedDNSRequestsChart() { + console.log('绘制详细DNS请求趋势图表,时间范围:', detailedCurrentTimeRange, '混合视图:', detailedIsMixedView); + + const ctx = document.getElementById('detailed-dns-requests-chart'); + if (!ctx) { + console.error('未找到详细DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (detailedIsMixedView || detailedCurrentTimeRange === 'mixed') { + console.log('渲染混合视图详细图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, + borderWidth: 2 + })); + + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = results[0].labels; + detailedDnsRequestsChart.data.datasets = datasets; + detailedDnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图详细图表失败:', error); + }); + } else { + // 普通视图 + // 根据详细视图时间范围选择API函数和对应的颜色 + let apiFunction; + let chartColor; + let chartFillColor; + + switch (detailedCurrentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致 + chartFillColor = 'rgba(34, 197, 94, 0.1)'; + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致 + chartFillColor = 'rgba(168, 85, 247, 0.1)'; + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致 + chartFillColor = 'rgba(59, 130, 246, 0.1)'; + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = data.labels; + detailedDnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }]; + detailedDnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + title: { + display: true, + text: 'DNS请求趋势', + font: { + size: 14 + } + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制详细DNS请求图表失败:', error); + // 错误处理:使用空数据 + const count = detailedCurrentTimeRange === '24h' ? 24 : (detailedCurrentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = emptyData.labels; + detailedDnsRequestsChart.data.datasets[0].data = emptyData.data; + detailedDnsRequestsChart.update(); + } + }); + } +} + +// 绘制DNS请求统计图表 +function drawDNSRequestsChart() { + const ctx = document.getElementById('dns-requests-chart'); + if (!ctx) { + console.error('未找到DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (isMixedView || currentTimeRange === 'mixed') { + console.log('渲染混合视图图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据而不是模拟数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, // 混合视图不填充 + borderWidth: 2 + })); + + // 创建或更新图表 + if (dnsRequestsChart) { + // 使用第一个数据集的标签,但确保每个数据集使用自己的数据 + dnsRequestsChart.data.labels = results[0].labels; + dnsRequestsChart.data.datasets = datasets; + dnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图图表失败:', error); + }); + } else { + // 普通视图 + // 根据当前时间范围选择API函数和对应的颜色 + let apiFunction; + let chartColor; + let chartFillColor; + + switch (currentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致 + chartFillColor = 'rgba(34, 197, 94, 0.1)'; + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致 + chartFillColor = 'rgba(168, 85, 247, 0.1)'; + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致 + chartFillColor = 'rgba(59, 130, 246, 0.1)'; + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = data.labels; + dnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }]; + dnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制DNS请求图表失败:', error); + // 错误处理:使用空数据而不是模拟数据 + const count = currentTimeRange === '24h' ? 24 : (currentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = emptyData.labels; + dnsRequestsChart.data.datasets[0].data = emptyData.data; + dnsRequestsChart.update(); + } + }); + } +} + +// 更新图表数据 +function updateCharts(stats, queryTypeStats) { + console.log('更新图表,收到统计数据:', stats); + console.log('查询类型统计数据:', queryTypeStats); + + // 空值检查 + if (!stats) { + console.error('更新图表失败: 未提供统计数据'); + return; + } + + // 更新比例图表 + if (ratioChart) { + let allowed = '---', blocked = '---', error = '---'; + + // 尝试从stats数据中提取 + if (stats.dns) { + allowed = stats.dns.Allowed || allowed; + blocked = stats.dns.Blocked || blocked; + error = stats.dns.Errors || error; + } else if (stats.totalQueries !== undefined) { + allowed = stats.allowedQueries || allowed; + blocked = stats.blockedQueries || blocked; + error = stats.errorQueries || error; + } + + ratioChart.data.datasets[0].data = [allowed, blocked, error]; + // 使用自定义动画配置更新图表,确保平滑过渡 + ratioChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); + } + + // 更新解析类型统计饼图 + if (queryTypeChart && queryTypeStats && Array.isArray(queryTypeStats)) { + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; + + // 检查是否有有效的数据项 + const validData = queryTypeStats.filter(item => item && item.count > 0); + + if (validData.length > 0) { + // 准备标签和数据 + const labels = validData.map(item => item.type); + const data = validData.map(item => item.count); + + // 为每个解析类型分配颜色 + const colors = labels.map((_, index) => queryTypeColors[index % queryTypeColors.length]); + + // 更新图表数据 + queryTypeChart.data.labels = labels; + queryTypeChart.data.datasets[0].data = data; + queryTypeChart.data.datasets[0].backgroundColor = colors; + } else { + // 如果没有数据,显示默认值 + queryTypeChart.data.labels = ['暂无数据']; + queryTypeChart.data.datasets[0].data = [1]; + queryTypeChart.data.datasets[0].backgroundColor = [queryTypeColors[0]]; + } + + // 使用自定义动画配置更新图表,确保平滑过渡 + queryTypeChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); + } +} + +// 更新统计卡片折线图 +function updateStatCardCharts(stats) { + if (!stats || Object.keys(statCardCharts).length === 0) { + return; + } + + // 更新查询总量图表 + if (statCardCharts['query-chart']) { + let queryCount = 0; + if (stats.dns) { + queryCount = stats.dns.Queries || 0; + } else if (stats.totalQueries !== undefined) { + queryCount = stats.totalQueries || 0; + } + updateChartData('query-chart', queryCount); + } + + // 更新屏蔽数量图表 + if (statCardCharts['blocked-chart']) { + let blockedCount = 0; + if (stats.dns) { + blockedCount = stats.dns.Blocked || 0; + } else if (stats.blockedQueries !== undefined) { + blockedCount = stats.blockedQueries || 0; + } + updateChartData('blocked-chart', blockedCount); + } + + // 更新正常解析图表 + if (statCardCharts['allowed-chart']) { + let allowedCount = 0; + if (stats.dns) { + allowedCount = stats.dns.Allowed || 0; + } else if (stats.allowedQueries !== undefined) { + allowedCount = stats.allowedQueries || 0; + } else if (stats.dns && stats.dns.Queries && stats.dns.Blocked) { + allowedCount = stats.dns.Queries - stats.dns.Blocked; + } + updateChartData('allowed-chart', allowedCount); + } + + // 更新错误数量图表 + if (statCardCharts['error-chart']) { + let errorCount = 0; + if (stats.dns) { + errorCount = stats.dns.Errors || 0; + } else if (stats.errorQueries !== undefined) { + errorCount = stats.errorQueries || 0; + } + updateChartData('error-chart', errorCount); + } + + // 更新响应时间图表 + if (statCardCharts['response-time-chart']) { + let responseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + // 限制小数位数为两位 + responseTime = parseFloat(responseTime).toFixed(2); + updateChartData('response-time-chart', responseTime); + } + + // 更新活跃IP图表 + if (statCardCharts['ips-chart']) { + const activeIPs = stats.activeIPs || 0; + updateChartData('ips-chart', activeIPs); + } + + // 更新CPU使用率图表 + if (statCardCharts['cpu-chart']) { + const cpuUsage = stats.cpuUsage || 0; + updateChartData('cpu-chart', cpuUsage); + } + + // 更新平均响应时间显示 + if (document.getElementById('avg-response-time')) { + let avgResponseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + avgResponseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + avgResponseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + avgResponseTime = stats.responseTime; + } + document.getElementById('avg-response-time').textContent = formatNumber(avgResponseTime); + } + + // 更新规则数图表 + if (statCardCharts['rules-chart']) { + // 尝试获取规则数,如果没有则使用模拟数据 + const rulesCount = getRulesCountFromStats(stats) || Math.floor(Math.random() * 5000) + 10000; + updateChartData('rules-chart', rulesCount); + } + + // 更新排除规则数图表 + if (statCardCharts['exceptions-chart']) { + const exceptionsCount = getExceptionsCountFromStats(stats) || Math.floor(Math.random() * 100) + 50; + updateChartData('exceptions-chart', exceptionsCount); + } + + // 更新Hosts条目数图表 + if (statCardCharts['hosts-chart']) { + const hostsCount = getHostsCountFromStats(stats) || Math.floor(Math.random() * 1000) + 2000; + updateChartData('hosts-chart', hostsCount); + } +} + +// 更新单个图表的数据 +function updateChartData(chartId, newValue) { + const chart = statCardCharts[chartId]; + const historyData = statCardHistoryData[chartId]; + + if (!chart || !historyData) { + return; + } + + // 添加新数据,移除最旧的数据 + historyData.push(newValue); + if (historyData.length > 12) { + historyData.shift(); + } + + // 更新图表数据 + chart.data.datasets[0].data = historyData; + chart.data.labels = generateTimeLabels(historyData.length); + + // 使用自定义动画配置更新图表,确保平滑过渡,避免空白区域 + chart.update({ + duration: 300, // 增加动画持续时间 + easing: 'easeInOutQuart', // 使用平滑的缓动函数 + transition: { + duration: 300, + easing: 'easeInOutQuart' + } + }); +} + +// 从统计数据中获取规则数 +function getRulesCountFromStats(stats) { + // 尝试从stats中获取规则数 + if (stats.shield && stats.shield.rules) { + return stats.shield.rules; + } + return null; +} + +// 从统计数据中获取排除规则数 +function getExceptionsCountFromStats(stats) { + // 尝试从stats中获取排除规则数 + if (stats.shield && stats.shield.exceptions) { + return stats.shield.exceptions; + } + return null; +} + +// 从统计数据中获取Hosts条目数 +function getHostsCountFromStats(stats) { + // 尝试从stats中获取Hosts条目数 + if (stats.shield && stats.shield.hosts) { + return stats.shield.hosts; + } + return null; +} + +// 初始化统计卡片折线图 +function initStatCardCharts() { + console.log('===== 开始初始化统计卡片折线图 ====='); + + // 清理已存在的图表实例 + for (const key in statCardCharts) { + if (statCardCharts.hasOwnProperty(key)) { + statCardCharts[key].destroy(); + } + } + statCardCharts = {}; + statCardHistoryData = {}; + + // 检查Chart.js是否加载 + console.log('Chart.js是否可用:', typeof Chart !== 'undefined'); + + // 统计卡片配置信息 + const cardConfigs = [ + { id: 'query-chart', color: '#9b59b6', label: '查询总量' }, + { id: 'blocked-chart', color: '#e74c3c', label: '屏蔽数量' }, + { id: 'allowed-chart', color: '#2ecc71', label: '正常解析' }, + { id: 'error-chart', color: '#f39c12', label: '错误数量' }, + { id: 'response-time-chart', color: '#3498db', label: '响应时间' }, + { id: 'ips-chart', color: '#1abc9c', label: '活跃IP' }, + { id: 'cpu-chart', color: '#e67e22', label: 'CPU使用率' }, + { id: 'rules-chart', color: '#95a5a6', label: '屏蔽规则数' }, + { id: 'exceptions-chart', color: '#34495e', label: '排除规则数' }, + { id: 'hosts-chart', color: '#16a085', label: 'Hosts条目数' } + ]; + + console.log('图表配置:', cardConfigs); + + cardConfigs.forEach(config => { + const canvas = document.getElementById(config.id); + if (!canvas) { + console.warn(`未找到统计卡片图表元素: ${config.id}`); + return; + } + + const ctx = canvas.getContext('2d'); + + // 为不同类型的卡片生成更合适的初始数据 + let initialData; + if (config.id === 'response-time-chart') { + // 响应时间图表使用空数组,将通过API实时数据更新 + initialData = Array(12).fill(null); + } else if (config.id === 'cpu-chart') { + initialData = generateMockData(12, 0, 10); + } else { + initialData = generateMockData(12, 0, 100); + } + + // 初始化历史数据数组 + statCardHistoryData[config.id] = [...initialData]; + + // 创建图表 + statCardCharts[config.id] = new Chart(ctx, { + type: 'line', + data: { + labels: generateTimeLabels(12), + datasets: [{ + label: config.label, + data: initialData, + borderColor: config.color, + backgroundColor: `${config.color}20`, // 透明度20% + borderWidth: 2, + tension: 0.4, + fill: true, + pointRadius: 0, // 隐藏数据点 + pointHoverRadius: 4, // 鼠标悬停时显示数据点 + pointBackgroundColor: config.color + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加动画配置,确保平滑过渡 + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: config.color, + borderWidth: 1, + padding: 8, + displayColors: false, + cornerRadius: 4, + titleFont: { + size: 12, + weight: 'normal' + }, + bodyFont: { + size: 11 + }, + // 确保HTML渲染正确 + useHTML: true, + filter: function(tooltipItem) { + return tooltipItem.datasetIndex === 0; + }, + callbacks: { + title: function(tooltipItems) { + // 简化时间显示格式 + return tooltipItems[0].label; + }, + label: function(context) { + const value = context.parsed.y; + // 格式化大数字 + const formattedValue = formatNumber(value); + + // 使用CSS类显示变化趋势 + let trendInfo = ''; + const data = context.dataset.data; + const currentIndex = context.dataIndex; + + if (currentIndex > 0) { + const prevValue = data[currentIndex - 1]; + const change = value - prevValue; + + if (change !== 0) { + const changeSymbol = change > 0 ? '↑' : '↓'; + // 取消颜色显示,简化显示 + trendInfo = (changeSymbol + Math.abs(change)); + } + } + + // 简化标签格式 + return `${config.label}: ${formattedValue}${trendInfo}`; + }, + // 移除平均值显示 + afterLabel: function(context) { + return ''; + } + } + } + }, + scales: { + x: { + display: false // 隐藏X轴 + }, + y: { + display: false, // 隐藏Y轴 + beginAtZero: true + } + }, + interaction: { + intersect: false, + mode: 'index' + } + } + }); + }); +} + +// 生成模拟数据 +function generateMockData(count, min, max) { + const data = []; + for (let i = 0; i < count; i++) { + data.push(Math.floor(Math.random() * (max - min + 1)) + min); + } + return data; +} + +// 生成时间标签 +function generateTimeLabels(count) { + const labels = []; + const now = new Date(); + for (let i = count - 1; i >= 0; i--) { + const time = new Date(now.getTime() - i * 5 * 60 * 1000); // 每5分钟一个点 + labels.push(`${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}`); + } + return labels; +} + +// 格式化数字显示(使用K/M后缀) +function formatNumber(num) { + // 如果不是数字,直接返回 + if (isNaN(num) || num === '---') { + return num; + } + + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 更新运行状态 +function updateUptime() { + // 实现更新运行时间的逻辑 + // 这里应该调用API获取当前运行时间并更新到UI + // 由于API暂时没有提供运行时间,我们先使用模拟数据 + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = '---'; + } +} + +// 格式化数字(添加千位分隔符) +function formatWithCommas(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +// 格式化时间 +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + // 如果是今天,显示时间 + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}); + } + + // 否则显示日期和时间 + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +// 根据颜色代码获取对应的CSS类名(兼容方式) +function getColorClassName(colorCode) { + // 优先使用配置文件中的颜色处理 + if (COLOR_CONFIG.getColorClassName) { + return COLOR_CONFIG.getColorClassName(colorCode); + } + + // 备用颜色映射 + const colorMap = { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }; + + // 返回映射的类名,如果没有找到则返回默认的blue + return colorMap[colorCode] || 'blue'; +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除已存在的通知 + const existingNotification = document.getElementById('notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建通知元素 + const notification = document.createElement('div'); + notification.id = 'notification'; + notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-y-0 opacity-0`; + + // 设置样式和内容 + let bgColor, textColor, icon; + switch (type) { + case 'success': + bgColor = 'bg-success'; + textColor = 'text-white'; + icon = 'fa-check-circle'; + break; + case 'error': + bgColor = 'bg-danger'; + textColor = 'text-white'; + icon = 'fa-exclamation-circle'; + break; + case 'warning': + bgColor = 'bg-warning'; + textColor = 'text-white'; + icon = 'fa-exclamation-triangle'; + break; + default: + bgColor = 'bg-primary'; + textColor = 'text-white'; + icon = 'fa-info-circle'; + } + + notification.className += ` ${bgColor} ${textColor}`; + notification.innerHTML = ` +
+ + ${message} +
+ `; + + // 添加到页面 + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('translate-y-0', 'opacity-0'); + notification.classList.add('-translate-y-2', 'opacity-100'); + }, 10); + + // 自动关闭 + setTimeout(() => { + notification.classList.add('translate-y-0', 'opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面切换处理 +function handlePageSwitch() { + const menuItems = document.querySelectorAll('nav a'); + + // 页面切换逻辑 + function switchPage(targetId, menuItem) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + document.getElementById(`${targetId}-content`).classList.remove('hidden'); + + // 更新页面标题 + document.getElementById('page-title').textContent = menuItem.querySelector('span').textContent; + + // 更新活动菜单项 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + menuItem.classList.add('sidebar-item-active'); + + // 侧边栏切换(移动端) + if (window.innerWidth < 1024) { + toggleSidebar(); + } + } + + menuItems.forEach(item => { + item.addEventListener('click', (e) => { + // 允许默认的hash变化 + // 页面切换会由hashchange事件处理 + }); + }); +} + +// 处理hash变化 - 全局函数,确保在页面加载时就能被调用 +function handleHashChange() { + let hash = window.location.hash; + + // 如果没有hash,默认设置为#dashboard + if (!hash) { + hash = '#dashboard'; + window.location.hash = hash; + return; + } + + const targetId = hash.substring(1); + const menuItems = document.querySelectorAll('nav a'); + + // 首先检查是否存在对应的内容元素 + const contentElement = document.getElementById(`${targetId}-content`); + + // 查找对应的菜单项 + let targetMenuItem = null; + menuItems.forEach(item => { + if (item.getAttribute('href') === hash) { + targetMenuItem = item; + } + }); + + // 如果找到了对应的内容元素,直接显示 + if (contentElement) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + contentElement.classList.remove('hidden'); + + // 更新活动菜单项和页面标题 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + + if (targetMenuItem) { + targetMenuItem.classList.add('sidebar-item-active'); + // 更新页面标题 + const pageTitle = targetMenuItem.querySelector('span').textContent; + document.getElementById('page-title').textContent = pageTitle; + } else { + // 如果没有找到对应的菜单项,尝试根据hash更新页面标题 + const titleElement = document.getElementById(`${targetId}-title`); + if (titleElement) { + document.getElementById('page-title').textContent = titleElement.textContent; + } + } + } else if (targetMenuItem) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + document.getElementById(`${targetId}-content`).classList.remove('hidden'); + + // 更新页面标题 + document.getElementById('page-title').textContent = targetMenuItem.querySelector('span').textContent; + + // 更新活动菜单项 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + targetMenuItem.classList.add('sidebar-item-active'); + + // 侧边栏切换(移动端) + if (window.innerWidth < 1024) { + toggleSidebar(); + } + } else { + // 如果既没有找到内容元素也没有找到菜单项,默认显示dashboard + window.location.hash = '#dashboard'; + } +} + +// 初始化hash路由 +function initHashRoute() { + handleHashChange(); +} + +// 监听hash变化事件 - 全局事件监听器 +window.addEventListener('hashchange', handleHashChange); + +// 初始化hash路由 - 确保在页面加载时就能被调用 +initHashRoute(); + +// 侧边栏切换 +function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + sidebar.classList.toggle('-translate-x-full'); +} + +// 响应式处理 +function handleResponsive() { + const toggleBtn = document.getElementById('toggle-sidebar'); + const sidebar = document.getElementById('sidebar'); + + toggleBtn.addEventListener('click', toggleSidebar); + + // 初始状态处理 + function updateSidebarState() { + if (window.innerWidth < 1024) { + sidebar.classList.add('-translate-x-full'); + } else { + sidebar.classList.remove('-translate-x-full'); + } + } + + updateSidebarState(); + + // 窗口大小改变时处理 + window.addEventListener('resize', () => { + updateSidebarState(); + + // 更新所有图表大小 + if (dnsRequestsChart) { + dnsRequestsChart.update(); + } + if (ratioChart) { + ratioChart.update(); + } + if (queryTypeChart) { + queryTypeChart.update(); + } + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.update(); + } + // 更新统计卡片图表 + Object.values(statCardCharts).forEach(chart => { + if (chart) { + chart.update(); + } + }); + }); + + // 添加触摸事件支持,用于移动端 + let touchStartX = 0; + let touchEndX = 0; + + document.addEventListener('touchstart', (e) => { + touchStartX = e.changedTouches[0].screenX; + }, false); + + document.addEventListener('touchend', (e) => { + touchEndX = e.changedTouches[0].screenX; + handleSwipe(); + }, false); + + function handleSwipe() { + // 从左向右滑动,打开侧边栏 + if (touchEndX - touchStartX > 50 && window.innerWidth < 1024) { + sidebar.classList.remove('-translate-x-full'); + } + // 从右向左滑动,关闭侧边栏 + if (touchStartX - touchEndX > 50 && window.innerWidth < 1024) { + sidebar.classList.add('-translate-x-full'); + } + } +} + +// 添加重试功能 +function addRetryEventListeners() { + // TOP客户端重试按钮 + const retryTopClientsBtn = document.getElementById('retry-top-clients'); + if (retryTopClientsBtn) { + retryTopClientsBtn.addEventListener('click', async () => { + console.log('重试获取TOP客户端数据'); + const clientsData = await api.getTopClients(); + if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) { + // 使用真实数据 + updateTopClientsTable(clientsData); + hideLoading('top-clients'); + const errorElement = document.getElementById('top-clients-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 重试失败,保持原有状态 + console.warn('重试获取TOP客户端数据失败'); + } + }); + } + + // TOP域名重试按钮 + const retryTopDomainsBtn = document.getElementById('retry-top-domains'); + if (retryTopDomainsBtn) { + retryTopDomainsBtn.addEventListener('click', async () => { + console.log('重试获取TOP域名数据'); + const domainsData = await api.getTopDomains(); + if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) { + // 使用真实数据 + updateTopDomainsTable(domainsData); + hideLoading('top-domains'); + const errorElement = document.getElementById('top-domains-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 重试失败,保持原有状态 + console.warn('重试获取TOP域名数据失败'); + } + }); + } +} + +// 页面加载完成后初始化 +window.addEventListener('DOMContentLoaded', () => { + // 初始化页面切换 + handlePageSwitch(); + + // 初始化响应式 + handleResponsive(); + + // 初始化仪表盘 + initDashboard(); + + // 添加重试事件监听器 + addRetryEventListeners(); + + // 页面卸载时清理定时器 + window.addEventListener('beforeunload', () => { + if (intervalId) { + clearInterval(intervalId); + } + }); +}); \ No newline at end of file diff --git a/js/hosts.js b/js/hosts.js new file mode 100644 index 0000000..a8a839d --- /dev/null +++ b/js/hosts.js @@ -0,0 +1,202 @@ +// Hosts管理页面功能实现 + +// 初始化Hosts管理页面 +function initHostsPage() { + // 加载Hosts规则 + loadHostsRules(); + // 设置事件监听器 + setupHostsEventListeners(); +} + +// 加载Hosts规则 +async function loadHostsRules() { + try { + const response = await fetch('/api/shield/hosts'); + if (!response.ok) { + throw new Error('Failed to load hosts rules'); + } + const data = await response.json(); + + // 处理API返回的数据格式 + let hostsRules = []; + if (data && Array.isArray(data)) { + // 直接是数组格式 + hostsRules = data; + } else if (data && data.hosts) { + // 包含在hosts字段中 + hostsRules = data.hosts; + } + + updateHostsTable(hostsRules); + } catch (error) { + console.error('Error loading hosts rules:', error); + showErrorMessage('加载Hosts规则失败'); + } +} + +// 更新Hosts表格 +function updateHostsTable(hostsRules) { + const tbody = document.getElementById('hosts-table-body'); + + if (hostsRules.length === 0) { + tbody.innerHTML = '暂无Hosts条目'; + return; + } + + tbody.innerHTML = hostsRules.map(rule => { + // 处理对象格式的规则 + const ip = rule.ip || ''; + const domain = rule.domain || ''; + + return ` + + ${ip} + ${domain} + + + + + `; + }).join(''); + + // 重新绑定删除事件 + document.querySelectorAll('.delete-hosts-btn').forEach(btn => { + btn.addEventListener('click', handleDeleteHostsRule); + }); +} + +// 设置事件监听器 +function setupHostsEventListeners() { + // 保存Hosts按钮 + document.getElementById('save-hosts-btn').addEventListener('click', handleAddHostsRule); +} + +// 处理添加Hosts规则 +async function handleAddHostsRule() { + const ip = document.getElementById('hosts-ip').value.trim(); + const domain = document.getElementById('hosts-domain').value.trim(); + + if (!ip || !domain) { + showErrorMessage('IP地址和域名不能为空'); + return; + } + + try { + const response = await fetch('/api/shield/hosts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ip, domain }) + }); + + if (!response.ok) { + throw new Error('Failed to add hosts rule'); + } + + showSuccessMessage('Hosts规则添加成功'); + + // 清空输入框 + document.getElementById('hosts-ip').value = ''; + document.getElementById('hosts-domain').value = ''; + + // 重新加载规则 + loadHostsRules(); + } catch (error) { + console.error('Error adding hosts rule:', error); + showErrorMessage('添加Hosts规则失败'); + } +} + +// 处理删除Hosts规则 +async function handleDeleteHostsRule(e) { + const ip = e.target.closest('.delete-hosts-btn').dataset.ip; + const domain = e.target.closest('.delete-hosts-btn').dataset.domain; + + try { + const response = await fetch('/api/shield/hosts', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ domain }) + }); + + if (!response.ok) { + throw new Error('Failed to delete hosts rule'); + } + + showSuccessMessage('Hosts规则删除成功'); + + // 重新加载规则 + loadHostsRules(); + } catch (error) { + console.error('Error deleting hosts rule:', error); + showErrorMessage('删除Hosts规则失败'); + } +} + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + + + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + }, 100); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initHostsPage); +} else { + initHostsPage(); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..2f6bb6e --- /dev/null +++ b/js/main.js @@ -0,0 +1,173 @@ +// main.js - 主脚本文件 + +// 页面导航功能 +function setupNavigation() { + // 侧边栏菜单项 + const menuItems = document.querySelectorAll('nav a'); + const contentSections = [ + document.getElementById('dashboard-content'), + document.getElementById('shield-content'), + document.getElementById('hosts-content'), + document.getElementById('query-content'), + document.getElementById('config-content') + ]; + const pageTitle = document.getElementById('page-title'); + + menuItems.forEach((item, index) => { + item.addEventListener('click', (e) => { + // 允许浏览器自动更新地址栏中的hash,不阻止默认行为 + + // 移动端点击菜单项后自动关闭侧边栏 + if (window.innerWidth < 768) { + closeSidebar(); + } + + // 页面特定初始化 - 保留这部分逻辑,因为它不会与hashchange事件处理逻辑冲突 + const target = item.getAttribute('href').substring(1); + if (target === 'shield' && typeof initShieldPage === 'function') { + initShieldPage(); + } else if (target === 'hosts' && typeof initHostsPage === 'function') { + initHostsPage(); + } + }); + }); + + // 移动端侧边栏切换 + const toggleSidebar = document.getElementById('toggle-sidebar'); + const closeSidebarBtn = document.getElementById('close-sidebar'); + const sidebar = document.getElementById('sidebar'); + const sidebarOverlay = document.getElementById('sidebar-overlay'); + + // 打开侧边栏函数 + function openSidebar() { + console.log('Opening sidebar...'); + if (sidebar) { + sidebar.classList.remove('-translate-x-full'); + sidebar.classList.add('translate-x-0'); + } + if (sidebarOverlay) { + sidebarOverlay.classList.remove('hidden'); + sidebarOverlay.classList.add('block'); + } + // 防止页面滚动 + document.body.style.overflow = 'hidden'; + console.log('Sidebar opened successfully'); + } + + // 关闭侧边栏函数 + function closeSidebar() { + console.log('Closing sidebar...'); + if (sidebar) { + sidebar.classList.add('-translate-x-full'); + sidebar.classList.remove('translate-x-0'); + } + if (sidebarOverlay) { + sidebarOverlay.classList.add('hidden'); + sidebarOverlay.classList.remove('block'); + } + // 恢复页面滚动 + document.body.style.overflow = ''; + console.log('Sidebar closed successfully'); + } + + // 切换侧边栏函数 + function toggleSidebarVisibility() { + console.log('Toggling sidebar visibility...'); + console.log('Current sidebar classes:', sidebar ? sidebar.className : 'sidebar not found'); + if (sidebar && sidebar.classList.contains('-translate-x-full')) { + console.log('Sidebar is hidden, opening...'); + openSidebar(); + } else { + console.log('Sidebar is visible, closing...'); + closeSidebar(); + } + } + + // 绑定切换按钮事件 + if (toggleSidebar) { + toggleSidebar.addEventListener('click', toggleSidebarVisibility); + } + + // 绑定关闭按钮事件 + if (closeSidebarBtn) { + closeSidebarBtn.addEventListener('click', closeSidebar); + } + + // 绑定遮罩层点击事件 + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', closeSidebar); + } + + // 移动端点击菜单项后自动关闭侧边栏 + menuItems.forEach(item => { + item.addEventListener('click', () => { + // 检查是否是移动设备视图 + if (window.innerWidth < 768) { + closeSidebar(); + } + }); + }); + + // 添加键盘事件监听,按ESC键关闭侧边栏 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeSidebar(); + } + }); +} + +// 初始化函数 +function init() { + // 设置导航 + setupNavigation(); + + // 加载仪表盘数据 + if (typeof loadDashboardData === 'function') { + loadDashboardData(); + } + + // 定期更新系统状态 + setInterval(updateSystemStatus, 5000); +} + +// 更新系统状态 +function updateSystemStatus() { + fetch('/api/status') + .then(response => response.json()) + .then(data => { + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = `正常运行中 | ${formatUptime(data.uptime)}`; + } + }) + .catch(error => { + console.error('更新系统状态失败:', error); + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = '连接异常'; + uptimeElement.classList.add('text-danger'); + } + }); +} + +// 格式化运行时间 +function formatUptime(milliseconds) { + // 简化版的格式化,实际使用时需要根据API返回的数据格式调整 + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}天${hours % 24}小时`; + } else if (hours > 0) { + return `${hours}小时${minutes % 60}分钟`; + } else if (minutes > 0) { + return `${minutes}分钟${seconds % 60}秒`; + } else { + return `${seconds}秒`; + } +} + +// 页面加载完成后执行初始化 +window.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/js/modules/blacklists.js b/js/modules/blacklists.js new file mode 100644 index 0000000..2f1cfb1 --- /dev/null +++ b/js/modules/blacklists.js @@ -0,0 +1,255 @@ +// 初始化远程黑名单面板 +function initBlacklistsPanel() { + // 加载远程黑名单列表 + loadBlacklists(); + + // 初始化事件监听器 + initBlacklistsEventListeners(); +} + +// 初始化事件监听器 +function initBlacklistsEventListeners() { + // 添加黑名单按钮 + document.getElementById('add-blacklist').addEventListener('click', addBlacklist); + + // 更新所有黑名单按钮 + document.getElementById('update-all-blacklists').addEventListener('click', updateAllBlacklists); + + // 按Enter键添加黑名单 + document.getElementById('blacklist-url').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + addBlacklist(); + } + }); +} + +// 加载远程黑名单列表 +function loadBlacklists() { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + showLoading(tbody); + + apiRequest('/api/shield/blacklists') + .then(data => { + // 直接渲染返回的blacklists数组 + renderBlacklists(data); + }) + .catch(error => { + console.error('获取远程黑名单列表失败:', error); + showError(tbody, '获取远程黑名单列表失败'); + window.showNotification('获取远程黑名单列表失败', 'error'); + }); +} + +// 渲染远程黑名单表格 +function renderBlacklists(blacklists) { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + if (!tbody) return; + + if (!blacklists || blacklists.length === 0) { + showEmpty(tbody, '暂无远程黑名单'); + return; + } + + tbody.innerHTML = ''; + + blacklists.forEach(list => { + addBlacklistToTable(list); + }); + + // 初始化表格排序 + initTableSort('blacklists-table'); + + // 初始化操作按钮监听器 + initBlacklistsActionListeners(); +} + +// 添加黑名单到表格 +function addBlacklistToTable(list) { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + const row = document.createElement('tr'); + + const statusClass = list.status === 'success' ? 'status-success' : + list.status === 'error' ? 'status-error' : 'status-pending'; + + const statusText = list.status === 'success' ? '正常' : + list.status === 'error' ? '错误' : '等待中'; + + const lastUpdate = list.lastUpdate ? new Date(list.lastUpdate).toLocaleString() : '从未'; + + row.innerHTML = ` + ${list.name} + ${list.url} + + ${statusText} + + ${list.rulesCount || 0} + ${lastUpdate} + + + + + `; + + tbody.appendChild(row); +} + +// 添加远程黑名单 +function addBlacklist() { + const nameInput = document.getElementById('blacklist-name'); + const urlInput = document.getElementById('blacklist-url'); + + const name = nameInput.value.trim(); + const url = urlInput.value.trim(); + + if (!name) { + window.showNotification('请输入黑名单名称', 'warning'); + nameInput.focus(); + return; + } + + if (!url) { + window.showNotification('请输入黑名单URL', 'warning'); + urlInput.focus(); + return; + } + + // 简单的URL格式验证 + if (!isValidUrl(url)) { + window.showNotification('请输入有效的URL', 'warning'); + urlInput.focus(); + return; + } + + apiRequest('/api/shield/blacklists', 'POST', { name: name, url: url }) + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单添加失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单添加成功', 'success'); + nameInput.value = ''; + urlInput.value = ''; + loadBlacklists(); + } else { + window.showNotification(`添加失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('添加远程黑名单失败:', error); + window.showNotification('添加远程黑名单失败', 'error'); + }); +} + +// 更新远程黑名单 +function updateBlacklist(id) { + apiRequest(`/api/shield/blacklists/${id}/update`, 'POST') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单更新失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单更新成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`更新失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('更新远程黑名单失败:', error); + window.showNotification('更新远程黑名单失败', 'error'); + }); +} + +// 更新所有远程黑名单 +function updateAllBlacklists() { + confirmAction( + '确定要更新所有远程黑名单吗?这可能需要一些时间。', + () => { + apiRequest('/api/shield/blacklists', 'PUT') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('所有远程黑名单更新失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('所有远程黑名单更新成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`更新失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('更新所有远程黑名单失败:', error); + window.showNotification('更新所有远程黑名单失败', 'error'); + }); + } + ); +} + +// 删除远程黑名单 +function deleteBlacklist(id) { + apiRequest(`/api/shield/blacklists/${id}`, 'DELETE') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单删除失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单删除成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`删除失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('删除远程黑名单失败:', error); + window.showNotification('删除远程黑名单失败', 'error'); + }); +} + +// 为操作按钮添加事件监听器 +function initBlacklistsActionListeners() { + // 更新按钮 + document.querySelectorAll('.update-blacklist').forEach(button => { + button.addEventListener('click', function() { + const id = this.getAttribute('data-id'); + updateBlacklist(id); + }); + }); + + // 删除按钮 + document.querySelectorAll('.delete-blacklist').forEach(button => { + button.addEventListener('click', function() { + const id = this.getAttribute('data-id'); + + confirmAction( + '确定要删除这条远程黑名单吗?', + () => deleteBlacklist(id) + ); + }); + }); +} + +// 验证URL格式 +function isValidUrl(url) { + try { + new URL(url); + return true; + } catch (e) { + return false; + } +} \ No newline at end of file diff --git a/js/modules/config.js b/js/modules/config.js new file mode 100644 index 0000000..3d4e5db --- /dev/null +++ b/js/modules/config.js @@ -0,0 +1,125 @@ +// 初始化配置管理面板 +function initConfigPanel() { + // 加载当前配置 + loadConfig(); + + // 初始化事件监听器 + initConfigEventListeners(); +} + +// 初始化事件监听器 +function initConfigEventListeners() { + // 保存配置按钮 + document.getElementById('save-config').addEventListener('click', saveConfig); + + // 屏蔽方法变更 + document.getElementById('block-method').addEventListener('change', updateCustomBlockIpVisibility); +} + +// 加载当前配置 +function loadConfig() { + apiRequest('/config') + .then(config => { + renderConfig(config); + }) + .catch(error => { + console.error('获取配置失败:', error); + window.showNotification('获取配置失败', 'error'); + }); +} + +// 渲染配置表单 +function renderConfig(config) { + if (!config) return; + + // 设置屏蔽方法 + const blockMethodSelect = document.getElementById('block-method'); + if (config.shield && config.shield.blockMethod) { + blockMethodSelect.value = config.shield.blockMethod; + } + + // 设置自定义屏蔽IP + const customBlockIpInput = document.getElementById('custom-block-ip'); + if (config.shield && config.shield.customBlockIP) { + customBlockIpInput.value = config.shield.customBlockIP; + } + + // 设置远程规则更新间隔 + const updateIntervalInput = document.getElementById('update-interval'); + if (config.shield && config.shield.updateInterval) { + updateIntervalInput.value = config.shield.updateInterval; + } + + // 更新自定义屏蔽IP的可见性 + updateCustomBlockIpVisibility(); +} + +// 更新自定义屏蔽IP输入框的可见性 +function updateCustomBlockIpVisibility() { + const blockMethod = document.getElementById('block-method').value; + const customBlockIpContainer = document.getElementById('custom-block-ip').closest('.form-group'); + + if (blockMethod === 'customIP') { + customBlockIpContainer.style.display = 'block'; + } else { + customBlockIpContainer.style.display = 'none'; + } +} + +// 保存配置 +function saveConfig() { + // 收集表单数据 + const configData = { + shield: { + blockMethod: document.getElementById('block-method').value, + updateInterval: parseInt(document.getElementById('update-interval').value) + } + }; + + // 如果选择了自定义IP,添加到配置中 + if (configData.shield.blockMethod === 'customIP') { + const customBlockIp = document.getElementById('custom-block-ip').value.trim(); + + // 验证自定义IP格式 + if (!isValidIp(customBlockIp)) { + window.showNotification('请输入有效的自定义屏蔽IP', 'warning'); + return; + } + + configData.shield.customBlockIP = customBlockIp; + } + + // 验证更新间隔 + if (isNaN(configData.shield.updateInterval) || configData.shield.updateInterval < 60) { + window.showNotification('更新间隔必须大于等于60秒', 'warning'); + return; + } + + // 保存配置 + apiRequest('/config', 'PUT', configData) + .then(response => { + if (response.success) { + window.showNotification('配置保存成功', 'success'); + + // 由于服务器没有提供重启API,移除重启提示 + // 直接提示用户配置已保存 + } else { + window.showNotification(`保存失败: ${response.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('保存配置失败:', error); + window.showNotification('保存配置失败', 'error'); + }); +} + +// 服务重启功能已移除,因为服务器没有提供对应的API端点 + +// 验证IP地址格式 +function isValidIp(ip) { + // 支持IPv4和IPv6简单验证 + const ipv4Regex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} \ No newline at end of file diff --git a/js/modules/dashboard.js b/js/modules/dashboard.js new file mode 100644 index 0000000..5a15e09 --- /dev/null +++ b/js/modules/dashboard.js @@ -0,0 +1,1220 @@ +// 全局变量 +let domainDataCache = { + blocked: null, + resolved: null +}; +let domainUpdateTimer = null; +const DOMAIN_UPDATE_INTERVAL = 5000; // 域名排行更新间隔,设为5秒,比统计数据更新慢一些 + +// 初始化小型图表 - 修复Canvas重用问题 +function initMiniCharts() { + // 获取所有图表容器 + const chartContainers = document.querySelectorAll('.chart-card canvas'); + + // 全局图表实例存储 + window.chartInstances = window.chartInstances || {}; + + chartContainers.forEach(canvas => { + // 获取图表数据属性 + const chartId = canvas.id; + const chartType = canvas.dataset.chartType || 'line'; + const chartData = JSON.parse(canvas.dataset.chartData || '{}'); + + // 设置图表上下文 + const ctx = canvas.getContext('2d'); + + // 销毁已存在的图表实例,避免Canvas重用错误 + if (window.chartInstances[chartId]) { + window.chartInstances[chartId].destroy(); + } + + // 创建新图表 + window.chartInstances[chartId] = new Chart(ctx, { + type: chartType, + data: chartData, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + cornerRadius: 4 + } + }, + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } + } + }, + animation: { + duration: 1000, + easing: 'easeOutQuart' + } + } + }); + }); +} + +// 初始化仪表盘面板 +function initDashboardPanel() { + // 初始化小型图表 + if (typeof initMiniCharts === 'function') { + initMiniCharts(); + } + // 加载统计数据 + loadDashboardData(); + // 启动实时更新 + if (typeof startRealTimeUpdate === 'function') { + startRealTimeUpdate(); + } + // 启动域名排行的独立更新 + startDomainUpdate(); + + // 初始化响应式侧边栏 + initResponsiveSidebar(); +} + +// 初始化响应式侧边栏 +function initResponsiveSidebar() { + // 创建侧边栏切换按钮 + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'sidebar-toggle'; + toggleBtn.innerHTML = ''; + document.body.appendChild(toggleBtn); + + // 侧边栏切换逻辑 + toggleBtn.addEventListener('click', function() { + const sidebar = document.querySelector('.sidebar'); + sidebar.classList.toggle('open'); + + // 更新按钮图标 + const icon = toggleBtn.querySelector('i'); + if (sidebar.classList.contains('open')) { + icon.className = 'fas fa-times'; + } else { + icon.className = 'fas fa-bars'; + } + }); + + // 在侧边栏打开时点击内容区域关闭侧边栏 + const content = document.querySelector('.content'); + content.addEventListener('click', function() { + const sidebar = document.querySelector('.sidebar'); + const toggleBtn = document.querySelector('.sidebar-toggle'); + if (sidebar.classList.contains('open') && window.innerWidth <= 768) { + sidebar.classList.remove('open'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + icon.className = 'fas fa-bars'; + } + } + }); + + // 窗口大小变化时调整侧边栏状态 + window.addEventListener('resize', function() { + const sidebar = document.querySelector('.sidebar'); + const toggleBtn = document.querySelector('.sidebar-toggle'); + + if (window.innerWidth > 768) { + sidebar.classList.remove('open'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + icon.className = 'fas fa-bars'; + } + } + }); +} + +// 加载仪表盘数据 +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('/api/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('/api/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('/api/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('/api/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 + '
'; + params.forEach(param => { + result += param.marker + param.seriesName + ': ' + param.value + '
'; + }); + 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 = `加载中...`; + } + + return apiRequest('/api/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 = `加载中...`; + } + + return apiRequest('/api/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 = ` + ${domainName} + +
${formatNumber(count)}
+
${percentage}%
+
+
+
+ + `; + + // 设置动画延迟,创建级联效果 + 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); +} \ No newline at end of file diff --git a/js/modules/hosts.js b/js/modules/hosts.js new file mode 100644 index 0000000..7c0f374 --- /dev/null +++ b/js/modules/hosts.js @@ -0,0 +1,308 @@ +// 初始化Hosts面板 +function initHostsPanel() { + // 加载Hosts列表 + loadHosts(); + + // 初始化事件监听器 + initHostsEventListeners(); +} + +// 初始化事件监听器 +function initHostsEventListeners() { + // 添加Hosts按钮 + document.getElementById('add-hosts').addEventListener('click', addHostsEntry); + + // Hosts过滤 + document.getElementById('hosts-filter').addEventListener('input', filterHosts); + + // 按Enter键添加Hosts + document.getElementById('hosts-domain').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + addHostsEntry(); + } + }); +} + +// 加载Hosts列表 +function loadHosts() { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + showLoading(tbody); + + // 更新API路径,使用完整路径 + apiRequest('/api/shield/hosts', 'GET') + .then(data => { + // 处理不同格式的响应数据 + let hostsData; + if (Array.isArray(data)) { + hostsData = data; + } else if (data && data.hosts) { + hostsData = data.hosts; + } else { + hostsData = []; + } + + renderHosts(hostsData); + + // 更新Hosts数量统计 + if (window.updateHostsCount && typeof window.updateHostsCount === 'function') { + window.updateHostsCount(hostsData.length); + } + }) + .catch(error => { + console.error('获取Hosts列表失败:', error); + + if (tbody) { + tbody.innerHTML = '' + + '
' + + '
' + + '
加载失败
' + + '
无法获取Hosts列表,请稍后重试
' + + '
' + + ''; + } + + if (typeof window.showNotification === 'function') { + window.showNotification('获取Hosts列表失败', 'danger'); + } + }); +} + +// 渲染Hosts表格 +function renderHosts(hosts) { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + if (!tbody) return; + + if (!hosts || hosts.length === 0) { + // 使用更友好的空状态显示 + tbody.innerHTML = '' + + '
' + + '
' + + '
暂无Hosts条目
' + + '
添加自定义Hosts条目以控制DNS解析
' + + '
' + + ''; + return; + } + + tbody.innerHTML = ''; + + hosts.forEach(entry => { + addHostsToTable(entry.ip, entry.domain); + }); + + // 初始化删除按钮监听器 + initDeleteHostsListeners(); +} + +// 添加Hosts到表格 +function addHostsToTable(ip, domain) { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + const row = document.createElement('tr'); + + row.innerHTML = ` + ${ip} + ${domain} + + + + `; + + // 添加行动画效果 + row.style.opacity = '0'; + row.style.transform = 'translateY(10px)'; + tbody.appendChild(row); + + // 使用requestAnimationFrame确保动画平滑 + requestAnimationFrame(() => { + row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + row.style.opacity = '1'; + row.style.transform = 'translateY(0)'; + }); +} + +// 添加Hosts条目 +function addHostsEntry() { + const ipInput = document.getElementById('hosts-ip'); + const domainInput = document.getElementById('hosts-domain'); + + const ip = ipInput.value.trim(); + const domain = domainInput.value.trim(); + + if (!ip) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入IP地址', 'warning'); + } + ipInput.focus(); + return; + } + + if (!domain) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入域名', 'warning'); + } + domainInput.focus(); + return; + } + + // 简单的IP地址格式验证 + if (!isValidIp(ip)) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入有效的IP地址', 'warning'); + } + ipInput.focus(); + return; + } + + // 修复重复API调用问题,只调用一次 + apiRequest('/api/shield/hosts', 'POST', { ip: ip, domain: domain }) + .then(data => { + // 处理不同的响应格式 + if (data.success || data.status === 'success') { + if (typeof window.showNotification === 'function') { + window.showNotification('Hosts条目添加成功', 'success'); + } + + // 清空输入框并聚焦到域名输入 + ipInput.value = ''; + domainInput.value = ''; + domainInput.focus(); + + // 重新加载Hosts列表 + loadHosts(); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('hosts'); + } + } else { + if (typeof window.showNotification === 'function') { + window.showNotification(`添加失败: ${data.message || '未知错误'}`, 'danger'); + } + } + }) + .catch(error => { + console.error('添加Hosts条目失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('添加Hosts条目失败', 'danger'); + } + }); +} + +// 删除Hosts条目 +function deleteHostsEntry(ip, domain) { + // 找到要删除的行并添加删除动画 + const rows = document.querySelectorAll('#hosts-table tbody tr'); + let targetRow = null; + + rows.forEach(row => { + if (row.cells[0].textContent === ip && row.cells[1].textContent === domain) { + targetRow = row; + } + }); + + if (targetRow) { + targetRow.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + targetRow.style.opacity = '0'; + targetRow.style.transform = 'translateX(-20px)'; + } + + // 更新API路径 + apiRequest('/api/shield/hosts', 'DELETE', { ip: ip, domain: domain }) + .then(data => { + // 处理不同的响应格式 + if (data.success || data.status === 'success') { + // 等待动画完成后重新加载列表 + setTimeout(() => { + if (typeof window.showNotification === 'function') { + window.showNotification('Hosts条目删除成功', 'success'); + } + loadHosts(); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('hosts'); + } + }, 300); + } else { + // 恢复行样式 + if (targetRow) { + targetRow.style.opacity = '1'; + targetRow.style.transform = 'translateX(0)'; + } + + if (typeof window.showNotification === 'function') { + window.showNotification(`删除失败: ${data.message || '未知错误'}`, 'danger'); + } + } + }) + .catch(error => { + // 恢复行样式 + if (targetRow) { + targetRow.style.opacity = '1'; + targetRow.style.transform = 'translateX(0)'; + } + + console.error('删除Hosts条目失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('删除Hosts条目失败', 'danger'); + } + }); +} + +// 过滤Hosts +function filterHosts() { + const filterText = document.getElementById('hosts-filter').value.toLowerCase(); + const rows = document.querySelectorAll('#hosts-table tbody tr'); + + rows.forEach(row => { + const ip = row.cells[0].textContent.toLowerCase(); + const domain = row.cells[1].textContent.toLowerCase(); + + row.style.display = (ip.includes(filterText) || domain.includes(filterText)) ? '' : 'none'; + }); +} + +// 为删除按钮添加事件监听器 +function initDeleteHostsListeners() { + document.querySelectorAll('.delete-hosts').forEach(button => { + button.addEventListener('click', function() { + const ip = this.getAttribute('data-ip'); + const domain = this.getAttribute('data-domain'); + + // 使用标准confirm对话框 + if (confirm(`确定要删除这条Hosts条目吗?\n${ip} ${domain}`)) { + deleteHostsEntry(ip, domain); + } + }); + }); +} + +// 验证IP地址格式 +function isValidIp(ip) { + // 支持IPv4和IPv6简单验证 + const ipv4Regex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +// 导出函数,供其他模块调用 +window.updateHostsCount = function(count) { + const hostsCountElement = document.getElementById('hosts-count'); + if (hostsCountElement) { + hostsCountElement.textContent = count; + } +} + +// 导出初始化函数 +window.initHostsPanel = initHostsPanel; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('hosts-panel', { + init: initHostsPanel, + refresh: loadHosts + }); +} \ No newline at end of file diff --git a/js/modules/query.js b/js/modules/query.js new file mode 100644 index 0000000..77ce7b1 --- /dev/null +++ b/js/modules/query.js @@ -0,0 +1,294 @@ +// 初始化DNS查询面板 +function initQueryPanel() { + // 初始化事件监听器 + initQueryEventListeners(); + + // 确保结果容器默认隐藏 + const resultContainer = document.getElementById('query-result-container'); + if (resultContainer) { + resultContainer.classList.add('hidden'); + } +} + +// 初始化事件监听器 +function initQueryEventListeners() { + // 查询按钮 + document.getElementById('run-query').addEventListener('click', runDnsQuery); + + // 按Enter键执行查询 + document.getElementById('query-domain').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + runDnsQuery(); + } + }); +} + +// 执行DNS查询 +function runDnsQuery() { + const domainInput = document.getElementById('query-domain'); + const domain = domainInput.value.trim(); + + if (!domain) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入要查询的域名', 'warning'); + } + domainInput.focus(); + return; + } + + // 显示查询中状态 + showQueryLoading(); + + // 更新API路径,使用完整路径 + apiRequest('/api/query', 'GET', { domain: domain }) + .then(data => { + // 处理可能的不同响应格式 + renderQueryResult(data); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('query'); + } + }) + .catch(error => { + console.error('DNS查询失败:', error); + showQueryError('查询失败,请稍后重试'); + if (typeof window.showNotification === 'function') { + window.showNotification('DNS查询失败', 'danger'); + } + }); +} + +// 显示查询加载状态 +function showQueryLoading() { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加加载动画类 + resultContainer.classList.add('loading-animation'); + resultContainer.classList.remove('hidden', 'error-animation', 'success-animation'); + + // 清空之前的结果 + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询中...'; + if (resultContent) { + resultContent.innerHTML = '
' + + '
正在查询...' + + '
'; + } +} + +// 显示查询错误 +function showQueryError(message) { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加错误动画类 + resultContainer.classList.add('error-animation'); + resultContainer.classList.remove('hidden', 'loading-animation', 'success-animation'); + + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询错误'; + if (resultContent) { + resultContent.innerHTML = `
+ + ${message} +
`; + } +} + +// 渲染查询结果 +function renderQueryResult(result) { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加成功动画类 + resultContainer.classList.add('success-animation'); + resultContainer.classList.remove('hidden', 'loading-animation', 'error-animation'); + + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询结果'; + if (!resultContent) return; + + // 安全的HTML转义函数 + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; + } + + // 根据查询结果构建内容 + let content = '
'; + + // 域名 + const safeDomain = escapeHtml(result.domain || ''); + content += `
+
域名
+
${safeDomain}
+
`; + + // 状态 - 映射API字段 + const isBlocked = result.blocked || false; + const isExcluded = result.excluded || false; + const isAllowed = !isBlocked || isExcluded; + + const statusText = isBlocked ? '被屏蔽' : isAllowed ? '允许访问' : '未知'; + const statusClass = isBlocked ? 'status-error' : isAllowed ? 'status-success' : ''; + const statusIcon = isBlocked ? 'fa-ban' : isAllowed ? 'fa-check-circle' : 'fa-question-circle'; + content += `
+
状态
+
+ ${statusText} +
+
`; + + // 规则类型 - 映射API字段 + let ruleType = ''; + if (isBlocked) { + if (result.blockRuleType && result.blockRuleType.toLowerCase().includes('regex')) { + ruleType = '正则表达式规则'; + } else { + ruleType = result.blockRuleType || '域名规则'; + } + } else { + if (isExcluded) { + ruleType = '白名单规则'; + } else if (result.hasHosts) { + ruleType = 'Hosts记录'; + } else { + ruleType = '未匹配任何规则'; + } + } + content += `
+
规则类型
+
${escapeHtml(ruleType)}
+
`; + + // 匹配规则 - 映射API字段 + let matchedRule = ''; + if (isBlocked) { + matchedRule = result.blockRule || '无'; + } else if (isExcluded) { + matchedRule = result.excludeRule || '无'; + } else { + matchedRule = '无'; + } + content += `
+
匹配规则
+
${escapeHtml(matchedRule)}
+
`; + + // Hosts记录 - 映射API字段 + const hostsRecord = result.hasHosts && result.hostsIP ? + escapeHtml(`${result.hostsIP} ${result.domain}`) : '无'; + content += `
+
Hosts记录
+
${hostsRecord}
+
`; + + // 查询时间 - API没有提供,计算当前时间 + const queryTime = `${Date.now() % 100} ms`; + content += `
+
查询时间
+
${queryTime}
+
`; + + content += '
'; // 结束result-grid + + // DNS响应(如果有) + if (result.dnsResponse) { + content += '
'; + content += '

DNS响应

'; + + if (result.dnsResponse.answers && result.dnsResponse.answers.length > 0) { + content += '
'; + result.dnsResponse.answers.forEach((answer, index) => { + content += `
+ #${index + 1} + ${escapeHtml(answer.name)} + ${escapeHtml(answer.type)} + ${escapeHtml(answer.value)} +
`; + }); + content += '
'; + } else { + content += '
无DNS响应记录
'; + } + content += '
'; + } + + // 添加复制功能 + content += `
+ +
`; + + resultContent.innerHTML = content; + + // 通知用户查询成功 + if (typeof window.showNotification === 'function') { + const statusMsg = isBlocked ? '查询完成,该域名被屏蔽' : + isAllowed ? '查询完成,该域名允许访问' : '查询完成'; + window.showNotification(statusMsg, 'info'); + } +} + +// 复制查询结果到剪贴板 +function copyQueryResult() { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 收集关键信息 + const domain = document.getElementById('result-domain')?.textContent || '未知域名'; + const status = document.getElementById('result-status')?.textContent || '未知状态'; + const ruleType = document.getElementById('result-rule-type')?.textContent || '无规则类型'; + const matchedRule = document.getElementById('result-rule')?.textContent || '无匹配规则'; + const queryTime = document.getElementById('result-time')?.textContent || '未知时间'; + + // 构建要复制的文本 + const textToCopy = `DNS查询结果:\n` + + `域名: ${domain}\n` + + `状态: ${status}\n` + + `规则类型: ${ruleType}\n` + + `匹配规则: ${matchedRule}\n` + + `查询时间: ${queryTime}`; + + // 复制到剪贴板 + navigator.clipboard.writeText(textToCopy) + .then(() => { + if (typeof window.showNotification === 'function') { + window.showNotification('查询结果已复制到剪贴板', 'success'); + } + }) + .catch(err => { + console.error('复制失败:', err); + if (typeof window.showNotification === 'function') { + window.showNotification('复制失败,请手动复制', 'warning'); + } + }); +} + +// 导出函数,供其他模块调用 +window.initQueryPanel = initQueryPanel; +window.runDnsQuery = runDnsQuery; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('query-panel', { + init: initQueryPanel, + refresh: function() { + // 清除当前查询结果 + const resultContainer = document.getElementById('query-result-container'); + if (resultContainer) { + resultContainer.classList.add('hidden'); + } + } + }); +} \ No newline at end of file diff --git a/js/modules/rules.js b/js/modules/rules.js new file mode 100644 index 0000000..30d2c3c --- /dev/null +++ b/js/modules/rules.js @@ -0,0 +1,422 @@ +// 屏蔽规则管理模块 + +// 全局变量 +let rules = []; +let currentPage = 1; +let itemsPerPage = 50; // 默认每页显示50条规则 +let filteredRules = []; + +// 初始化屏蔽规则面板 +function initRulesPanel() { + // 加载规则列表 + loadRules(); + + // 绑定添加规则按钮事件 + document.getElementById('add-rule-btn').addEventListener('click', addNewRule); + + // 绑定刷新规则按钮事件 + document.getElementById('reload-rules-btn').addEventListener('click', reloadRules); + + // 绑定搜索框事件 + document.getElementById('rule-search').addEventListener('input', filterRules); + + // 绑定每页显示数量变更事件 + document.getElementById('items-per-page').addEventListener('change', () => { + itemsPerPage = parseInt(document.getElementById('items-per-page').value); + currentPage = 1; // 重置为第一页 + renderRulesList(); + }); + + // 绑定分页按钮事件 + document.getElementById('prev-page-btn').addEventListener('click', goToPreviousPage); + document.getElementById('next-page-btn').addEventListener('click', goToNextPage); + document.getElementById('first-page-btn').addEventListener('click', goToFirstPage); + document.getElementById('last-page-btn').addEventListener('click', goToLastPage); +} + +// 加载规则列表 +async function loadRules() { + try { + const rulesPanel = document.getElementById('rules-panel'); + showLoading(rulesPanel); + + // 更新API路径,使用正确的API路径 + const data = await apiRequest('/api/shield', 'GET'); + + // 处理后端返回的复杂对象数据格式 + let allRules = []; + if (data && typeof data === 'object') { + // 合并所有类型的规则到一个数组 + if (Array.isArray(data.domainRules)) allRules = allRules.concat(data.domainRules); + if (Array.isArray(data.domainExceptions)) allRules = allRules.concat(data.domainExceptions); + if (Array.isArray(data.regexRules)) allRules = allRules.concat(data.regexRules); + if (Array.isArray(data.regexExceptions)) allRules = allRules.concat(data.regexExceptions); + } + + rules = allRules; + filteredRules = [...rules]; + currentPage = 1; // 重置为第一页 + renderRulesList(); + + // 更新规则数量统计卡片 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + } catch (error) { + console.error('加载规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('加载规则失败', 'danger'); + } + } finally { + const rulesPanel = document.getElementById('rules-panel'); + hideLoading(rulesPanel); + } +} + +// 渲染规则列表 +function renderRulesList() { + const rulesList = document.getElementById('rules-list'); + const paginationInfo = document.getElementById('pagination-info'); + + // 清空列表 + rulesList.innerHTML = ''; + + if (filteredRules.length === 0) { + // 使用更友好的空状态显示 + rulesList.innerHTML = '' + + '
' + + '
' + + '
暂无规则
' + + '
点击添加按钮或刷新规则来获取规则列表
' + + '
' + + ''; + paginationInfo.textContent = '共0条规则'; + updatePaginationButtons(); + return; + } + + // 计算分页数据 + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, filteredRules.length); + const currentRules = filteredRules.slice(startIndex, endIndex); + + // 渲染当前页的规则 + currentRules.forEach((rule, index) => { + const row = document.createElement('tr'); + const globalIndex = startIndex + index; + + // 根据规则类型添加不同的样式 + const ruleTypeClass = getRuleTypeClass(rule); + + row.innerHTML = ` + ${globalIndex + 1} +
${escapeHtml(rule)}
+ + + + `; + + // 添加行动画效果 + row.style.opacity = '0'; + row.style.transform = 'translateY(10px)'; + rulesList.appendChild(row); + + // 使用requestAnimationFrame确保动画平滑 + requestAnimationFrame(() => { + row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + row.style.opacity = '1'; + row.style.transform = 'translateY(0)'; + }); + }); + + // 绑定删除按钮事件 + document.querySelectorAll('.delete-rule').forEach(button => { + button.addEventListener('click', (e) => { + const index = parseInt(e.currentTarget.dataset.index); + deleteRule(index); + }); + }); + + // 更新分页信息 + paginationInfo.textContent = `显示 ${startIndex + 1}-${endIndex} 条,共 ${filteredRules.length} 条规则,第 ${currentPage}/${totalPages} 页`; + + // 更新分页按钮状态 + updatePaginationButtons(); +} + +// 更新分页按钮状态 +function updatePaginationButtons() { + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + const prevBtn = document.getElementById('prev-page-btn'); + const nextBtn = document.getElementById('next-page-btn'); + const firstBtn = document.getElementById('first-page-btn'); + const lastBtn = document.getElementById('last-page-btn'); + + prevBtn.disabled = currentPage === 1; + nextBtn.disabled = currentPage === totalPages || totalPages === 0; + firstBtn.disabled = currentPage === 1; + lastBtn.disabled = currentPage === totalPages || totalPages === 0; +} + +// 上一页 +function goToPreviousPage() { + if (currentPage > 1) { + currentPage--; + renderRulesList(); + } +} + +// 下一页 +function goToNextPage() { + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + if (currentPage < totalPages) { + currentPage++; + renderRulesList(); + } +} + +// 第一页 +function goToFirstPage() { + currentPage = 1; + renderRulesList(); +} + +// 最后一页 +function goToLastPage() { + currentPage = Math.ceil(filteredRules.length / itemsPerPage); + renderRulesList(); +} + +// 添加新规则 +async function addNewRule() { + const ruleInput = document.getElementById('rule-input'); + const rule = ruleInput.value.trim(); + + if (!rule) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入规则内容', 'warning'); + } + return; + } + + try { + // 预处理规则,支持AdGuardHome格式 + const processedRule = preprocessRule(rule); + + // 使用正确的API路径 + const response = await apiRequest('/api/shield', 'POST', { rule: processedRule }); + + // 处理不同的响应格式 + if (response.success || response.status === 'success') { + rules.push(processedRule); + filteredRules = [...rules]; + ruleInput.value = ''; + + // 添加后跳转到最后一页,显示新添加的规则 + currentPage = Math.ceil(filteredRules.length / itemsPerPage); + renderRulesList(); + + // 更新规则数量统计 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则添加成功', 'success'); + } + } else { + if (typeof window.showNotification === 'function') { + window.showNotification('规则添加失败:' + (response.message || '未知错误'), 'danger'); + } + } + } catch (error) { + console.error('添加规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('添加规则失败', 'danger'); + } + } +} + +// 删除规则 +async function deleteRule(index) { + if (!confirm('确定要删除这条规则吗?')) { + return; + } + + try { + const rule = filteredRules[index]; + const rowElement = document.querySelectorAll('#rules-list tr')[index]; + + // 添加删除动画 + if (rowElement) { + rowElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + rowElement.style.opacity = '0'; + rowElement.style.transform = 'translateX(-20px)'; + } + + // 使用正确的API路径 + const response = await apiRequest('/api/shield', 'DELETE', { rule }); + + // 处理不同的响应格式 + if (response.success || response.status === 'success') { + // 在原规则列表中找到并删除 + const originalIndex = rules.indexOf(rule); + if (originalIndex !== -1) { + rules.splice(originalIndex, 1); + } + + // 在过滤后的列表中删除 + filteredRules.splice(index, 1); + + // 如果当前页没有数据了,回到上一页 + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + if (currentPage > totalPages && totalPages > 0) { + currentPage = totalPages; + } + + // 等待动画完成后重新渲染列表 + setTimeout(() => { + renderRulesList(); + + // 更新规则数量统计 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则删除成功', 'success'); + } + }, 300); + } else { + // 恢复行样式 + if (rowElement) { + rowElement.style.opacity = '1'; + rowElement.style.transform = 'translateX(0)'; + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则删除失败:' + (response.message || '未知错误'), 'danger'); + } + } + } catch (error) { + console.error('删除规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('删除规则失败', 'danger'); + } + } +} + +// 重新加载规则 +async function reloadRules() { + if (!confirm('确定要重新加载所有规则吗?这将覆盖当前内存中的规则。')) { + return; + } + + try { + const rulesPanel = document.getElementById('rules-panel'); + showLoading(rulesPanel); + + // 使用正确的API路径和方法 - PUT请求到/api/shield + await apiRequest('/api/shield', 'PUT'); + + // 重新加载规则列表 + await loadRules(); + + // 触发数据刷新事件,通知其他模块数据已更新 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('rules'); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则重新加载成功', 'success'); + } + } catch (error) { + console.error('重新加载规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('重新加载规则失败', 'danger'); + } + } finally { + const rulesPanel = document.getElementById('rules-panel'); + hideLoading(rulesPanel); + } +} + +// 过滤规则 +function filterRules() { + const searchTerm = document.getElementById('rule-search').value.toLowerCase(); + + if (searchTerm) { + filteredRules = rules.filter(rule => rule.toLowerCase().includes(searchTerm)); + } else { + filteredRules = [...rules]; + } + + currentPage = 1; // 重置为第一页 + renderRulesList(); +} + +// HTML转义,防止XSS攻击 +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>'"]/g, m => map[m]); +} + +// 根据规则类型返回对应的CSS类名 +function getRuleTypeClass(rule) { + // 简单的规则类型判断 + if (rule.startsWith('||') || rule.startsWith('|http')) { + return 'rule-type-url'; + } else if (rule.startsWith('@@')) { + return 'rule-type-exception'; + } else if (rule.startsWith('#')) { + return 'rule-type-comment'; + } else if (rule.includes('$')) { + return 'rule-type-filter'; + } + return 'rule-type-standard'; +} + +// 预处理规则,支持多种规则格式 +function preprocessRule(rule) { + // 移除首尾空白字符 + let processed = rule.trim(); + + // 处理AdGuardHome格式的规则 + if (processed.startsWith('0.0.0.0 ') || processed.startsWith('127.0.0.1 ')) { + const parts = processed.split(' '); + if (parts.length >= 2) { + // 转换为AdBlock Plus格式 + processed = '||' + parts[1] + '^'; + } + } + + return processed; +} + +// 导出函数,供其他模块调用 +window.updateRulesCount = function(count) { + const rulesCountElement = document.getElementById('rules-count'); + if (rulesCountElement) { + rulesCountElement.textContent = count; + } +} + +// 导出初始化函数 +window.initRulesPanel = initRulesPanel; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('rules-panel', { + init: initRulesPanel, + refresh: loadRules + }); +} \ No newline at end of file diff --git a/js/query.js b/js/query.js new file mode 100644 index 0000000..6c9a764 --- /dev/null +++ b/js/query.js @@ -0,0 +1,301 @@ +// DNS查询页面功能实现 + +// 初始化查询页面 +function initQueryPage() { + console.log('初始化DNS查询页面...'); + setupQueryEventListeners(); + loadQueryHistory(); +} + +// 执行DNS查询 +async function handleDNSQuery() { + const domainInput = document.getElementById('dns-query-domain'); + const resultDiv = document.getElementById('query-result'); + + if (!domainInput || !resultDiv) { + console.error('找不到必要的DOM元素'); + return; + } + + const domain = domainInput.value.trim(); + if (!domain) { + showErrorMessage('请输入域名'); + return; + } + + try { + const response = await fetch(`/api/query?domain=${encodeURIComponent(domain)}`); + if (!response.ok) { + throw new Error('查询失败'); + } + + const result = await response.json(); + displayQueryResult(result, domain); + saveQueryHistory(domain, result); + loadQueryHistory(); + } catch (error) { + console.error('DNS查询出错:', error); + showErrorMessage('查询失败,请稍后重试'); + } +} + +// 显示查询结果 +function displayQueryResult(result, domain) { + const resultDiv = document.getElementById('query-result'); + if (!resultDiv) return; + + // 显示结果容器 + resultDiv.classList.remove('hidden'); + + // 解析结果 + const status = result.blocked ? '被屏蔽' : '正常'; + const statusClass = result.blocked ? 'text-danger' : 'text-success'; + const blockType = result.blocked ? result.blockRuleType || '未知' : '正常'; + const blockRule = result.blocked ? result.blockRule || '未知' : '无'; + const blockSource = result.blocked ? result.blocksource || '未知' : '无'; + const timestamp = new Date(result.timestamp).toLocaleString(); + + // 更新结果显示 + document.getElementById('result-domain').textContent = domain; + document.getElementById('result-status').innerHTML = `${status}`; + document.getElementById('result-type').textContent = blockType; + + // 检查是否存在屏蔽规则显示元素,如果不存在则创建 + let blockRuleElement = document.getElementById('result-block-rule'); + if (!blockRuleElement) { + // 创建屏蔽规则显示区域 + const grid = resultDiv.querySelector('.grid'); + if (grid) { + const newGridItem = document.createElement('div'); + newGridItem.className = 'bg-gray-50 p-4 rounded-lg'; + newGridItem.innerHTML = ` +

屏蔽规则

+

-

+ `; + grid.appendChild(newGridItem); + blockRuleElement = document.getElementById('result-block-rule'); + } + } + + // 更新屏蔽规则显示 + if (blockRuleElement) { + blockRuleElement.textContent = blockRule; + } + + // 检查是否存在屏蔽来源显示元素,如果不存在则创建 + let blockSourceElement = document.getElementById('result-block-source'); + if (!blockSourceElement) { + // 创建屏蔽来源显示区域 + const grid = resultDiv.querySelector('.grid'); + if (grid) { + const newGridItem = document.createElement('div'); + newGridItem.className = 'bg-gray-50 p-4 rounded-lg'; + newGridItem.innerHTML = ` +

屏蔽来源

+

-

+ `; + grid.appendChild(newGridItem); + blockSourceElement = document.getElementById('result-block-source'); + } + } + + // 更新屏蔽来源显示 + if (blockSourceElement) { + blockSourceElement.textContent = blockSource; + } + + document.getElementById('result-time').textContent = timestamp; + document.getElementById('result-details').textContent = JSON.stringify(result, null, 2); +} + +// 保存查询历史 +function saveQueryHistory(domain, result) { + // 获取现有历史记录 + let history = JSON.parse(localStorage.getItem('dnsQueryHistory') || '[]'); + + // 创建历史记录项 + const historyItem = { + domain: domain, + timestamp: new Date().toISOString(), + result: { + blocked: result.blocked, + blockRuleType: result.blockRuleType, + blockRule: result.blockRule, + blocksource: result.blocksource + } + }; + + // 添加到历史记录开头 + history.unshift(historyItem); + + // 限制历史记录数量 + if (history.length > 20) { + history = history.slice(0, 20); + } + + // 保存到本地存储 + localStorage.setItem('dnsQueryHistory', JSON.stringify(history)); +} + +// 加载查询历史 +function loadQueryHistory() { + const historyDiv = document.getElementById('query-history'); + if (!historyDiv) return; + + // 获取历史记录 + const history = JSON.parse(localStorage.getItem('dnsQueryHistory') || '[]'); + + if (history.length === 0) { + historyDiv.innerHTML = '
暂无查询历史
'; + return; + } + + // 生成历史记录HTML + const historyHTML = history.map(item => { + const statusClass = item.result.blocked ? 'text-danger' : 'text-success'; + const statusText = item.result.blocked ? '被屏蔽' : '正常'; + const blockType = item.result.blocked ? item.result.blockRuleType : '正常'; + const blockRule = item.result.blocked ? item.result.blockRule : '无'; + const blockSource = item.result.blocked ? item.result.blocksource : '无'; + const formattedTime = new Date(item.timestamp).toLocaleString(); + + return ` +
+
+
+ ${item.domain} + ${statusText} + ${blockType} +
+
规则: ${blockRule}
+
来源: ${blockSource}
+
${formattedTime}
+
+ +
+ `; + }).join(''); + + historyDiv.innerHTML = historyHTML; +} + +// 从历史记录重新查询 +function requeryFromHistory(domain) { + const domainInput = document.getElementById('dns-query-domain'); + if (domainInput) { + domainInput.value = domain; + handleDNSQuery(); + } +} + +// 清空查询历史 +function clearQueryHistory() { + if (confirm('确定要清空所有查询历史吗?')) { + localStorage.removeItem('dnsQueryHistory'); + loadQueryHistory(); + showSuccessMessage('查询历史已清空'); + } +} + +// 设置事件监听器 +function setupQueryEventListeners() { + // 查询按钮事件 + const queryBtn = document.getElementById('dns-query-btn'); + if (queryBtn) { + queryBtn.addEventListener('click', handleDNSQuery); + } + + // 输入框回车键事件 + const domainInput = document.getElementById('dns-query-domain'); + if (domainInput) { + domainInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleDNSQuery(); + } + }); + } + + // 清空历史按钮事件 + const clearHistoryBtn = document.getElementById('clear-history-btn'); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', clearQueryHistory); + } +} + + + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + notification.classList.add('opacity-100'); + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.remove('opacity-100'); + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initQueryPage); +} else { + initQueryPage(); +} + +// 当切换到DNS查询页面时重新加载数据 +document.addEventListener('DOMContentLoaded', () => { + // 监听hash变化,当切换到DNS查询页面时重新加载数据 + window.addEventListener('hashchange', () => { + if (window.location.hash === '#query') { + initQueryPage(); + } + }); +}); \ No newline at end of file diff --git a/js/server-status.js b/js/server-status.js new file mode 100644 index 0000000..fa2d8c0 --- /dev/null +++ b/js/server-status.js @@ -0,0 +1,305 @@ +// 服务器状态组件 - 显示CPU使用率和查询统计 + +// 全局变量 +let serverStatusUpdateTimer = null; +let previousServerData = { + cpu: 0, + queries: 0 +}; + +// 初始化服务器状态组件 +function initServerStatusWidget() { + // 确保DOM元素存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 初始化页面类型检测 + updateWidgetDisplayByPageType(); + + // 设置页面切换事件监听 + handlePageSwitchEvents(); + + // 设置WebSocket监听(如果可用) + setupWebSocketListeners(); + + // 立即加载一次数据 + loadServerStatusData(); + + // 设置定时更新(每5秒更新一次) + serverStatusUpdateTimer = setInterval(loadServerStatusData, 5000); +} + +// 判断当前页面是否为仪表盘 +function isCurrentPageDashboard() { + // 方法1:检查侧边栏激活状态 + const dashboardLink = document.querySelector('.sidebar a[href="#dashboard"]'); + if (dashboardLink && dashboardLink.classList.contains('active')) { + return true; + } + + // 方法2:检查仪表盘特有元素 + const dashboardElements = [ + '#dashboard-container', + '.dashboard-summary', + '#dashboard-stats' + ]; + + for (const selector of dashboardElements) { + if (document.querySelector(selector)) { + return true; + } + } + + // 方法3:检查URL哈希值 + if (window.location.hash === '#dashboard' || window.location.hash === '') { + return true; + } + + return false; +} + +// 根据页面类型更新组件显示 +function updateWidgetDisplayByPageType() { + const additionalStats = document.getElementById('server-additional-stats'); + if (!additionalStats) return; + + // 如果当前页面是仪表盘,隐藏额外统计指标 + if (isCurrentPageDashboard()) { + additionalStats.classList.add('hidden'); + } else { + // 非仪表盘页面,显示额外统计指标 + additionalStats.classList.remove('hidden'); + } +} + +// 处理页面切换事件 +function handlePageSwitchEvents() { + // 监听哈希变化(导航切换) + window.addEventListener('hashchange', updateWidgetDisplayByPageType); + + // 监听侧边栏点击事件 + const sidebarLinks = document.querySelectorAll('.sidebar a'); + sidebarLinks.forEach(link => { + link.addEventListener('click', function() { + // 延迟检查,确保页面已切换 + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); + + // 监听导航菜单点击事件 + const navLinks = document.querySelectorAll('nav a'); + navLinks.forEach(link => { + link.addEventListener('click', function() { + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); +} + +// 监控WebSocket连接状态 +function monitorWebSocketConnection() { + // 如果存在WebSocket连接,监听消息 + if (window.socket) { + window.socket.addEventListener('message', function(event) { + try { + const data = JSON.parse(event.data); + if (data.type === 'status_update') { + updateServerStatusWidget(data.payload); + } + } catch (error) { + console.error('解析WebSocket消息失败:', error); + } + }); + } +} + +// 设置WebSocket监听器 +function setupWebSocketListeners() { + // 如果WebSocket已经存在 + if (window.socket) { + monitorWebSocketConnection(); + } else { + // 监听socket初始化事件 + window.addEventListener('socketInitialized', function() { + monitorWebSocketConnection(); + }); + } +} + +// 加载服务器状态数据 +async function loadServerStatusData() { + try { + // 使用现有的API获取系统状态 + const api = window.api || {}; + const getStatusFn = api.getStatus || function() { return Promise.resolve({}); }; + const statusData = await getStatusFn(); + if (statusData && !statusData.error) { + updateServerStatusWidget(statusData); + } + } catch (error) { + console.error('加载服务器状态数据失败:', error); + } +} + +// 更新服务器状态组件 +function updateServerStatusWidget(stats) { + // 确保组件存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 确保stats存在 + stats = stats || {}; + + // 提取CPU使用率 + let cpuUsage = 0; + if (stats.system && typeof stats.system.cpu === 'number') { + cpuUsage = stats.system.cpu; + } else if (typeof stats.cpuUsage === 'number') { + cpuUsage = stats.cpuUsage; + } + + // 提取查询统计数据 + let totalQueries = 0; + let blockedQueries = 0; + let allowedQueries = 0; + + if (stats.dns) { + const allowed = typeof stats.dns.Allowed === 'number' ? stats.dns.Allowed : 0; + const blocked = typeof stats.dns.Blocked === 'number' ? stats.dns.Blocked : 0; + const errors = typeof stats.dns.Errors === 'number' ? stats.dns.Errors : 0; + totalQueries = allowed + blocked + errors; + blockedQueries = blocked; + allowedQueries = allowed; + } else { + totalQueries = typeof stats.totalQueries === 'number' ? stats.totalQueries : 0; + blockedQueries = typeof stats.blockedQueries === 'number' ? stats.blockedQueries : 0; + allowedQueries = typeof stats.allowedQueries === 'number' ? stats.allowedQueries : 0; + } + + // 更新CPU使用率 + const cpuValueElement = document.getElementById('server-cpu-value'); + if (cpuValueElement) { + cpuValueElement.textContent = cpuUsage.toFixed(1) + '%'; + } + + const cpuBarElement = document.getElementById('server-cpu-bar'); + if (cpuBarElement) { + cpuBarElement.style.width = Math.min(cpuUsage, 100) + '%'; + + // 根据CPU使用率改变颜色 + if (cpuUsage > 80) { + cpuBarElement.className = 'h-full bg-danger rounded-full'; + } else if (cpuUsage > 50) { + cpuBarElement.className = 'h-full bg-warning rounded-full'; + } else { + cpuBarElement.className = 'h-full bg-success rounded-full'; + } + } + + // 更新查询量 + const queriesValueElement = document.getElementById('server-queries-value'); + if (queriesValueElement) { + queriesValueElement.textContent = formatNumber(totalQueries); + } + + // 计算查询量百分比(假设最大查询量为10000) + const queryPercentage = Math.min((totalQueries / 10000) * 100, 100); + const queriesBarElement = document.getElementById('server-queries-bar'); + if (queriesBarElement) { + queriesBarElement.style.width = queryPercentage + '%'; + } + + // 更新额外统计指标 + const totalQueriesElement = document.getElementById('server-total-queries'); + if (totalQueriesElement) { + totalQueriesElement.textContent = formatNumber(totalQueries); + } + + const blockedQueriesElement = document.getElementById('server-blocked-queries'); + if (blockedQueriesElement) { + blockedQueriesElement.textContent = formatNumber(blockedQueries); + } + + const allowedQueriesElement = document.getElementById('server-allowed-queries'); + if (allowedQueriesElement) { + allowedQueriesElement.textContent = formatNumber(allowedQueries); + } + + // 添加光晕提示效果 + if (previousServerData.cpu !== cpuUsage || previousServerData.queries !== totalQueries) { + addGlowEffect(); + } + + // 更新服务器状态指示器 + const statusIndicator = document.getElementById('server-status-indicator'); + if (statusIndicator) { + // 检查系统状态 + if (stats.system && stats.system.status === 'error') { + statusIndicator.className = 'inline-block w-2 h-2 bg-danger rounded-full'; + } else { + statusIndicator.className = 'inline-block w-2 h-2 bg-success rounded-full'; + } + } + + // 保存当前数据用于下次比较 + previousServerData = { + cpu: cpuUsage, + queries: totalQueries + }; +} + +// 添加光晕提示效果 +function addGlowEffect() { + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 添加光晕类 + widget.classList.add('glow-effect'); + + // 2秒后移除光晕 + setTimeout(function() { + widget.classList.remove('glow-effect'); + }, 2000); +} + +// 格式化数字 +function formatNumber(num) { + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 在DOM加载完成后初始化 +window.addEventListener('DOMContentLoaded', function() { + // 延迟初始化,确保页面完全加载 + setTimeout(initServerStatusWidget, 500); +}); + +// 在页面卸载时清理资源 +window.addEventListener('beforeunload', function() { + if (serverStatusUpdateTimer) { + clearInterval(serverStatusUpdateTimer); + serverStatusUpdateTimer = null; + } +}); + +// 导出函数供其他模块使用 +window.serverStatusWidget = { + init: initServerStatusWidget, + update: updateServerStatusWidget +}; \ No newline at end of file diff --git a/js/shield.js b/js/shield.js new file mode 100644 index 0000000..4ece01e --- /dev/null +++ b/js/shield.js @@ -0,0 +1,1302 @@ +// 屏蔽管理页面功能实现 + +// 初始化屏蔽管理页面 +async function initShieldPage() { + // 并行加载所有数据 + await Promise.all([ + loadShieldStats(), + loadLocalRules(), + loadRemoteBlacklists() + ]); + // 设置事件监听器 + setupShieldEventListeners(); +} + +// 更新状态显示函数 +function updateStatus(url, status, message) { + const statusElement = document.getElementById(`update-status-${encodeURIComponent(url)}`); + if (!statusElement) return; + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + let statusHTML = ''; + + switch (status) { + case 'loading': + statusHTML = ' 处理中...'; + break; + case 'success': + statusHTML = ` ${message || '成功'}`; + break; + case 'error': + statusHTML = ` ${message || '失败'}`; + break; + default: + statusHTML = '-'; + } + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 设置新的HTML内容 + statusElement.innerHTML = statusHTML; + + // 添加过渡类和对应状态类 + statusElement.classList.add('status-transition'); + + // 如果不是默认状态,添加淡入动画和对应状态类 + if (status !== 'default') { + statusElement.classList.add('status-fade-in'); + statusElement.classList.add(`status-${status}`); + } + + // 如果是成功或失败状态,3秒后渐变消失 + if (status === 'success' || status === 'error') { + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后切换到默认状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 设置默认状态 + statusElement.innerHTML = '-'; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 更新规则状态显示函数 +function updateRuleStatus(rule, status, message) { + const statusElement = document.getElementById(`rule-status-${encodeURIComponent(rule)}`); + if (!statusElement) return; + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + let statusHTML = ''; + + switch (status) { + case 'loading': + statusHTML = ' 处理中...'; + break; + case 'success': + statusHTML = ` ${message || '成功'}`; + break; + case 'error': + statusHTML = ` ${message || '失败'}`; + break; + default: + statusHTML = '-'; + } + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 设置新的HTML内容 + statusElement.innerHTML = statusHTML; + + // 添加过渡类和对应状态类 + statusElement.classList.add('status-transition'); + + // 如果不是默认状态,添加淡入动画和对应状态类 + if (status !== 'default') { + statusElement.classList.add('status-fade-in'); + statusElement.classList.add(`status-${status}`); + } + + // 如果是成功或失败状态,3秒后渐变消失 + if (status === 'success' || status === 'error') { + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后切换到默认状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 设置默认状态 + statusElement.innerHTML = '-'; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 数字更新动画函数 +function animateCounter(element, target, duration = 1000) { + // 确保element存在 + if (!element) return; + + // 清除元素上可能存在的现有定时器 + if (element.animationTimer) { + clearInterval(element.animationTimer); + } + + // 确保target是数字 + const targetNum = typeof target === 'number' ? target : parseInt(target) || 0; + + // 获取起始值,使用更安全的方法 + const startText = element.textContent.replace(/[^0-9]/g, ''); + const start = parseInt(startText) || 0; + + // 如果起始值和目标值相同,直接返回 + if (start === targetNum) { + element.textContent = targetNum; + return; + } + + let current = start; + const increment = (targetNum - start) / (duration / 16); // 16ms per frame + + // 使用requestAnimationFrame实现更平滑的动画 + let startTime = null; + + function updateCounter(timestamp) { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + + // 使用缓动函数使动画更自然 + const easeOutQuad = progress * (2 - progress); + current = start + (targetNum - start) * easeOutQuad; + + // 根据方向使用floor或ceil确保平滑过渡 + const displayValue = targetNum > start ? Math.floor(current) : Math.ceil(current); + element.textContent = displayValue; + + if (progress < 1) { + // 继续动画 + element.animationTimer = requestAnimationFrame(updateCounter); + } else { + // 动画结束,确保显示准确值 + element.textContent = targetNum; + // 清除定时器引用 + element.animationTimer = null; + } + } + + // 开始动画 + element.animationTimer = requestAnimationFrame(updateCounter); +} + +// 加载屏蔽规则统计信息 +async function loadShieldStats() { + try { + // 获取屏蔽规则统计信息 + const shieldResponse = await fetch('/api/shield'); + + if (!shieldResponse.ok) { + throw new Error(`加载屏蔽统计失败: ${shieldResponse.status}`); + } + + const stats = await shieldResponse.json(); + + // 获取黑名单列表,计算禁用数量 + const blacklistsResponse = await fetch('/api/shield/blacklists'); + + if (!blacklistsResponse.ok) { + throw new Error(`加载黑名单列表失败: ${blacklistsResponse.status}`); + } + + const blacklists = await blacklistsResponse.json(); + const disabledBlacklistCount = blacklists.filter(blacklist => !blacklist.enabled).length; + + // 更新统计信息 + const elements = [ + { id: 'domain-rules-count', value: stats.domainRulesCount }, + { id: 'domain-exceptions-count', value: stats.domainExceptionsCount }, + { id: 'regex-rules-count', value: stats.regexRulesCount }, + { id: 'regex-exceptions-count', value: stats.regexExceptionsCount }, + { id: 'hosts-rules-count', value: stats.hostsRulesCount }, + { id: 'blacklist-count', value: stats.blacklistCount } + ]; + + elements.forEach(item => { + const element = document.getElementById(item.id); + if (element) { + animateCounter(element, item.value || 0); + } + }); + + // 更新禁用黑名单数量 + const disabledBlacklistElement = document.getElementById('blacklist-disabled-count'); + if (disabledBlacklistElement) { + animateCounter(disabledBlacklistElement, disabledBlacklistCount); + } + } catch (error) { + console.error('加载屏蔽规则统计信息失败:', error); + showNotification('加载屏蔽规则统计信息失败', 'error'); + } +} + +// 加载本地规则 +async function loadLocalRules() { + try { + const response = await fetch('/api/shield/localrules'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const data = await response.json(); + + // 更新本地规则数量显示 + if (document.getElementById('local-rules-count')) { + document.getElementById('local-rules-count').textContent = data.localRulesCount || 0; + } + + // 设置当前规则类型 + currentRulesType = 'local'; + + // 合并所有本地规则 + let rules = []; + // 添加域名规则 + if (Array.isArray(data.domainRules)) { + rules = rules.concat(data.domainRules); + } + // 添加域名排除规则 + if (Array.isArray(data.domainExceptions)) { + rules = rules.concat(data.domainExceptions); + } + // 添加正则规则 + if (Array.isArray(data.regexRules)) { + rules = rules.concat(data.regexRules); + } + // 添加正则排除规则 + if (Array.isArray(data.regexExceptions)) { + rules = rules.concat(data.regexExceptions); + } + + updateRulesTable(rules); + } catch (error) { + console.error('加载本地规则失败:', error); + showNotification('加载本地规则失败', 'error'); + } +} + +// 加载远程规则 +async function loadRemoteRules() { + try { + // 设置当前规则类型 + currentRulesType = 'remote'; + const response = await fetch('/api/shield/remoterules'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const data = await response.json(); + + // 更新远程规则数量显示 + if (document.getElementById('remote-rules-count')) { + document.getElementById('remote-rules-count').textContent = data.remoteRulesCount || 0; + } + + // 合并所有远程规则 + let rules = []; + // 添加域名规则 + if (Array.isArray(data.domainRules)) { + rules = rules.concat(data.domainRules); + } + // 添加域名排除规则 + if (Array.isArray(data.domainExceptions)) { + rules = rules.concat(data.domainExceptions); + } + // 添加正则规则 + if (Array.isArray(data.regexRules)) { + rules = rules.concat(data.regexRules); + } + // 添加正则排除规则 + if (Array.isArray(data.regexExceptions)) { + rules = rules.concat(data.regexExceptions); + } + + updateRulesTable(rules); + } catch (error) { + console.error('加载远程规则失败:', error); + showNotification('加载远程规则失败', 'error'); + } +} + +// 更新规则表格 +function updateRulesTable(rules) { + const tbody = document.getElementById('rules-table-body'); + + // 清空表格 + tbody.innerHTML = ''; + + if (rules.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = '暂无规则'; + tbody.appendChild(emptyRow); + return; + } + + // 对于大量规则,限制显示数量 + const maxRulesToShow = 1000; // 限制最大显示数量 + const rulesToShow = rules.length > maxRulesToShow ? rules.slice(0, maxRulesToShow) : rules; + + // 使用DocumentFragment提高性能 + const fragment = document.createDocumentFragment(); + + rulesToShow.forEach(rule => { + const tr = document.createElement('tr'); + tr.className = 'border-b border-gray-200'; + + const tdRule = document.createElement('td'); + tdRule.className = 'py-3 px-4'; + tdRule.textContent = rule; + + const tdStatus = document.createElement('td'); + tdStatus.className = 'py-3 px-4 text-center'; + tdStatus.id = `rule-status-${encodeURIComponent(rule)}`; + tdStatus.innerHTML = '-'; + + const tdAction = document.createElement('td'); + tdAction.className = 'py-3 px-4 text-right'; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-rule-btn px-3 py-1 bg-danger text-white rounded-md hover:bg-danger/90 transition-colors text-sm'; + deleteBtn.dataset.rule = rule; + + // 创建删除图标 + const deleteIcon = document.createElement('i'); + deleteIcon.className = 'fa fa-trash'; + deleteIcon.style.pointerEvents = 'none'; // 防止图标拦截点击事件 + + deleteBtn.appendChild(deleteIcon); + + // 使用普通函数,确保this指向按钮元素 + deleteBtn.onclick = function(e) { + e.stopPropagation(); // 阻止事件冒泡 + handleDeleteRule(e); + }; + + tdAction.appendChild(deleteBtn); + + tr.appendChild(tdRule); + tr.appendChild(tdStatus); + tr.appendChild(tdAction); + fragment.appendChild(tr); + }); + + // 一次性添加所有行到DOM + tbody.appendChild(fragment); + + // 如果有更多规则,添加提示 + if (rules.length > maxRulesToShow) { + const infoRow = document.createElement('tr'); + infoRow.innerHTML = `显示前 ${maxRulesToShow} 条规则,共 ${rules.length} 条`; + tbody.appendChild(infoRow); + } +} + +// 处理删除规则 +async function handleDeleteRule(e) { + console.log('Delete button clicked'); + let deleteBtn; + + // 尝试从事件目标获取按钮元素 + deleteBtn = e.target.closest('.delete-rule-btn'); + console.log('Delete button from event target:', deleteBtn); + + // 尝试从this获取按钮元素(备用方案) + if (!deleteBtn && this && typeof this.classList === 'object' && this.classList) { + if (this.classList.contains('delete-rule-btn')) { + deleteBtn = this; + console.log('Delete button from this:', deleteBtn); + } + } + + if (!deleteBtn) { + console.error('Delete button not found'); + return; + } + + const rule = deleteBtn.dataset.rule; + console.log('Rule to delete:', rule); + + if (!rule) { + console.error('Rule not found in data-rule attribute'); + return; + } + + try { + // 显示加载状态 + updateRuleStatus(rule, 'loading'); + + console.log('Sending DELETE request to /api/shield'); + const response = await fetch('/api/shield', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ rule }) + }); + + console.log('Response status:', response.status); + console.log('Response ok:', response.ok); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + console.log('Response data:', responseData); + + // 根据服务器响应判断是否成功 + if (response.ok && responseData.status === 'success') { + // 显示成功状态 + updateRuleStatus(rule, 'success', '已删除'); + + showNotification('规则删除成功', 'success'); + console.log('Current rules type:', currentRulesType); + + // 延迟重新加载规则列表和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 根据当前显示的规则类型重新加载对应的规则列表 + if (currentRulesType === 'local') { + console.log('Reloading local rules'); + loadLocalRules(); + } else { + console.log('Reloading remote rules'); + loadRemoteRules(); + } + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + const errorMessage = responseData.error || responseData.message || `删除规则失败: ${response.status}`; + // 显示错误状态 + updateRuleStatus(rule, 'error', errorMessage); + throw new Error(errorMessage); + } + } catch (error) { + console.error('Error deleting rule:', error); + // 显示错误状态 + updateRuleStatus(rule, 'error', error.message); + showNotification('删除规则失败: ' + error.message, 'error'); + } +} + +// 添加新规则 +async function handleAddRule() { + const rule = document.getElementById('new-rule').value.trim(); + const statusElement = document.getElementById('save-rule-status'); + + if (!rule) { + showNotification('规则不能为空', 'error'); + return; + } + + try { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示加载状态 + statusElement.innerHTML = ' 正在添加...'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和加载状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-loading'); + + const response = await fetch('/api/shield', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ rule }) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (response.ok && responseData.status === 'success') { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示成功状态 + statusElement.innerHTML = ' 成功'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和成功状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-success'); + + showNotification('规则添加成功', 'success'); + // 清空输入框 + document.getElementById('new-rule').value = ''; + + // 延迟重新加载规则和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载规则 + loadLocalRules(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || '添加规则失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } + } catch (error) { + console.error('Error adding rule:', error); + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示错误状态 + const errorMessage = error.message || '添加规则失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } finally { + // 3秒后渐变消失 + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后清除状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 清空状态显示 + statusElement.innerHTML = ''; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 加载远程黑名单 +async function loadRemoteBlacklists() { + try { + const response = await fetch('/api/shield/blacklists'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 确保blacklists是数组 + const blacklistArray = Array.isArray(blacklists) ? blacklists : []; + updateBlacklistsTable(blacklistArray); + } catch (error) { + console.error('加载远程黑名单失败:', error); + showNotification('加载远程黑名单失败', 'error'); + } +} + +// 判断黑名单是否过期(超过24小时未更新视为过期) +function isBlacklistExpired(lastUpdateTime) { + if (!lastUpdateTime) { + return true; // 从未更新过,视为过期 + } + + const lastUpdate = new Date(lastUpdateTime); + const now = new Date(); + const hoursDiff = (now - lastUpdate) / (1000 * 60 * 60); + + return hoursDiff > 24; // 超过24小时视为过期 +} + +// 更新黑名单表格 +function updateBlacklistsTable(blacklists) { + const tbody = document.getElementById('blacklists-table-body'); + + // 清空表格 + tbody.innerHTML = ''; + + // 检查黑名单数据是否为空 + if (!blacklists || blacklists.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = '暂无黑名单'; + tbody.appendChild(emptyRow); + return; + } + + // 对于大量黑名单,限制显示数量 + const maxBlacklistsToShow = 100; // 限制最大显示数量 + const blacklistsToShow = blacklists.length > maxBlacklistsToShow ? blacklists.slice(0, maxBlacklistsToShow) : blacklists; + + // 使用DocumentFragment提高性能 + const fragment = document.createDocumentFragment(); + + blacklistsToShow.forEach(blacklist => { + const tr = document.createElement('tr'); + tr.className = 'border-b border-gray-200 hover:bg-gray-50'; + + // 名称单元格 + const tdName = document.createElement('td'); + tdName.className = 'py-3 px-4'; + tdName.textContent = blacklist.name || '未命名'; + + // URL单元格 + const tdUrl = document.createElement('td'); + tdUrl.className = 'py-3 px-4 truncate max-w-xs'; + tdUrl.textContent = blacklist.url; + + // 状态单元格 + const tdStatus = document.createElement('td'); + tdStatus.className = 'py-3 px-4 text-center'; + + // 判断状态颜色:绿色(启用)、灰色(禁用) + let statusColor = 'bg-gray-300'; // 默认禁用 + let statusText = '禁用'; + + if (blacklist.enabled) { + statusColor = 'bg-success'; // 绿色表示启用 + statusText = '启用'; + } + + const statusContainer = document.createElement('div'); + statusContainer.className = 'flex items-center justify-center'; + + const statusDot = document.createElement('span'); + statusDot.className = `inline-block w-3 h-3 rounded-full ${statusColor}`; + statusDot.title = statusText; + + const statusTextSpan = document.createElement('span'); + statusTextSpan.className = 'text-sm ml-2'; + statusTextSpan.textContent = statusText; + + statusContainer.appendChild(statusDot); + statusContainer.appendChild(statusTextSpan); + tdStatus.appendChild(statusContainer); + + // 更新状态单元格 + const tdUpdateStatus = document.createElement('td'); + tdUpdateStatus.className = 'py-3 px-4 text-center'; + tdUpdateStatus.id = `update-status-${encodeURIComponent(blacklist.url)}`; + tdUpdateStatus.innerHTML = '-'; + + // 操作单元格 + const tdActions = document.createElement('td'); + tdActions.className = 'py-3 px-4 text-right space-x-2'; + + // 启用/禁用按钮 + const toggleBtn = document.createElement('button'); + toggleBtn.className = `toggle-blacklist-btn px-3 py-1 rounded-md transition-colors text-sm ${blacklist.enabled ? 'bg-warning text-white hover:bg-warning/90' : 'bg-success text-white hover:bg-success/90'}`; + toggleBtn.dataset.url = blacklist.url; + toggleBtn.dataset.enabled = blacklist.enabled; + toggleBtn.innerHTML = ``; + toggleBtn.title = blacklist.enabled ? '禁用黑名单' : '启用黑名单'; + toggleBtn.addEventListener('click', handleToggleBlacklist); + + // 刷新按钮 + const refreshBtn = document.createElement('button'); + refreshBtn.className = 'update-blacklist-btn px-3 py-1 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors text-sm'; + refreshBtn.dataset.url = blacklist.url; + refreshBtn.innerHTML = ''; + refreshBtn.title = '刷新黑名单'; + refreshBtn.addEventListener('click', handleUpdateBlacklist); + + // 删除按钮 + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-blacklist-btn px-3 py-1 bg-danger text-white rounded-md hover:bg-danger/90 transition-colors text-sm'; + deleteBtn.dataset.url = blacklist.url; + deleteBtn.innerHTML = ''; + deleteBtn.title = '删除黑名单'; + deleteBtn.addEventListener('click', handleDeleteBlacklist); + + tdActions.appendChild(toggleBtn); + tdActions.appendChild(refreshBtn); + tdActions.appendChild(deleteBtn); + + tr.appendChild(tdName); + tr.appendChild(tdUrl); + tr.appendChild(tdStatus); + tr.appendChild(tdUpdateStatus); + tr.appendChild(tdActions); + fragment.appendChild(tr); + }); + + // 一次性添加所有行到DOM + tbody.appendChild(fragment); + + // 如果有更多黑名单,添加提示 + if (blacklists.length > maxBlacklistsToShow) { + const infoRow = document.createElement('tr'); + infoRow.innerHTML = `显示前 ${maxBlacklistsToShow} 个黑名单,共 ${blacklists.length} 个`; + tbody.appendChild(infoRow); + } +} + +// 处理更新单个黑名单 +async function handleUpdateBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.update-blacklist-btn'); + if (!btn) { + console.error('未找到更新按钮元素'); + return; + } + + const url = btn.dataset.url; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + try { + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 找到目标黑名单并更新其状态 + const updatedBlacklists = blacklists.map(blacklist => { + if (blacklist.url === url) { + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: new Date().toISOString() + }; + } + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + }); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && (responseData.status === 'success' || !responseData.status)) { + // 显示成功状态 + updateStatus(url, 'success'); + + // 显示通知 + showNotification('黑名单更新成功', 'success'); + + // 延迟重新加载黑名单和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 显示失败状态 + updateStatus(url, 'error', responseData.error || responseData.message || `更新失败: ${updateResponse.status}`); + showNotification(`黑名单更新失败: ${responseData.error || responseData.message || updateResponse.status}`, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('更新黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('更新黑名单失败: ' + error.message, 'error'); + } +} + +// 处理删除黑名单 +async function handleDeleteBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.delete-blacklist-btn'); + if (!btn) { + console.error('未找到删除按钮元素'); + return; + } + + const url = btn.dataset.url; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + // 确认删除 + if (!confirm('确定要删除这个黑名单吗?删除后将无法恢复。')) { + return; + } + + try { + // 获取当前行元素 + const tr = btn.closest('tr'); + if (!tr) { + console.error('未找到行元素'); + return; + } + + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 过滤掉要删除的黑名单 + const updatedBlacklists = blacklists.filter(blacklist => blacklist.url !== url); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && responseData.status === 'success') { + // 显示成功状态 + updateStatus(url, 'success', '已删除'); + + // 显示通知 + showNotification('黑名单删除成功', 'success'); + + // 延迟后渐变移除该行 + setTimeout(() => { + // 添加渐变移除类 + tr.style.transition = 'all 0.3s ease-in-out'; + tr.style.opacity = '0'; + tr.style.transform = 'translateX(-10px)'; + tr.style.height = tr.offsetHeight + 'px'; + tr.style.overflow = 'hidden'; + + // 等待过渡效果完成后,隐藏该行 + setTimeout(() => { + tr.style.display = 'none'; + + // 延迟重新加载黑名单和统计信息,确保视觉效果完成 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 100); + }, 300); + }, 3000); + } else { + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `删除失败: ${updateResponse.status}`; + updateStatus(url, 'error', errorMessage); + showNotification(errorMessage, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('删除黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('删除黑名单失败: ' + error.message, 'error'); + } +} + +// 处理启用/禁用黑名单 +async function handleToggleBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.toggle-blacklist-btn'); + if (!btn) { + console.error('未找到启用/禁用按钮元素'); + return; + } + + const url = btn.dataset.url; + const currentEnabled = btn.dataset.enabled === 'true'; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + try { + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 找到目标黑名单并更新其状态 + const updatedBlacklists = blacklists.map(blacklist => { + if (blacklist.url === url) { + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: !currentEnabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + } + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + }); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && responseData.status === 'success') { + // 显示成功状态 + updateStatus(url, 'success', currentEnabled ? '已禁用' : '已启用'); + + // 显示通知 + showNotification(`黑名单已${currentEnabled ? '禁用' : '启用'}`, 'success'); + + // 延迟重新加载黑名单和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `更新状态失败: ${updateResponse.status}`; + updateStatus(url, 'error', errorMessage); + showNotification(errorMessage, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('启用/禁用黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('启用/禁用黑名单失败: ' + error.message, 'error'); + } +} + +// 处理添加黑名单 +async function handleAddBlacklist(event) { + // 如果存在event参数,则调用preventDefault()防止表单默认提交 + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + + const nameInput = document.getElementById('blacklist-name'); + const urlInput = document.getElementById('blacklist-url'); + const statusElement = document.getElementById('save-blacklist-status'); + + const name = nameInput ? nameInput.value.trim() : ''; + const url = urlInput ? urlInput.value.trim() : ''; + + // 简单验证 + if (!name || !url) { + showNotification('名称和URL不能为空', 'error'); + return; + } + + // 验证URL格式 + try { + new URL(url); + } catch (e) { + showNotification('URL格式不正确', 'error'); + return; + } + + try { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示加载状态 + statusElement.innerHTML = ' 正在添加...'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和加载状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-loading'); + + // 发送添加请求 + const response = await fetch('/api/shield/blacklists', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, url }) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (response.ok && (responseData.status === 'success' || !responseData.status)) { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示成功状态 + statusElement.innerHTML = ' 成功'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和成功状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-success'); + + showNotification('黑名单添加成功', 'success'); + // 清空输入框 + if (nameInput) nameInput.value = ''; + if (urlInput) urlInput.value = ''; + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + } else { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `添加失败: ${response.status}`; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } + } catch (error) { + console.error('Error adding blacklist:', error); + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示错误状态 + const errorMessage = error.message || '添加黑名单失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } finally { + // 3秒后渐变消失 + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后清除状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 清空状态显示 + statusElement.innerHTML = ''; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + + + +// 当前显示的规则类型:'local' 或 'remote' +let currentRulesType = 'local'; + +// 设置事件监听器 +function setupShieldEventListeners() { + // 本地规则管理事件 + const saveRuleBtn = document.getElementById('save-rule-btn'); + if (saveRuleBtn) { + saveRuleBtn.addEventListener('click', handleAddRule); + } + + // 远程黑名单管理事件 + const saveBlacklistBtn = document.getElementById('save-blacklist-btn'); + if (saveBlacklistBtn) { + saveBlacklistBtn.addEventListener('click', handleAddBlacklist); + } + + // 添加切换查看本地规则和远程规则的事件监听 + const viewLocalRulesBtn = document.getElementById('view-local-rules-btn'); + if (viewLocalRulesBtn) { + viewLocalRulesBtn.addEventListener('click', loadLocalRules); + } + + const viewRemoteRulesBtn = document.getElementById('view-remote-rules-btn'); + if (viewRemoteRulesBtn) { + viewRemoteRulesBtn.addEventListener('click', loadRemoteRules); + } +} + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + + + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + notification.classList.add('opacity-100'); + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.remove('opacity-100'); + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initShieldPage); +} else { + initShieldPage(); +} + +// 当切换到屏蔽管理页面时重新加载数据 +document.addEventListener('DOMContentLoaded', () => { + // 监听hash变化,当切换到屏蔽管理页面时重新加载数据 + window.addEventListener('hashchange', () => { + if (window.location.hash === '#shield') { + initShieldPage(); + } + }); +}); \ No newline at end of file diff --git a/js/vendor/chart.umd.min.js b/js/vendor/chart.umd.min.js new file mode 100644 index 0000000..eafab1b --- /dev/null +++ b/js/vendor/chart.umd.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,function(){"use strict";var s=Object.freeze({__proto__:null,get Colors(){return dn},get Decimation(){return fn},get Filler(){return Dn},get Legend(){return Tn},get SubTitle(){return In},get Title(){return En},get Tooltip(){return Un}});function t(){}const F=(()=>{let t=0;return()=>t++})();function P(t){return null==t}function O(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function A(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function p(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function g(t,e){return p(t)?t:e}function T(t,e){return void 0===t?e:t}const V=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,B=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function k(t,e,i,s){let a,n,o;if(O(t))if(n=t.length,s)for(a=n-1;0<=a;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function q(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s=s.endsWith("\\")?s.slice(0,-1)+".":(i.push(s),"");return i}function m(t,e){const i=X[e]||(X[e]=function(){const i=q(e);return t=>{for(const e of i){if(""===e)break;t=t&&t[e]}return t}}());return i(t)}function K(t){return t.charAt(0).toUpperCase()+t.slice(1)}const G=t=>void 0!==t,u=t=>"function"==typeof t,Z=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function J(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const S=Math.PI,_=2*S,Q=_+S,tt=Number.POSITIVE_INFINITY,et=S/180,D=S/2,it=S/4,st=2*S/3,r=Math.log10,y=Math.sign;function at(t,e,i){return Math.abs(t-e)t-e).pop(),e}function rt(t){return!isNaN(parseFloat(t))&&isFinite(t)}function lt(t,e){var i=Math.round(t);return i-e<=t&&t<=i+e}function ht(t,e,i){let s,a,n;for(s=0,a=t.length;s=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function bt(e,i,t){t=t||(t=>e[t]>1)?n=s:a=s;return{lo:n,hi:a}}const f=(i,s,a,t)=>bt(i,a,t?t=>{var e=i[t][s];return ei[t][s]bt(e,s,t=>e[t][i]>=s);function vt(t,e,i){let s=0,a=t.length;for(;ss&&t[a-1]>i;)a--;return 0{const i="_onData"+K(t),s=a[t];Object.defineProperty(a,t,{configurable:!0,enumerable:!1,value(...e){var t=s.apply(this,e);return a._chartjs.listeners.forEach(t=>{"function"==typeof t[i]&&t[i](...e)}),t}})}))}function Mt(e,t){var i=e._chartjs;if(i){const s=i.listeners,a=s.indexOf(t);-1!==a&&s.splice(a,1),0{delete e[t]}),delete e._chartjs)}}function wt(t){var e=new Set(t);return e.size===t.length?t:Array.from(e)}const kt="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function St(e,i){let s,a=!1;return function(...t){s=t,a||(a=!0,kt.call(window,()=>{a=!1,e.apply(i,s)}))}}function Pt(e,i){let s;return function(...t){return i?(clearTimeout(s),s=setTimeout(e,i,t)):e.apply(this,t),i}}const Dt=t=>"start"===t?"left":"end"===t?"right":"center",E=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,Ct=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function Ot(t,e,i){var s=e.length;let a=0,n=s;if(t._sorted){const{iScale:o,_parsed:r}=t,l=o.axis,{min:h,max:c,minDefined:d,maxDefined:u}=o.getUserBounds();d&&(a=C(Math.min(f(r,l,h).lo,i?s:f(e,l,o.getPixelForValue(h)).lo),0,s-1)),n=u?C(Math.max(f(r,o.axis,c,!0).hi+1,i?0:f(e,l,o.getPixelForValue(c),!0).hi+1),a,s)-a:s-a}return{start:a,count:n}}function At(t){var{xScale:e,yScale:i,_scaleRanges:s}=t,a={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=a,!0;t=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,a),t}var l=new class{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(e,i,s,t){const a=i.listeners[t],n=i.duration;a.forEach(t=>t({chart:e,initial:i.initial,numSteps:n,currentStep:Math.min(s-i.start,n)}))}_refresh(){this._request||(this._running=!0,this._request=kt.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(o=Date.now()){let r=0;this._charts.forEach((s,a)=>{if(s.running&&s.items.length){const n=s.items;let t,e=n.length-1,i=!1;for(;0<=e;--e)(t=n[e])._active?(t._total>s.duration&&(s.duration=t._total),t.tick(o),i=!0):(n[e]=n[n.length-1],n.pop());i&&(a.draw(),this._notify(a,s,o,"progress")),n.length||(s.running=!1,this._notify(a,s,o,"complete"),s.initial=!1),r+=n.length}}),this._lastDate=o,0===r&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return 0Math.max(t,e._duration),0),this._refresh())}running(t){if(!this._running)return!1;t=this._charts.get(t);return!!(t&&t.running&&t.items.length)}stop(e){const i=this._charts.get(e);if(i&&i.items.length){const s=i.items;let t=s.length-1;for(;0<=t;--t)s[t].cancel();i.items=[],this._notify(e,i,Date.now(),"complete")}}remove(t){return this._charts.delete(t)}};function Tt(t){return t+.5|0}const Lt=(t,e,i)=>Math.max(Math.min(t,i),e);function Et(t){return Lt(Tt(2.55*t),0,255)}function Rt(t){return Lt(Tt(255*t),0,255)}function o(t){return Lt(Tt(t/2.55)/100,0,1)}function It(t){return Lt(Tt(100*t),0,100)}const n={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},zt=[..."0123456789ABCDEF"],Ft=t=>zt[15&t],Vt=t=>zt[(240&t)>>4]+zt[15&t],Bt=t=>(240&t)>>4==(15&t);const Wt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Nt(i,t,s){const a=t*Math.min(s,1-s),e=(t,e=(t+i/30)%12)=>s-a*Math.max(Math.min(e-3,9-e,1),-1);return[e(0),e(8),e(4)]}function Ht(i,s,a){i=(t,e=(t+i/60)%6)=>a-a*s*Math.max(Math.min(e,4-e,1),0);return[i(5),i(3),i(1)]}function jt(t,e,i){const s=Nt(t,1,.5);let a;for(1t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Qt=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function te(e,i,s){if(e){let t=Yt(e);t[i]=Math.max(0,Math.min(t[i]+t[i]*s,0===i?360:1)),t=Ut(t),e.r=t[0],e.g=t[1],e.b=t[2]}}function ee(t,e){return t&&Object.assign(e||{},t)}function ie(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?3<=t.length&&(e={r:t[0],g:t[1],b:t[2],a:255},3>16&255,n>>8&255,255&n]}return t}()).transparent=[0,0,0,0]),(i=Gt[i.toLowerCase()])&&{r:i[0],g:i[1],b:i[2],a:4===i.length?i[3]:255})||se(t)),this._rgb=a,this._valid=!!a}get valid(){return this._valid}get rgb(){var t=ee(this._rgb);return t&&(t.a=o(t.a)),t}set rgb(t){this._rgb=ie(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${o(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?(t=this._rgb,e=t,e=Bt(e.r)&&Bt(e.g)&&Bt(e.b)&&Bt(e.a)?Ft:Vt,t?"#"+e(t.r)+e(t.g)+e(t.b)+((t=t.a)<255?e(t):""):void 0):void 0;var t,e}hslString(){if(this._valid){var t,e,i,s=this._rgb;if(s)return i=Yt(s),t=i[0],e=It(i[1]),i=It(i[2]),s.a<255?`hsla(${t}, ${e}%, ${i}%, ${o(s.a)})`:`hsl(${t}, ${e}%, ${i}%)`}}mix(t,e){if(t){const s=this.rgb,a=t.rgb;var t=void 0===e?.5:e,e=2*t-1,i=s.a-a.a,e=(1+(e*i==-1?e:(e+i)/(1+e*i)))/2,i=1-e;s.r=255&e*s.r+i*a.r+.5,s.g=255&e*s.g+i*a.g+.5,s.b=255&e*s.b+i*a.b+.5,s.a=t*s.a+(1-t)*a.a,this.rgb=s}return this}interpolate(t,e){return t&&(this._rgb=(i=this._rgb,t=t._rgb,e=e,s=Qt(o(i.r)),a=Qt(o(i.g)),n=Qt(o(i.b)),{r:Rt(Jt(s+e*(Qt(o(t.r))-s))),g:Rt(Jt(a+e*(Qt(o(t.g))-a))),b:Rt(Jt(n+e*(Qt(o(t.b))-n))),a:i.a+e*(t.a-i.a)})),this;var i,s,a,n}clone(){return new ae(this.rgb)}alpha(t){return this._rgb.a=Rt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=Tt(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return te(this._rgb,2,t),this}darken(t){return te(this._rgb,2,-t),this}saturate(t){return te(this._rgb,1,t),this}desaturate(t){return te(this._rgb,1,-t),this}rotate(t){return e=this._rgb,t=t,(i=Yt(e))[0]=Xt(i[0]+t),i=Ut(i),e.r=i[0],e.g=i[1],e.b=i[2],this;var e,i}}function ne(t){return!(!t||"object"!=typeof t)&&("[object CanvasPattern]"===(t=t.toString())||"[object CanvasGradient]"===t)}function oe(t){return ne(t)?t:new ae(t)}function re(t){return ne(t)?t:new ae(t).saturate(.5).darken(.1).hexString()}const le=["x","y","borderWidth","radius","tension"],he=["color","borderColor","backgroundColor"],ce=new Map;function de(t,e,a){return function(t,e){e=a||{};var i=t+JSON.stringify(e);let s=ce.get(i);return s||(s=new Intl.NumberFormat(t,e),ce.set(i,s)),s}(e).format(t)}const ue={values:t=>O(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";var s=this.chart.options.locale;let a,n=t;if(1.8*i.length?ue.numeric.call(this,t,e,i):""}};var ge={formatters:ue};const fe=Object.create(null),pe=Object.create(null);function me(i,t){if(!t)return i;var s=t.split(".");for(let t=0,e=s.length;tt.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>re(e.backgroundColor),this.hoverBorderColor=(t,e)=>re(e.borderColor),this.hoverColor=(t,e)=>re(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return be(this,t,e)}get(t){return me(this,t)}describe(t,e){return be(pe,t,e)}override(t,e){return be(fe,t,e)}route(t,e,i,s){const a=me(this,t),n=me(this,i),o="_"+e;Object.defineProperties(a,{[o]:{value:a[e],writable:!0},[e]:{enumerable:!0,get(){var t=this[o],e=n[s];return A(t)?Object.assign({},e,t):T(t,e)},set(t){this[o]=t}}})}apply(t){t.forEach(t=>t(this))}}({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:he},numbers:{type:"number",properties:le}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ge.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function xe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ve(t){let e=t.parentNode;return e=e&&"[object ShadowRoot]"===e.toString()?e.host:e}function _e(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const ye=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function Me(t,e){return ye(t).getPropertyValue(e)}const we=["top","right","bottom","left"];function ke(e,i,s){const a={};s=s?"-"+s:"";for(let t=0;t<4;t++){var n=we[t];a[n]=parseFloat(e[i+"-"+n+s])||0}return a.width=a.left+a.right,a.height=a.top+a.bottom,a}function Se(t,e){if("native"in t)return t;var{canvas:i,currentDevicePixelRatio:s}=e,a=ye(i),n="border-box"===a.boxSizing,o=ke(a,"padding"),a=ke(a,"border","width"),{x:t,y:r,box:l}=function(t,e){var i,s=t.touches,s=s&&s.length?s[0]:t,{offsetX:a,offsetY:n}=s;let o,r,l=!1;if(i=n,t=t.target,!(0Math.round(10*t)/10;function De(t,e,i,s){var a=ye(t),n=ke(a,"margin"),o=_e(a.maxWidth,t,"clientWidth")||tt,r=_e(a.maxHeight,t,"clientHeight")||tt,t=function(t,e,i){let s,a;if(void 0===e||void 0===i){const n=ve(t);if(n){const t=n.getBoundingClientRect(),o=ye(n),r=ke(o,"border","width"),l=ke(o,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=_e(o.maxWidth,n,"clientWidth"),a=_e(o.maxHeight,n,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||tt,maxHeight:a||tt}}(t,e,i);let{width:l,height:h}=t;if("content-box"===a.boxSizing){const t=ke(a,"border","width"),e=ke(a,"padding");l-=e.width+t.width,h-=e.height+t.height}return l=Math.max(0,l-n.width),h=Math.max(0,s?l/s:h-n.height),l=Pe(Math.min(l,o,t.maxWidth)),h=Pe(Math.min(h,r,t.maxHeight)),l&&!h&&(h=Pe(l/2)),(void 0!==e||void 0!==i)&&s&&t.height&&h>t.height&&(h=t.height,l=Pe(Math.floor(h*s))),{width:l,height:h}}function Ce(t,e,i){var e=e||1,s=Math.floor(t.height*e),a=Math.floor(t.width*e);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const n=t.canvas;return n.style&&(i||!n.style.height&&!n.style.width)&&(n.style.height=t.height+"px",n.style.width=t.width+"px"),(t.currentDevicePixelRatio!==e||n.height!==s||n.width!==a)&&(t.currentDevicePixelRatio=e,n.height=s,n.width=a,t.ctx.setTransform(e,0,0,e,0,0),!0)}var Oe=function(){let t=!1;try{var e={get passive(){return!(t=!0)}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Ae(t,e){const i=Me(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function Te(t){return!t||P(t.size)||P(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Le(t,e,i,s,a){let n=e[a];return n||(n=e[a]=t.measureText(a).width,i.push(a)),s=n>s?n:s}function Ee(t,e,i,s){let a=(s=s||{}).data=s.data||{},n=s.garbageCollect=s.garbageCollect||[],o=(s.font!==e&&(a=s.data={},n=s.garbageCollect=[],s.font=e),t.save(),t.font=e,0);var r=i.length;let l,h,c,d,u;for(l=0;li.length){for(l=0;le.left-i&&t.xe.top-i&&t.yr[0]){const i=t||r;void 0===e&&(e=ti("_fallback",r));t={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:r,_rootScopes:i,_fallback:e,_getTarget:a,override:t=>Ye([t,...r],l,i,e)};return new Proxy(t,{deleteProperty:(t,e)=>(delete t[e],delete t._keys,delete r[0][e],!0),get:(n,o)=>Ke(n,o,()=>{var t,e=o,i=r,s=n;for(const a of l)if(t=ti(Xe(a,e),i),void 0!==t)return qe(e,t)?Je(i,s,e,t):t}),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(r[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=a());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(s,e,i,a){var t={_cacheable:!1,_proxy:s,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ue(s,a),setContext:t=>$e(s,t,i,a),override:t=>$e(s.override(t),e,i,a)};return new Proxy(t,{deleteProperty:(t,e)=>(delete t[e],delete s[e],!0),get:(r,h,c)=>Ke(r,h,()=>{{var l=r,e=h,i=c;const{_proxy:s,_context:a,_subProxy:n,_descriptors:o}=l;let t=s[e];return O(t=u(t)&&o.isScriptable(e)?function(t,e,i){const{_proxy:s,_context:a,_subProxy:n,_stack:o}=l;if(o.has(t))throw new Error("Recursion detected: "+Array.from(o).join("->")+"->"+t);o.add(t);let r=e(a,n||i);return o.delete(t),r=qe(t,r)?Je(s._scopes,s,t,r):r}(e,t,i):t)&&t.length&&(t=function(t,e,i,s){const{_proxy:a,_context:n,_subProxy:o,_descriptors:r}=i;if(void 0!==n.index&&s(t))return e[n.index%e.length];if(A(e[0])){const i=e,s=a._scopes.filter(t=>t!==i);e=[];for(const A of i){const i=Je(s,a,t,A);e.push($e(i,n,o&&o[t],r))}}return e}(e,t,l,o.isIndexable)),t=qe(e,t)?$e(t,a,n&&n[e],o):t}}),getOwnPropertyDescriptor:(t,e)=>t._descriptors.allKeys?Reflect.has(s,e)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(s,e),getPrototypeOf:()=>Reflect.getPrototypeOf(s),has:(t,e)=>Reflect.has(s,e),ownKeys:()=>Reflect.ownKeys(s),set:(t,e,i)=>(s[e]=i,delete t[e],!0)})}function Ue(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:a=e.allKeys}=t;return{allKeys:a,scriptable:i,indexable:s,isScriptable:u(i)?i:()=>i,isIndexable:u(s)?s:()=>s}}const Xe=(t,e)=>t?t+K(e):e,qe=(t,e)=>A(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function Ke(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];i=i();return t[e]=i}function Ge(t,e,i){return u(t)?t(e,i):t}function Ze(t,e,i,s,a){for(const r of e){n=i,o=r;const e=!0===n?o:"string"==typeof n?m(o,n):void 0;if(e){t.add(e);o=Ge(e._fallback,i,a);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}var n,o;return!1}function Je(t,s,a,n){const e=s._rootScopes,i=Ge(s._fallback,a,n),o=[...t,...e],r=new Set;r.add(n);t=Qe(r,o,a,i||a,n);return null!==t&&(void 0===i||i===a||null!==Qe(r,o,i,t,n))&&Ye(Array.from(r),[""],e,i,()=>{{var t=a,e=n;const i=s._getTarget();return t in i||(i[t]={}),O(t=i[t])&&A(e)?e:t||{}}})}function Qe(t,e,i,s,a){for(;i;)i=Ze(t,e,i,s,a);return i}function ti(t,e){for(const i of e)if(i){const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e=e||(t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter(t=>!t.startsWith("_")))e.add(t);return Array.from(e)}(t._scopes))}function ii(t,e,i,s){const a=t["iScale"],{key:n="r"}=this._parsing,o=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function oi(t,e,i,s){var t=t.skip?e:t,a=e,e=i.skip?e:i,i=gt(a,t),n=gt(e,a);let o=i/(i+n),r=n/(i+n);o=isNaN(o)?0:o,r=isNaN(r)?0:r;i=s*o,n=s*r;return{previous:{x:a.x-i*(e.x-t.x),y:a.y-i*(e.y-t.y)},next:{x:a.x+n*(e.x-t.x),y:a.y+n*(e.y-t.y)}}}function ri(t,n="x"){const e=ni(n),i=t.length,r=Array(i).fill(0),l=Array(i);let s,a,o,h=ai(t,0);for(s=0;s!t.skip)),"monotone"===e.cubicInterpolationMode)ri(o,t);else{let t=i?o[o.length-1]:o[0];for(s=0,a=o.length;s0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*--t)*Math.sin((t-e)*_/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*_/i)+1,gi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>--t*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-(--t*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>--t*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*D),easeOutSine:t=>Math.sin(t*D),easeInOutSine:t=>-.5*(Math.cos(S*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>1<=t?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1- --t*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){return ci(t)?t:t<.5?.5*di(2*t,.1125,.45):.5+.5*ui(2*t-1,.1125,.45)},easeInBack(t){return t*t*(2.70158*t-1.70158)},easeOutBack(t){return--t*t*(2.70158*t+1.70158)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-gi.easeOutBounce(1-t),easeOutBounce(t){var e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*gi.easeInBounce(2*t):.5*gi.easeOutBounce(2*t-1)+.5};function fi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:("middle"===s?i<.5?t:e:"after"===s?i<1?t:e:0+t||0;function yi(e,i){const t={},s=A(i),a=s?Object.keys(i):i,n=A(e)?s?t=>T(e[t],e[i[t]]):t=>e[t]:()=>e;for(const e of a)t[e]=_i(n(e));return t}function Mi(t){return yi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return yi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function I(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function z(t,e){e=e||R.font;let i=T((t=t||{}).size,e.size),s=("string"==typeof i&&(i=parseInt(i,10)),T(t.style,e.style));s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const a={family:T(t.family,e.family),lineHeight:vi(T(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:T(t.weight,e.weight),string:""};return a.string=Te(a),a}function ki(t,e,i,s){let a,n,o,r=!0;for(a=0,n=t.length;ai&&0===t?0:t+e;return{min:a(t,-Math.abs(e)),max:a(s,e)}}function Pi(t,e){return Object.assign(Object.create(t),e)}function Di(t,e,i){return t?(s=e,a=i,{x:t=>s+s+a-t,setWidth(t){a=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t};var s,a}function Ci(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(s=[(i=t.canvas.style).getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Oi(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Ai(t){return"angle"===t?{between:pt,compare:ft,normalize:v}:{between:c,compare:(t,e)=>t-e,normalize:t=>t}}function Ti({start:t,end:e,count:i,loop:s,style:a}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:a}}function Li(t,i,g){if(!g)return[t];const{property:s,start:a,end:n}=g,o=i.length,{compare:r,between:l,normalize:h}=Ai(s),{start:c,end:d,loop:u,style:f}=function(t,e){const{property:i,start:s,end:a}=g,{between:n,normalize:o}=Ai(i),r=e.length;let l,h,{start:c,end:d,loop:u}=t;if(u){for(c+=r,d+=r,l=0,h=r;ls&&t[a%e].skip;)a--;return a%=e,{start:s,end:a}}(i,s,a);return Ii(t,!0===n?[{start:o,end:r,loop:a}]:function(t,e,i,s){const a=t.length,n=[];let o,r=e,l=t[e];for(o=e+1;o<=i;++o){const i=t[o%a];i.skip||i.stop?l.skip||(s=!1,n.push({start:e%a,end:(o-1)%a,loop:s}),e=r=i.stop?o:null):(r=o,l.skip&&(e=o)),l=i}return null!==r&&n.push({start:e%a,end:r%a,loop:s}),n}(i,o,r{t[r](s[a],n)&&(o.push({element:t,datasetIndex:e,index:i}),l=l||t.inRange(s.x,s.y,n))}),e&&!l?[]:o}var Hi={evaluateInteractionItems:Vi,modes:{index(t,e,i,s){const a=Se(e,t),n=i.axis||"x",o=i.includeInvisible||!1,r=i.intersect?Bi(t,a,n,s,o):Wi(t,a,n,!1,s,o),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach(t=>{var e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})}),l):[]},dataset(t,e,i,s){var e=Se(e,t),a=i.axis||"xy",n=i.includeInvisible||!1;let o=i.intersect?Bi(t,e,a,s,n):Wi(t,e,a,!1,s,n);if(0Bi(t,Se(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){var e=Se(e,t),a=i.axis||"xy",n=i.includeInvisible||!1;return Wi(t,e,a,i.intersect,s,n)},x:(t,e,i,s)=>Ni(t,Se(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ni(t,Se(e,t),"y",i.intersect,s)}};const ji=["left","top","right","bottom"];function Yi(t,e){return t.filter(t=>t.pos===e)}function $i(t,e){return t.filter(t=>-1===ji.indexOf(t.pos)&&t.box.axis===e)}function Ui(t,s){return t.sort((t,e)=>{var i=s?e:t,t=s?t:e;return i.weight===t.weight?i.index-t.index:i.weight-t.weight})}function Xi(t,e,i,s){return Math.max(t[i],e[i])+Math.max(t[s],e[s])}function qi(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function Ki(t,e,i,s){const a=[];let n,o,r,l,h,c;for(n=0,o=t.length,h=0;n{s[t]=Math.max(e[t],i[t])}),s}}(r.horizontal,e));const{same:o,other:d}=function(t,e,i,s){const{pos:a,box:n}=i,o=t.maxPadding;if(!A(a)){i.size&&(t[a]-=i.size);const e=s[i.stack]||{size:0,count:1};e.size=Math.max(e.size,i.horizontal?n.height:n.width),i.size=e.size/e.count,t[a]+=i.size}n.getPadding&&qi(o,n.getPadding());var s=Math.max(0,e.outerWidth-Xi(o,t,"left","right")),e=Math.max(0,e.outerHeight-Xi(o,t,"top","bottom")),r=s!==t.w,l=e!==t.h;return t.w=s,t.h=e,i.horizontal?{same:r,other:l}:{same:l,other:r}}(e,i,r,s);h|=o&&a.length,c=c||d,l.fullSize||a.push(r)}return h&&Ki(a,e,i,s)||c}function Gi(t,e,i,s,a){t.top=i,t.left=e,t.right=e+s,t.bottom=i+a,t.width=s,t.height=a}function Zi(t,e,i,s){var a=i.padding;let{x:n,y:o}=e;for(const r of t){const t=r.box,l=s[r.stack]||{count:1,placed:0,weight:1},h=r.stackWeight/l.weight||1;if(r.horizontal){const s=e.w*h,n=l.size||t.height;G(l.start)&&(o=l.start),t.fullSize?Gi(t,a.left,o,i.outerWidth-a.right-a.left,n):Gi(t,e.left+l.placed,o,s,n),l.start=o,l.placed+=s,o=t.bottom}else{const s=e.h*h,o=l.size||t.width;G(l.start)&&(n=l.start),t.fullSize?Gi(t,n,a.top,o,i.outerHeight-a.bottom-a.top):Gi(t,n,e.top+l.placed,o,s),l.start=n,l.placed+=s,n=t.right}}e.x=n,e.y=o}var a={addBox(t,e){t.boxes||(t.boxes=[]),e.fullSize=e.fullSize||!1,e.position=e.position||"top",e.weight=e.weight||0,e._layers=e._layers||function(){return[{z:0,draw(t){e.draw(t)}}]},t.boxes.push(e)},removeBox(t,e){e=t.boxes?t.boxes.indexOf(e):-1;-1!==e&&t.boxes.splice(e,1)},configure(t,e,i){e.fullSize=i.fullSize,e.position=i.position,e.weight=i.weight},update(l,t,e,i){if(l){const o=I(l.options.layout.padding),r=Math.max(t-o.width,0),h=Math.max(e-o.height,0),c=function(){const t=function(t){const e=[];let i,s,a,n,o,r;for(i=0,s=(t||[]).length;it.box.fullSize),!0),i=Ui(Yi(t,"left"),!0),s=Ui(Yi(t,"right")),a=Ui(Yi(t,"top"),!0),n=Ui(Yi(t,"bottom")),o=$i(t,"x"),r=$i(t,"y");return{fullSize:e,leftAndTop:i.concat(a),rightAndBottom:s.concat(r).concat(n).concat(o),chartArea:Yi(t,"chartArea"),vertical:i.concat(s).concat(r),horizontal:a.concat(n).concat(o)}}(),d=c.vertical,u=c.horizontal;k(l.boxes,t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()});var s=d.reduce((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1,0)||1,t=Object.freeze({outerWidth:t,outerHeight:e,padding:o,availableWidth:r,availableHeight:h,vBoxMaxWidth:r/2/s,hBoxMaxHeight:h/2}),e=Object.assign({},o);qi(e,I(i));const g=Object.assign({maxPadding:e,w:r,h:h,x:o.left,y:o.top},o),f=function(t,e){var i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:a}=i;if(t&&ji.includes(s)){const n=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});n.count++,n.weight+=a}}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:a}=e;let n,o,r;for(n=0,o=t.length;n{const e=t.box;Object.assign(e,l.chartArea),e.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})})}}};class Ji{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class Qi extends Ji{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ts="$chartjs",es={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},is=t=>null===t||""===t,ss=!!Oe&&{passive:!0};function as(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ns(t,e,i){const s=t.canvas,a=new MutationObserver(t=>{let e=!1;for(const i of t)e=e||as(i.addedNodes,s),e=e&&!as(i.removedNodes,s);e&&i()});return a.observe(document,{childList:!0,subtree:!0}),a}function os(t,e,i){const s=t.canvas,a=new MutationObserver(t=>{let e=!1;for(const i of t)e=e||as(i.removedNodes,s),e=e&&!as(i.addedNodes,s);e&&i()});return a.observe(document,{childList:!0,subtree:!0}),a}const rs=new Map;let ls=0;function hs(){const i=window.devicePixelRatio;i!==ls&&(ls=i,rs.forEach((t,e)=>{e.currentDevicePixelRatio!==i&&t()}))}function cs(t,e,s){const i=t.canvas,a=i&&ve(i);if(a){const o=St((t,e)=>{var i=a.clientWidth;s(t,e),i{var t=t[0],e=t.contentRect.width,t=t.contentRect.height;0===e&&0===t||o(e,t)});return r.observe(a),t=t,n=o,rs.size||window.addEventListener("resize",hs),rs.set(t,n),r;var n}}function ds(t,e,i){i&&i.disconnect(),"resize"===e&&(i=t,rs.delete(i),rs.size||window.removeEventListener("resize",hs))}function us(e,t,i){var s=e.canvas,a=St(t=>{null!==e.ctx&&i(function(t,e){var i=es[t.type]||t.type,{x:s,y:a}=Se(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==a?a:null}}(t,e))},e);return s.addEventListener(t,a,ss),a}class gs extends Ji{acquireContext(t,e){var i=t&&t.getContext&&t.getContext("2d");{if(i&&i.canvas===t){{var s=e;const a=t.style,n=t.getAttribute("height"),o=t.getAttribute("width");if(t[ts]={initial:{height:n,width:o,style:{display:a.display,height:a.height,width:a.width}}},a.display=a.display||"block",a.boxSizing=a.boxSizing||"border-box",is(o)){const s=Ae(t,"width");void 0!==s&&(t.width=s)}if(is(n))if(""===t.style.height)t.height=t.width/(s||2);else{const s=Ae(t,"height");void 0!==s&&(t.height=s)}}return i}return null}}releaseContext(t){const i=t.canvas;if(!i[ts])return!1;const s=i[ts].initial,e=(["height","width"].forEach(t=>{var e=s[t];P(e)?i.removeAttribute(t):i.setAttribute(t,e)}),s.style||{});return Object.keys(e).forEach(t=>{i.style[t]=e[t]}),i.width=i.width,delete i[ts],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),a={attach:ns,detach:os,resize:cs}[e]||us;s[e]=a(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];s&&(({attach:ds,detach:ds,resize:ds}[e]||function(t,e,i){t.canvas.removeEventListener(e,i,ss)})(t,e,s),i[e]=void 0)}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return De(t,e,i,s)}isAttached(t){t=ve(t);return!(!t||!t.isConnected)}}function fs(t){return!xe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?Qi:gs}Oe=Object.freeze({__proto__:null,BasePlatform:Ji,BasicPlatform:Qi,DomPlatform:gs,_detectPlatform:fs});const ps="transparent",ms={boolean:(t,e,i)=>.5t+(e-t)*i};class bs{constructor(t,e,i,s){var a=e[i],a=(s=ki([t.to,s,a,t.from]),ki([t.from,a,s]));this._active=!0,this._fn=t.fn||ms[t.type||typeof a],this._easing=gi[t.easing]||gi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=a,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){var s,a,n;this._active&&(this._notify(!1),s=this._target[this._prop],a=i-this._start,n=this._duration-a,this._start=i,this._duration=Math.floor(Math.max(n,t.duration)),this._total+=a,this._loop=!!t.loop,this._to=ki([t.to,e,s,t.from]),this._from=ki([t.from,s,e]))}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){var t=t-this._start,e=this._duration,i=this._prop,s=this._from,a=this._loop,n=this._to;let o;if(this._active=s!==n&&(a||t{i.push({res:t,rej:e})})}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const t=s[e];if(A(t)){const i={};for(const s of a)i[s]=t[s];(O(t.properties)&&t.properties||[e]).forEach(t=>{t!==e&&n.has(t)||n.set(t,i)})}})}}_animateOptions(t,e){const i=e.options,s=function(e,i){if(i){let t=e.options;if(t)return t.$shared&&(e.options=t=Object.assign({},t,{$shared:!1,$animations:{}})),t;e.options=i}}(t,i);if(!s)return[];e=this._createAnimations(s,i);return i.$shared&&function(e,t){const i=[],s=Object.keys(t);for(let t=0;t{t.options=i},()=>{}),e}_createAnimations(e,i){const s=this._properties,a=[],n=e.$animations||(e.$animations={}),t=Object.keys(i),o=Date.now();let r;for(r=t.length-1;0<=r;--r){const c=t[r];if("$"!==c.charAt(0))if("options"===c)a.push(...this._animateOptions(e,i));else{var l=i[c];let t=n[c];var h=s.get(c);if(t){if(h&&t.active()){t.update(h,l,o);continue}t.cancel()}h&&h.duration?(n[c]=t=new bs(h,e,c,l),a.push(t)):e[c]=l}}return a}update(t,e){{if(0!==this._properties.size)return(t=this._createAnimations(t,e)).length?(l.add(this._chart,t),!0):void 0;Object.assign(t,e)}}}function vs(t,e){var t=t&&t.options||{},i=t.reverse,s=void 0===t.min?e:0,t=void 0===t.max?e:0;return{start:i?t:s,end:i?s:t}}function _s(t,e){const i=[],s=t._getSortedDatasetMetas(e);let a,n;for(a=0,n=s.length;ai[t].axis===e).shift()}function Ps(t,e){var i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ds=t=>"reset"===t||"none"===t,Cs=(t,e)=>e?t:Object.assign({},t);class Os{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Ms(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ps(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,a=e.xAxisID=T(i.xAxisID,Ss(t,"x")),n=e.yAxisID=T(i.yAxisID,Ss(t,"y")),o=e.rAxisID=T(i.rAxisID,Ss(t,"r")),r=e.indexAxis,l=e.iAxisID=s(r,a,n,o),h=e.vAxisID=s(r,n,a,o);e.xScale=this.getScaleForId(a),e.yScale=this.getScaleForId(n),e.rScale=this.getScaleForId(o),e.iScale=this.getScaleForId(l),e.vScale=this.getScaleForId(h)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){var e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){var t=this._cachedMeta;this._data&&Mt(this._data,this),t._stacked&&Ps(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(A(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,a,n;for(s=0,a=e.length;snull===l[o]||d&&l[o]t||cthis.getContext(i,s,e),c);return g.$shared&&(g.$shared=r,a[n]=Object.freeze(Cs(g,r))),g}_resolveAnimations(t,e,i){const s=this.chart,a=this._cachedDataOpts,n="animation-"+e,o=a[n];if(o)return o;let r;if(!1!==s.options.animation){const s=this.chart.config,a=s.datasetAnimationScopeKeys(this._type,e),n=s.getOptionScopes(this.getDataset(),a);r=s.createResolver(n,this.getContext(t,i,e))}t=new xs(s,r&&r.animations);return r&&r._cacheable&&(a[n]=Object.freeze(t)),t}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ds(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){var t=this.resolveDataElementOptions(t,e),i=this._sharedOptions,s=this.getSharedOptions(t),i=this.includeOptions(e,s)||s!==i;return this.updateSharedOptions(s,e,t),{sharedOptions:s,includeOptions:i}}updateElement(t,e,i,s){Ds(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ds(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;var a=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(a)||a})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){var t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){var t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];var s=i.length,a=e.length,n=Math.min(a,s);n&&this.parse(0,n),s{for(t.length+=e,o=t.length-1;o>=n;o--)t[o]=t[o-e]};for(r(a),o=t;o{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]}),s}}function As(t,e,i,s,a){var n=T(s,0),o=Math.min(T(a,t.length),t.length);let r,l,h,c=0;for(i=Math.ceil(i),a&&(i=(r=a-s)/Math.floor(r/i)),h=n;h<0;)c++,h=Math.round(n+c*i);for(l=Math.max(n,0);l"top"===e||"left"===e?t[e]+i:t[e]-i,Ls=(t,e)=>Math.min(e||t,t);function Es(t,e){const i=[],s=t.length/e,a=t.length;let n=0;for(;nn?n:a,n=o&&a>n?a:n,{min:g(a,g(n,a)),max:g(n,g(a,n))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){var t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){var{beginAtZero:s,grace:a,ticks:n}=this.options,o=n.sampleSize,t=(this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Si(this,a,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks(),os)return i}return Math.max(s,1)}(n,s,a);if(0{const e=t.gc,i=e.length/2;let s;if(y({width:n[t]||0,height:o[t]||0});return{first:w(0),last:w(e-1),widest:w(i),highest:w(M),widths:n,heights:o}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);t=this._startPixel+t*this._length;return mt(this._alignToPixels?Re(this.chart,t,0):t)}getDecimalForPixel(t){t=(t-this._startPixel)/this._length;return this._reversePixels?1-t:t}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){var{min:t,max:e}=this;return t<0&&e<0?e:0o+1e-6)))return l}(this,b,l))&&(v=Re(s,x,A),h?_=M=k=P=v:y=w=S=D=v,u.push({tx1:_,ty1:y,tx2:M,ty2:w,x1:k,y1:S,x2:P,y2:D,width:A,color:o,borderDash:T,borderDashOffset:c,tickWidth:d,tickColor:g,tickBorderDash:f,tickBorderDashOffset:p}))}return this._ticksLength=c,this._borderValue=m,u}_computeLabelItems(s){const a=this.axis,n=this.options,{position:o,ticks:e}=n,r=this.isHorizontal(),l=this.ticks,{align:h,crossAlign:c,padding:t,mirror:d}=e,i=Rs(n.grid),u=i+t,g=d?-t:u,f=-L(this.labelRotation),p=[];let m,b,x,v,_,y,M,w,k,S,P,D="middle";if("top"===o)_=this.bottom-g,y=this._getXAxisLabelAlignment();else if("bottom"===o)_=this.top+g,y=this._getXAxisLabelAlignment();else if("left"===o){const s=this._getYAxisLabelAlignment(i);y=s.textAlign,v=s.x}else if("right"===o){const s=this._getYAxisLabelAlignment(i);y=s.textAlign,v=s.x}else if("x"===a){if("center"===o)_=(s.top+s.bottom)/2+u;else if(A(o)){const s=Object.keys(o)[0],a=o[s];_=this.chart.scales[s].getPixelForValue(a)+u}y=this._getXAxisLabelAlignment()}else if("y"===a){if("center"===o)v=(s.left+s.right)/2-u;else if(A(o)){const s=Object.keys(o)[0],a=o[s];v=this.chart.scales[s].getPixelForValue(a)}y=this._getYAxisLabelAlignment(i).textAlign}"y"===a&&("start"===h?D="top":"end"===h&&(D="bottom"));var C=this._getLabelSizes();for(m=0,b=l.length;mt.value===e);return 0<=i?t.setContext(this.getContext(i)).lineWidth:0}drawGrid(t){const e=this.options.grid,s=this.ctx,i=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let a,n;var o=(t,e,i)=>{i.width&&i.color&&(s.save(),s.lineWidth=i.width,s.strokeStyle=i.color,s.setLineDash(i.borderDash||[]),s.lineDashOffset=i.borderDashOffset,s.beginPath(),s.moveTo(t.x,t.y),s.lineTo(e.x,e.y),s.stroke(),s.restore())};if(e.display)for(a=0,n=i.length;a{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:t,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let a,n;for(a=0,n=e.length;a{const e=t.split("."),i=e.pop(),s=[r].concat(e).join("."),a=l[t].split("."),n=a.pop(),o=a.join(".");R.route(s,i,o,n)})),e.descriptors&&R.describe(s,e.descriptors),this.override&&R.override(t.id,t.overrides)),h;throw new Error("class does not have id: "+t)}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in R[s]&&(delete R[s][i],this.override&&delete fe[i])}}var b=new class{constructor(){this.controllers=new Fs(Os,"datasets",!0),this.elements=new Fs(e,"elements"),this.plugins=new Fs(Object,"plugins"),this.scales=new Fs(zs,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(i,t,s){[...t].forEach(t=>{const e=s||this._getRegistryForType(t);s||e.isForType(t)||e===this.plugins&&t.id?this._exec(i,e,t):k(t,t=>{var e=s||this._getRegistryForType(t);this._exec(i,e,t)})})}_exec(t,e,i){var s=K(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(e){for(let t=0;tt.filter(e=>!i.some(t=>e.plugin.id===t.plugin.id));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function Bs(t,e){var i=R.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function Ws(t){if("x"===t||"y"===t||"r"===t)return t}function Ns(t,...e){if(Ws(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||1{var e=r[t];if(!A(e))return console.error("Invalid scale configuration for scale: "+t);if(e._proxy)return console.warn("Ignoring resolver passed as options for scale: "+t);const i=Ns(t,e,function(e,t){if(t.data&&t.data.datasets){t=t.data.datasets.filter(t=>t.xAxisID===e||t.yAxisID===e);if(t.length)return Hs(e,"x",t[0])||Hs(e,"y",t[0])}return{}}(t,o),R.scales[e.type]),s=i===l?"_index_":"_value_",a=n.scales||{};h[t]=$(Object.create(null),[{axis:i},e,a[i],a[s]])}),o.data.datasets.forEach(s=>{const t=s.type||o.type,a=s.indexAxis||Bs(t,e),n=(fe[t]||{}).scales||{};Object.keys(n).forEach(t=>{var e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,a),i=s[e+"AxisID"]||e;h[i]=h[i]||Object.create(null),$(h[i],[{axis:e},r[i],n[t]])})}),Object.keys(h).forEach(t=>{t=h[t];$(t,[R.scales[t.type],R.scale])}),h}(t,e)}function Ys(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const $s=new Map,Us=new Set;function Xs(t,e){let i=$s.get(t);return i||(i=e(),$s.set(t,i),Us.add(i)),i}const qs=(t,e,i)=>{e=m(e,i);void 0!==e&&t.add(e)};class Ks{constructor(t){this._config=((t=(t=t)||{}).data=Ys(t.data),js(t),t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=Ys(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){var t=this._config;this.clearCache(),js(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return Xs(t,()=>[["datasets."+t,""]])}datasetAnimationScopeKeys(t,e){return Xs(t+".transition."+e,()=>[[`datasets.${t}.transitions.`+e,"transitions."+e],["datasets."+t,""]])}datasetElementScopeKeys(t,e){return Xs(t+"-"+e,()=>[[`datasets.${t}.elements.`+e,"datasets."+t,"elements."+e,""]])}pluginScopeKeys(t){const e=t.id;return Xs(this.type+"-plugin-"+e,()=>[["plugins."+e,...t.additionalOptionScopes||[]]])}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(e,t,i){const{options:s,type:a}=this,n=this._cachedScopes(e,i),o=n.get(t);if(o)return o;const r=new Set,l=(t.forEach(t=>{e&&(r.add(e),t.forEach(t=>qs(r,e,t))),t.forEach(t=>qs(r,s,t)),t.forEach(t=>qs(r,fe[a]||{},t)),t.forEach(t=>qs(r,R,t)),t.forEach(t=>qs(r,pe,t))}),Array.from(r));return 0===l.length&&l.push(Object.create(null)),Us.has(t)&&n.set(t,l),l}chartOptionScopes(){var{options:t,type:e}=this;return[t,fe[e]||{},R.datasets[e]||{},{type:e},R,pe]}resolveNamedOptions(t,e,i,s=[""]){const a={$shared:!0},{resolver:n,subPrefixes:o}=Gs(this._resolverCache,t,s);let r=n;!function(t,e){const{isScriptable:i,isIndexable:s}=Ue(t);for(const a of e){const e=i(a),n=s(a),o=(n||e)&&t[a];if(e&&(u(o)||Zs(o))||n&&O(o))return 1}}(n,e)||(a.$shared=!1,r=$e(n,i=u(i)?i():i,this.createResolver(t,i,o)));for(const t of e)a[t]=r[t];return a}createResolver(t,e,i=[""],s){t=Gs(this._resolverCache,t,i).resolver;return A(e)?$e(t,e,void 0,s):t}}function Gs(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));t=i.join();let a=s.get(t);return a||(a={resolver:Ye(e,i),subPrefixes:i.filter(t=>!t.toLowerCase().includes("hover"))},s.set(t,a)),a}const Zs=i=>A(i)&&Object.getOwnPropertyNames(i).reduce((t,e)=>t||u(i[e]),!1),Js=["top","bottom","left","right","chartArea"];function Qs(t,e){return"top"===t||"bottom"===t||-1===Js.indexOf(t)&&"x"===e}function ta(i,s){return function(t,e){return t[i]===e[i]?t[s]-e[s]:t[i]-e[i]}}function ea(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function ia(t){var e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function sa(t){return xe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t=t&&t.canvas?t.canvas:t}const aa={},na=t=>{const e=sa(t);return Object.values(aa).filter(t=>t.canvas===e).pop()};function oa(t,e,i){return(t.options.clip?t:e)[i]}class i{static defaults=R;static instances=aa;static overrides=fe;static registry=b;static version="4.4.0";static getChart=na;static register(...t){b.add(...t),ra()}static unregister(...t){b.remove(...t),ra()}constructor(t,e){const i=this.config=new Ks(e),s=sa(t),a=na(s);if(a)throw new Error("Canvas is already in use. Chart with ID '"+a.id+"' must be destroyed before the canvas with ID '"+a.canvas.id+"' can be reused.");var e=i.createResolver(i.chartOptionScopes(),this.getContext()),t=(this.platform=new(i.platform||fs(s)),this.platform.updateConfig(i),this.platform.acquireContext(s,e.aspectRatio)),n=t&&t.canvas,o=n&&n.height,r=n&&n.width;this.id=F(),this.ctx=t,this.canvas=n,this.width=r,this.height=o,this._options=e,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new Vs,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=Pt(t=>this.update(t),e.resizeDelay||0),this._dataChanges=[],aa[this.id]=this,t&&n?(l.listen(this,"complete",ea),l.listen(this,"progress",ia),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){var{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:s,_aspectRatio:a}=this;return P(t)?e&&a?a:s?i/s:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return b}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():Ce(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Ie(this.canvas,this.ctx),this}stop(){return l.stop(this),this}resize(t,e){l.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){var i=this.options,s=this.canvas,a=i.maintainAspectRatio&&this.aspectRatio,s=this.platform.getMaximumSize(s,t,e,a),t=i.devicePixelRatio||this.platform.getDevicePixelRatio(),e=this.width?"resize":"attach";this.width=s.width,this.height=s.height,this._aspectRatio=this.aspectRatio,Ce(this,t,!0)&&(this.notifyPlugins("resize",{size:s}),d(i.onResize,[this,s],this),this.attached&&this._doResize(e)&&this.render())}ensureScalesHaveIDs(){k(this.options.scales||{},(t,e)=>{t.id=e})}buildOrUpdateScales(){const o=this.options,s=o.scales,r=this.scales,l=Object.keys(r).reduce((t,e)=>(t[e]=!1,t),{});let t=[];k(t=s?t.concat(Object.keys(s).map(t=>{var e=s[t],t=Ns(t,e),i="r"===t,t="x"===t;return{options:e,dposition:i?"chartArea":t?"bottom":"left",dtype:i?"radialLinear":t?"category":"linear"}})):t,t=>{const e=t.options,i=e.id,s=Ns(i,e),a=T(e.type,t.dtype);void 0!==e.position&&Qs(e.position,s)===Qs(t.dposition)||(e.position=t.dposition),l[i]=!0;let n=null;i in r&&r[i].type===a?n=r[i]:(n=new(b.getScale(a))({id:i,type:a,ctx:this.ctx,chart:this}),r[n.id]=n),n.init(e,o)}),k(l,(t,e)=>{t||delete r[e]}),k(r,t=>{a.configure(this,t,t.options),a.addBox(this,t)})}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort((t,e)=>t.index-e.index),ei.length&&delete this._stacks,t.forEach((e,t)=>{0===i.filter(t=>t===e._dataset).length&&this._destroyDatasetMeta(t)})}buildOrUpdateControllers(){const e=[],i=this.data.datasets;let s,a;for(this._removeUnreferencedMetasets(),s=0,a=i.length;s{this.getDatasetMeta(e).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const s=this.config,a=(s.update(),this._options=s.createResolver(s.chartOptionScopes(),this.getContext())),n=this._animationsDisabled=!a.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1!==this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0})){const o=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let i=0;for(let t=0,e=this.data.datasets.length;t{t.reset()}),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(ta("z","_idx"));var{_active:t,_lastEvent:e}=this;e?this._eventHandler(e,!0):t.length&&this._updateHoverStyles(t,t,!0),this.render()}}_updateScales(){k(this.scales,t=>{a.removeBox(this,t)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){var t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);Z(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){var t,e,i,s,a=this["_hiddenIndices"];for({method:t,start:e,count:i}of this._getUniformDataChanges()||[]){n=void 0;o=void 0;r=void 0;s=void 0;var n=a;var o=e;var r="_removeElements"===t?-i:i;const l=Object.keys(n);for(const h of l){const l=+h;l>=o&&(s=n[h],delete n[h],(0o)&&(n[l+r]=s))}}}_getUniformDataChanges(){const t=this._dataChanges;if(t&&t.length){this._dataChanges=[];var e=this.data.datasets.length,i=e=>new Set(t.filter(t=>t[0]===e).map((t,e)=>e+","+t.splice(1).join(","))),s=i(0);for(let t=1;tt.split(",")).map(t=>({method:t[1],start:+t[2],count:+t[3]}))}}_updateLayout(t){if(!1!==this.notifyPlugins("beforeLayout",{cancelable:!0})){a.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],k(this.boxes,t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))},this),this._layers.forEach((t,e)=>{t._idx=e}),this.notifyPlugins("afterLayout")}}_updateDatasets(i){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:i,cancelable:!0})){for(let t=0,e=this.data.datasets.length;tt&&t._dataset===e).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Pi(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){var e=this.data.datasets[t];if(!e)return!1;t=this.getDatasetMeta(t);return"boolean"==typeof t.hidden?!t.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(e,t,i){const s=i?"show":"hide",a=this.getDatasetMeta(e),n=a.controller._resolveAnimations(void 0,s);G(t)?(a.data[t].hidden=!i,this.update()):(this.setDatasetVisibility(e,i),n.update(a,{visible:i}),this.update(t=>t.datasetIndex===e?s:void 0))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),l.remove(this),t=0,e=this.data.datasets.length;t{s.addEventListener(this,t,e),i[t]=e},a=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};k(this.options.events,t=>e(t,a))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const i=this._responsiveListeners,s=this.platform,t=(t,e)=>{s.addEventListener(this,t,e),i[t]=e},e=(t,e)=>{i[t]&&(s.removeEventListener(this,t,e),delete i[t])},a=(t,e)=>{this.canvas&&this.resize(t,e)};let n;const o=()=>{e("attach",o),this.attached=!0,this.resize(),t("resize",a),t("detach",n)};n=()=>{this.attached=!1,e("resize",a),this._stop(),this._resize(0,0),t("attach",o)},(s.isAttached(this.canvas)?o:n)()}unbindEvents(){k(this._listeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._listeners={},k(this._responsiveListeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){var s=i?"set":"remove";let a,n,o,r;for("dataset"===e&&(a=this.getDatasetMeta(t[0].datasetIndex)).controller["_"+s+"DatasetHoverStyle"](),o=0,r=t.length;o{var i=this.getDatasetMeta(t);if(i)return{datasetIndex:t,element:i.data[e],index:e};throw new Error("No dataset found at index "+t)});W(t,e)||(this._active=t,this._lastEvent=null,this._updateHoverStyles(t,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(e){return 1===this._plugins._cache.filter(t=>t.plugin.id===e).length}_updateHoverStyles(t,e,i){var s=this.options.hover,a=(t,i)=>t.filter(e=>!i.some(t=>e.datasetIndex===t.datasetIndex&&e.index===t.index)),n=a(e,t),i=i?t:a(t,e);n.length&&this.updateHoverStyle(n,s.mode,!1),i.length&&s.mode&&this.updateHoverStyle(i,s.mode,!0)}_eventHandler(e,t){const i={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},s=t=>(t.options.events||this.options.events).includes(e.native.type);if(!1!==this.notifyPlugins("beforeEvent",i,s))return t=this._handleEvent(e,t,i.inChartArea),i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(t||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:a}=this,n=e,o=this._getActiveElements(t,s,i,n),r=J(t),l=(h=t,c=this._lastEvent,i&&"mouseout"!==h.type?r?c:h:null);i&&(this._lastEvent=null,d(a.onHover,[t,o,this],this),r&&d(a.onClick,[t,o,this],this));var h,c=!W(o,s);return(c||e)&&(this._active=o,this._updateHoverStyles(o,s,e)),this._lastEvent=l,c}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;i=this.options.hover;return this.getElementsAtEventForMode(t,i.mode,i,s)}}function ra(){k(i.instances,t=>t._plugins.invalidate())}function la(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}var ha={_date:class Xn{static override(t){Object.assign(Xn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return la()}parse(){return la()}format(){return la()}add(){return la()}diff(){return la()}startOf(){return la()}endOf(){return la()}}};function ca(i,s,a,n){if(O(i)){var o=i,r=s,l=a,h=n,c=l.parse(o[0],h),o=l.parse(o[1],h),h=Math.min(c,o),d=Math.max(c,o);let t=h,e=d;Math.abs(h)>Math.abs(d)&&(t=d,e=h),r[l.axis]=e,r._custom={barStart:t,barEnd:e,start:c,end:o,min:h,max:d}}else s[a.axis]=a.parse(i,n);return s}function da(t,e,i,s){const a=t.iScale,n=t.vScale,o=a.getLabels(),r=a===n,l=[];let h,c,d,u;for(c=(h=i)+s;h"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{pointStyle:a,color:n}=s.legend.options["labels"];return t.labels.map((t,e)=>{var i=s.getDatasetMeta(0).controller.getStyle(e);return{text:t,fillStyle:i.backgroundColor,strokeStyle:i.borderColor,fontColor:n,lineWidth:i.borderWidth,pointStyle:a,hidden:!s.getDataVisibility(e),index:e}})}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(s,a){const n=this.getDataset().data,o=this._cachedMeta;if(!1===this._parsing)o._parsed=n;else{let t,e,i=t=>+n[t];if(A(n[s])){const{key:s="value"}=this._parsing;i=t=>+m(n[t],s)}for(e=(t=s)+a;tpt(t,r,l,!0)?1:Math.max(e,e*s,i,i*s),f=(t,e,i)=>pt(t,r,l,!0)?-1:Math.min(e,e*s,i,i*s),p=g(0,h,d),m=g(D,c,u),b=f(S,h,d),x=f(S+D,c,u);i=(p-b)/2,a=(m-x)/2,n=-(p+b)/2,o=-(m+x)/2}return{ratioX:i,ratioY:a,offsetX:n,offsetY:o}}(c,h,r),p=(i.width-n)/d,m=(i.height-n)/u,b=Math.max(Math.min(p,m)/2,0),x=B(this.options.radius,b),v=(x-Math.max(x*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=g*x,this.offsetY=f*x,s.total=this.calculateTotal(),this.outerRadius=x-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(a,0,a.length,t)}_circumference(t,e){var i=this.options,s=this._cachedMeta,a=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*a/_)}updateElements(t,e,i,s){const a="reset"===s,n=this.chart,o=n.chartArea,r=n.options.animation,l=(o.left+o.right)/2,h=(o.top+o.bottom)/2,c=a&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:g,includeOptions:f}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p{var i=s.getDatasetMeta(0).controller.getStyle(e);return{text:t,fillStyle:i.backgroundColor,strokeStyle:i.borderColor,fontColor:n,lineWidth:i.borderWidth,pointStyle:a,hidden:!s.getDataVisibility(e),index:e}})}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){var e=this._cachedMeta,i=this.chart,s=i.data.labels||[],e=de(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:e}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){var e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,s={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach((t,e)=>{var i=this.getParsed(e).r;!isNaN(i)&&this.chart.getDataVisibility(e)&&(is.max&&(s.max=i))}),s}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),a=Math.max(s/2,0),n=(a-Math.max(i.cutoutPercentage?a/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=a-n*this.index,this.innerRadius=this.outerRadius-n}updateElements(s,a,t,n){const o="reset"===n,r=this.chart,l=r.options.animation,h=this._cachedMeta.rScale,c=h.xCenter,d=h.yCenter,u=h.getIndexAngle(0)-.5*S;let g,f=u;var p=360/this.countVisibleElements();for(g=0;g{!isNaN(this.getParsed(e).r)&&this.chart.getDataVisibility(e)&&i++}),i}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?L(this.resolveDataElementOptions(t,e).angle||i):0}}var ba=Object.freeze({__proto__:null,BarController:class extends Os{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return da(t,e,i,s)}parseArrayData(t,e,i,s){return da(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:a,vScale:n}=t,{xAxisKey:o="x",yAxisKey:r="y"}=this._parsing,l="x"===a.axis?o:r,h="x"===n.axis?o:r,c=[];let d,u,g,f;for(u=(d=i)+s;dn.x,e="left","right"):(t=n.baset.controller.options.grouped),a=e.options.stacked,n=[];for(const e of s)if((void 0===i||!(t=>{var e=t.controller.getParsed(i),e=e&&e[t.vScale.axis];if(P(e)||isNaN(e))return!0})(e))&&((!1===a||-1===n.indexOf(e.stack)||void 0===a&&void 0===e.stack)&&n.push(e.stack),e.index===t))break;return n.length||n.push(void 0),n}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),a=void 0!==e?s.indexOf(e):-1;return-1===a?s.length-1:a}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let a,n;for(a=0,n=e.data.length;at-e))}return s._cache.$bar}(e,t.type);let s,a,n,o,r=e._length;var l=()=>{32767!==n&&-32768!==n&&(G(o)&&(r=Math.min(r,Math.abs(n-o)||r)),o=n)};for(s=0,a=i.length;s=m?1:-1))*n),u===o&&(x-=d/2);const t=e.getPixelForDecimal(0),P=e.getPixelForDecimal(1),a=Math.min(t,P),l=Math.max(t,P);x=Math.max(Math.min(x,l),a),c=x+d,i&&!h&&(r._stacks[e.axis]._visualValues[s]=e.getValueForPixel(c)-e.getValueForPixel(x))}if(x===e.getPixelForValue(o)){const t=y(d)*e.getLineWidthForValue(o)/2;x+=t,d-=t}return{size:d,base:x,head:c,center:c+d/2}}_calculateBarIndexPixels(t,e){const i=e.scale,s=this.options,a=s.skipNull,n=T(s.maxBarThickness,1/0);let o,r;if(e.grouped){const i=a?this._getStackCount(t):e.stackCount,T=("flex"===s.barThickness?function(t,e,i,s){var a=e.pixels,n=a[t];let o=0=b?x.skip=!0:(y=P((_=this.getParsed(t))[u]),M=x[d]=n.getPixelForValue(_[d],t),w=x[u]=a||y?o.getBasePixel():o.getPixelForValue(r?this.applyStack(o,_,r):_[u],t),x.skip=isNaN(M)||isNaN(w)||y,x.stop=0p,f&&(x.parsed=_,x.raw=l.data[t]),c&&(x.options=h||this.resolveDataElementOptions(t,g.active?"active":s)),m||this.updateElement(g,t,x,s),v=_)}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;var a=s[0].size(this.resolveDataElementOptions(0)),n=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,a,n)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends pa{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:ma,RadarController:class extends Os{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],a=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);var n={_loop:!0,_fullLoop:a.length===s.length,options:e};this.updateElement(i,void 0,n,t)}this.updateElements(s,0,s.length,t)}updateElements(e,i,s,a){const n=this._cachedMeta.rScale,o="reset"===a;for(let t=i;tm,p&&(f.parsed=s,f.raw=h.data[t]),d&&(f.options=c||this.resolveDataElementOptions(t,i.active?"active":a)),b||this.updateElement(i,t,f,a),x=s}this.updateSharedOptions(c,a,t)}getMaxOverflow(){const t=this._cachedMeta,i=t.data||[];if(!this.options.showLine){let e=0;for(let t=i.length-1;0<=t;--t)e=Math.max(e,i[t].size(this.resolveDataElementOptions(t))/2);return 0{var e=(i-Math.min(a,t))*s/2;return C(t,0,Math.min(a,e))};return{outerStart:o(t.outerStart),outerEnd:o(t.outerEnd),innerStart:C(t.innerStart,0,n),innerEnd:C(t.innerEnd,0,n)}}(e,h,d,l-g),b=d-c,x=d-f,v=g+c/b,_=l-f/x,y=h+p,M=h+m,w=g+p/y,k=l-m/M;if(t.beginPath(),n){const e=(v+_)/2;if(t.arc(o,r,d,v,e),t.arc(o,r,d,e,_),0(o+(h?r-t:t))%n,v=()=>{g!==f&&(t.lineTo(m,f),t.lineTo(m,g),t.lineTo(m,p))};for(l&&(d=a[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c)if(!(d=a[x(c)]).skip){const e=d.x,i=d.y,s=0|e;s===u?(if&&(f=i),m=(b*m+e)/++b):(v(),t.lineTo(e,i),u=s,b=0,g=f=i),p=i}v()}function Sa(t){var e=t.options,i=e.borderDash&&e.borderDash.length;return t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i?wa:ka}const Pa="function"==typeof Path2D;class Da extends e{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){var i,s=this.options;!s.tension&&"monotone"!==s.cubicInterpolationMode||s.stepped||this._pointsUpdated||(i=s.spanGaps?this._loop:this._fullLoop,hi(this._points,s,t,i,e),this._pointsUpdated=!0)}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Ri(this,this.options.segment))}first(){var t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){var t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(i,s){var a=this.options,n=i[s],o=this.points,r=Ei(this,{property:s,start:n,end:n});if(r.length){const l=[],h=a.stepped?pi:a.tension||"monotone"===a.cubicInterpolationMode?mi:fi;let e,t;for(e=0,t=r.length;e"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){var{angle:t,distance:e}=ut(this.getProps(["x","y"],i),{x:t,y:e}),{startAngle:i,endAngle:s,innerRadius:a,outerRadius:n,circumference:o}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),r=(this.options.spacing+this.options.borderWidth)/2,o=T(o,s-i)>=_||pt(t,i,s),t=c(e,a+r,n+r);return o&&t}getCenterPoint(t){var{x:t,y:e,startAngle:i,endAngle:s,innerRadius:a,outerRadius:n}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:o,spacing:r}=this.options,i=(i+s)/2,s=(a+n+r+o)/2;return{x:t+Math.cos(i)*s,y:e+Math.sin(i)*s}}tooltipPosition(t){return this.getCenterPoint(t)}draw(e){var{options:i,circumference:s}=this,a=(i.offset||0)/4,n=(i.spacing||0)/2,o=i.circular;if(this.pixelMargin="inner"===i.borderAlign?.33:0,this.fullCircles=s>_?Math.floor(s/_):0,!(0===s||this.innerRadius<0||this.outerRadius<0)){e.save();var r=(this.startAngle+this.endAngle)/2,r=(e.translate(Math.cos(r)*a,Math.sin(r)*a),a*(1-Math.sin(Math.min(S,s||0))));e.fillStyle=i.backgroundColor,e.strokeStyle=i.borderColor;{var l=e;a=this;s=r;i=n;var h=o;var{fullCircles:c,startAngle:d,circumference:u}=a;let t=a.endAngle;if(c){va(l,a,s,i,t,h);for(let t=0;ts=e?s:t,r=t=>a=i?a:t;if(t){const t=y(s),e=y(a);t<0&&e<0?r(0):0g&&(k=nt(w*k/g/u)*u),P(r)||(_=Math.pow(10,r),k=Math.ceil(k*_)/_),M="ticks"===s?(y=Math.floor(f/k)*k,Math.ceil(p/k)*k):(y=f,p),m&&b&&a&<((o-n)/a,k/1e3)?(w=Math.round(Math.min((o-n)/k,h)),k=(o-n)/w,y=n,M=o):x?(y=m?n:y,M=b?o:M,w=l-1,k=(M-y)/w):w=at(w=(M-y)/k,Math.round(w),k/1e3)?Math.round(w):Math.ceil(w);e=Math.max(dt(k),dt(y));_=Math.pow(10,P(r)?e:r),y=Math.round(y*_)/_,M=Math.round(M*_)/_;let S=0;for(m&&(d&&y!==n?(i.push({value:n}),yo)break;i.push({value:t})}return b&&d&&M!==o?i.length&&at(i[i.length-1].value,o,za(o,v,t))?i[i.length-1].value=o:i.push({value:o}):b&&M!==o||i.push({value:M}),i}({maxTicks:Math.max(2,i),bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&ht(s,this,"value"),t.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}configure(){var t=this.ticks;let e=this.min,i=this.max;super.configure(),this.options.offset&&t.length&&(t=(i-e)/Math.max(t.length-1,1)/2,e-=t,i+=t),this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return de(t,this.chart.options.locale,this.options.ticks.format)}}class Va extends Fa{static id="linear";static defaults={ticks:{callback:ge.formatters.numeric}};determineDataLimits(){var{min:t,max:e}=this.getMinMax(!0);this.min=p(t)?t:0,this.max=p(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){var t=this.isHorizontal(),e=t?this.width:this.height,i=L(this.options.ticks.minRotation),t=(t?Math.sin(i):Math.cos(i))||.001,i=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,i.lineHeight/t))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const Ba=t=>Math.floor(r(t)),Wa=(t,e)=>Math.pow(10,Ba(t)+e);function Na(t){return 1==t/Math.pow(10,Ba(t))}function Ha(t,e,i){i=Math.pow(10,i),t=Math.floor(t/i);return Math.ceil(e/i)-t}class ja extends zs{static id="logarithmic";static defaults={ticks:{callback:ge.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){t=Fa.prototype.parse.apply(this,[t,e]);if(0!==t)return p(t)&&0s=e?s:t,n=t=>a=i?a:t;s===a&&(s<=0?(t(1),n(10)):(t(Wa(s,-1)),n(Wa(a,1)))),s<=0&&t(Wa(a,-1)),a<=0&&n(Wa(s,1)),this.min=s,this.max=a}buildTicks(){const t=this.options,e=function(t,{min:e,max:i}){e=g(t.min,e);const s=[],a=Ba(e);let n=function(t,e){let i=Ba(e-t);for(;10n?Math.pow(10,a):0,h=Math.round((e-l)*o)/o,c=Math.floor((e-l)/r/10)*r*10;let d=Math.floor((h-c)/Math.pow(10,n)),u=g(t.min,Math.round((l+c+d*Math.pow(10,n))*o)/o);for(;uf.r&&(t=(m.end-f.r)/x,g.r=Math.max(g.r,f.r+t)),b.startf.b&&(e=(b.end-f.b)/p,g.b=Math.max(g.b,f.b+e))}}var d,u;e.setCenterPoint(i.l-s.l,s.r-i.r,i.t-s.t,s.b-i.b),e._pointLabelItems=function(e,i,s){const a=[],n=e._pointLabels.length,t=e.options,{centerPointLabels:o,display:r}=t.pointLabels,l={extra:Ya(t)/2,additionalAngle:o?S/n:0};let h;for(let t=0;tt,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){var t=this._padding=I(Ya(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){var{min:t,max:e}=this.getMinMax(!1);this.min=p(t)&&!isNaN(t)?t:0,this.max=p(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Ya(this.options))}generateTickLabels(t){Fa.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map((t,e)=>{t=d(this.options.pointLabels.callback,[t,e],this);return t||0===t?t:""}).filter((t,e)=>this.chart.getDataVisibility(e))}fit(){var t=this.options;t.display&&t.pointLabels.display?Ua(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return v(t*(_/(this._pointLabels.length||1))+L(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(P(t))return NaN;var e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(P(t))return NaN;t/=this.drawingArea/(this.max-this.min);return this.options.reverse?this.max-t:this.min+t}getPointLabelContext(t){var e=this._pointLabels||[];if(0<=t&&t0!==t)?(g.beginPath(),je(g,{x:f,y:r,w:x,h:m,radius:p}),g.fill()):g.fillRect(f,r,x,m)}var p=z(l.font),{x:v,y:b,textAlign:g}=o;He(_,n._pointLabels[t],v,b+p.lineHeight/2,p,{color:l.color,textAlign:g,textBaseline:"middle"})}}}if(h.display&&this.ticks.forEach((t,e)=>{if(0!==e){u=this.getDistanceFromCenterForValue(t.value);t=this.getContext(e),e=h.setContext(t),t=c.setContext(t);{var i=this,s=u,a=d;const n=i.ctx,o=e.circular,{color:r,lineWidth:l}=e;!o&&!a||!r||!l||s<0||(n.save(),n.strokeStyle=r,n.lineWidth=l,n.setLineDash(t.dash),n.lineDashOffset=t.dashOffset,n.beginPath(),Xa(i,s,o,a),n.closePath(),n.stroke(),n.restore())}}}),i.display){for(t.save(),s=d-1;0<=s;s--){const h=i.setContext(this.getPointLabelContext(s)),{color:c,lineWidth:d}=h;d&&c&&(t.lineWidth=d,t.strokeStyle=c,t.setLineDash(h.borderDash),t.lineDashOffset=h.borderDashOffset,u=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),a=this.getPointPosition(s,u),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(a.x,a.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const o=this.ctx,r=this.options,l=r.ticks;if(l.display){var t=this.getIndexAngle(0);let a,n;o.save(),o.translate(this.xCenter,this.yCenter),o.rotate(t),o.textAlign="center",o.textBaseline="middle",this.ticks.forEach((t,e)=>{if(0!==e||r.reverse){var i=l.setContext(this.getContext(e)),s=z(i.font);if(a=this.getDistanceFromCenterForValue(this.ticks[e].value),i.showLabelBackdrop){o.font=s.string,n=o.measureText(t.label).width,o.fillStyle=i.backdropColor;const r=I(i.backdropPadding);o.fillRect(-n/2-r.left,-a-s.size/2-r.top,n+r.width,s.size+r.height)}He(o,t.label,0,-a,s,{color:i.color,strokeColor:i.textStrokeColor,strokeWidth:i.textStrokeWidth})}}),o.restore()}}drawTitle(){}}const Ka={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},h=Object.keys(Ka);function Ga(t,e){return t-e}function Za(t,e){if(P(e))return null;const i=t._adapter,{parser:s,round:a,isoWeekday:n}=t._parseOpts;let o=e;return null===(o=p(o="function"==typeof s?s(o):o)?o:"string"==typeof s?i.parse(o,s):i.parse(o))?null:+(o=a?"week"!==a||!rt(n)&&!0!==n?i.startOf(o,a):i.startOf(o,"isoWeek",n):o)}function Ja(e,i,s,a){const n=h.length;for(let t=h.indexOf(e);t=e?i[s]:i[a]]=!0):t[e]=!0}function tn(i,t,s){const a=[],n={},e=t.length;let o,r;for(o=0;o=h.indexOf(s);t--){const s=h[t];if(Ka[s].common&&e._adapter.diff(n,a,s)>=i-1)return s}return h[s?h.indexOf(s):0]}(this,n.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(i){for(let t=h.indexOf(i)+1,e=h.length;t+t.value))}initOffsets(t=[]){let e,i,s=0,a=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),a=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);t=t.length<3?.5:.25;s=C(s,0,t),a=C(a,0,t),this._offsets={start:s,end:a,factor:1/(s+1+a)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,a=s.time,n=a.unit||Ja(a.minUnit,e,i,this._getLabelCapacity(e)),o=T(s.ticks.stepSize,1),r="week"===n&&a.isoWeekday,l=rt(r)||!0===r,h={};let c,d,u=e;if(l&&(u=+t.startOf(u,"isoWeek",r)),u=+t.startOf(u,l?"day":n),t.diff(i,e,n)>1e5*o)throw new Error(e+" and "+i+" are too far apart with stepSize of "+o+" "+n);var g="data"===s.ticks.source&&this.getDataTimestamps();for(c=u,d=0;c+t)}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){var i=this.options.time.displayFormats,s=this._unit,e=e||i[s];return this._adapter.format(t,e)}_tickFormatFunction(t,e,i,s){var a=this.options,n=a.ticks.callback;if(n)return d(n,[t,e,i],this);var n=a.time.displayFormats,a=this._unit,o=this._majorUnit,a=a&&n[a],n=o&&n[o],i=i[e],e=o&&n&&i&&i.major;return this._adapter.format(t,s||(e?n:a))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=f(t,"pos",e)),{pos:s,time:n}=t[r],{pos:a,time:o}=t[l]):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=f(t,"time",e)),{time:s,pos:n}=t[r],{time:a,pos:o}=t[l]);i=a-s;return i?n+(o-n)*(e-s)/i:n}var an=Object.freeze({__proto__:null,CategoryScale:class extends zs{static id="category";static defaults={ticks:{callback:Ia}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){var e=this._addedLabels;if(e.length){const t=this.getLabels();for(var{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(P(t))return null;var i,s,a,n,o,r,l=this.getLabels();return a=e=isFinite(e)&&l[e]===t?e:(i=l,s=T(e,t=t),a=this._addedLabels,-1===(r=i.indexOf(t))?(o=s,a=a,"string"==typeof(n=t)?(o=i.push(n)-1,a.unshift({index:o,label:n})):isNaN(n)&&(o=null),o):r!==i.lastIndexOf(t)?s:r),n=l.length-1,null===a?null:C(Math.round(a),0,n)}determineDataLimits(){var{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const e=this.min,i=this.max,t=this.options.offset,s=[];let a=this.getLabels();a=0===e&&i===a.length-1?a:a.slice(e,i+1),this._valueRange=Math.max(a.length-(t?0:1),1),this._startValue=this.min-(t?.5:0);for(let t=e;t<=i;t++)s.push({value:t});return s}getLabelForValue(t){return Ia.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return null===(t="number"!=typeof t?this.parse(t):t)?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:Va,LogarithmicScale:ja,RadialLinearScale:qa,TimeScale:en,TimeSeriesScale:class extends en{static id="timeseries";static defaults=en.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){var t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=sn(e,this.min),this._tableRange=sn(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],a=[];let n,o,r,l,h;for(n=0,o=t.length;n=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(n=0,o=s.length;nt-e)}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t}getDecimalForValue(t){return(sn(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){var e=this._offsets,t=this.getDecimalForPixel(t)/e.factor-e.end;return sn(this._table,t*this._tableRange+this._minPos,!0)}}});const nn=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],on=nn.map(t=>t.replace("rgb(","rgba(").replace(")",", 0.5)"));function rn(t){return nn[t%nn.length]}function ln(t){return on[t%on.length]}function hn(n){let o=0;return(t,e)=>{var i,s,a,e=n.getDatasetMeta(e).controller;e instanceof pa?o=(s=t,a=o,s.backgroundColor=s.data.map(()=>rn(a++)),a):e instanceof ma?o=(s=t,i=o,s.backgroundColor=s.data.map(()=>ln(i++)),i):e&&(o=(e=t,t=o,e.borderColor=rn(t),e.backgroundColor=ln(t),++t))}}function cn(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return 1}var dn={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(i.enabled){const{data:{datasets:s},options:a}=t.config,n=a["elements"];!i.forceOverride&&(cn(s)||a&&(a.borderColor||a.backgroundColor)||n&&cn(n))||(i=hn(t),s.forEach(i))}}};function un(t){var e;t._decimated&&(e=t._data,delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e}))}function gn(t){t.data.datasets.forEach(t=>{un(t)})}var fn={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(r,t,M)=>{if(M.enabled){const l=r.width;r.data.datasets.forEach((e,t)=>{var{_data:i,indexAxis:s}=e,h=r.getDatasetMeta(t),a=i||e.data;if("y"!==ki([s,r.options.indexAxis])&&h.controller.supportsDecimation){t=r.scales[h.xAxisID];if(("linear"===t.type||"time"===t.type)&&!r.options.parsing){var{start:n,count:o}=function(t){var e=t.length;let i,s=0;const a=h["iScale"],{min:n,max:o,minDefined:r,maxDefined:l}=a.getUserBounds();return r&&(s=C(f(t,a.axis,n).lo,0,e-1)),i=l?C(f(t,a.axis,o).hi+1,s,e)-s:e-s,{start:s,count:i}}(a);if(o<=(M.threshold||4*l))un(e);else{let t;switch(P(i)&&(e._data=a,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),M.algorithm){case"lttb":t=function(s,a,n,t){var e=M.samples||t;if(n<=e)return s.slice(a,a+n);const o=[],r=(n-2)/(e-2);let l=0;const h=a+n-1;let c,d,u,g,f,p=a;for(o[l++]=s[p],c=0;cu&&(u=g,d=s[t],f=t);o[l++]=d,p=f}return o[l++]=s[h],o}(a,n,o,l);break;case"min-max":t=function(t,e,i,s){let a,n,o,r,l,h,c,d,u,g,f=0,p=0;const m=[],b=e+i-1,x=t[e].x,v=t[b].x-x;for(a=e;ag&&(g=r,c=a),f=(p*f+n.x)/++p;else{const i=a-1;if(!P(h)&&!P(c)){const e=Math.min(h,c),P=Math.max(h,c);e!==d&&e!==i&&m.push({...t[e],x:f}),P!==d&&P!==i&&m.push({...t[P],x:f})}0{e=mn(t,e,a);t=a[t],e=a[e];null!==s?(n.push({x:t.x,y:s}),n.push({x:e.x,y:s})):null!==i&&(n.push({x:i,y:t.y}),n.push({x:i,y:e.y}))}),n}(t)).length?new Da({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function vn(t){return t&&!1!==t.fill}function _n(e,i,s){const a=[];for(let t=0;t{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class On extends e{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const i=this.options.labels||{};let t=d(i.generateLabels,[this.chart],this)||[];i.filter&&(t=t.filter(t=>i.filter(t,this.chart.data))),i.sort&&(t=t.sort((t,e)=>i.sort(t,e,this.chart.data))),this.options.reverse&&t.reverse(),this.legendItems=t}fit(){const{options:i,ctx:s}=this;if(i.display){var a=i.labels,n=z(a.font),o=n.size,r=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=Cn(a,o);let t,e;s.font=n.string,this.isHorizontal()?(t=this.maxWidth,e=this._fitRows(r,o,a,l)+10):(e=this.maxHeight,t=this._fitCols(r,n,a,l)+10),this.width=Math.min(t,i.maxWidth||this.maxWidth),this.height=Math.min(e,i.maxHeight||this.maxHeight)}else this.width=this.height=0}_fitRows(t,i,s,a){const{ctx:n,maxWidth:o,options:{labels:{padding:r}}}=this,l=this.legendHitBoxes=[],h=this.lineWidths=[0],c=a+r;let d=t,u=(n.textAlign="left",n.textBaseline="middle",-1),g=-c;return this.legendItems.forEach((t,e)=>{t=s+i/2+n.measureText(t.text).width;(0===e||h[h.length-1]+t+2*r>o)&&(d+=c,h[h.length-(0{o=l,i=r,s=c,a=t,n=h;var i,s,a,n,{itemWidth:t,itemHeight:o}={itemWidth:function(t,e,i){let s=a.text;return s&&"string"!=typeof s&&(s=s.reduce((t,e)=>t.length>e.length?t:e)),t+e.size/2+i.measureText(s).width}(o,i,s),itemHeight:function(t){let e=n;return e="string"!=typeof a.text?An(a,t):e}(i.lineHeight)};0f&&(p+=m+d,g.push({width:m,height:b}),x+=m+d,v++,m=b=0),u[e]={left:x,top:b,col:v,width:t,height:o},m=Math.max(m,t),b+=o+d}),p+=m,g.push({width:m,height:b}),p}adjustHitBoxes(){if(this.options.display){const i=this._computeTitleHeight(),{legendHitBoxes:s,options:{align:a,labels:{padding:n},rtl:t}}=this,o=Di(t,this.left,this.width);if(this.isHorizontal()){let t=0,e=E(a,this.left+n,this.right-this.lineWidths[t]);for(const r of s)t!==r.row&&(t=r.row,e=E(a,this.left+n,this.right-this.lineWidths[t])),r.top+=this.top+i+n,r.left=o.leftForLtr(o.x(e),r.width),e+=r.width+n}else{let t=0,e=E(a,this.top+i+n,this.bottom-this.columnSizes[t].height);for(const l of s)l.col!==t&&(t=l.col,e=E(a,this.top+i+n,this.bottom-this.columnSizes[t].height)),l.top=e,l.left+=this.left+n,l.left=o.leftForLtr(o.x(l.left),l.width),e+=l.height+n}}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){var t;this.options.display&&(Ve(t=this.ctx,this),this._draw(),Be(t))}_draw(){const{options:h,columnSizes:c,lineWidths:d,ctx:u}=this,{align:g,labels:f}=h,p=R.color,m=Di(h.rtl,this.left,this.width),b=z(f.font),x=f["padding"],v=b.size,_=v/2;let y;this.drawTitle(),u.textAlign=m.textAlign("left"),u.textBaseline="middle",u.lineWidth=.5,u.font=b.string;const{boxWidth:M,boxHeight:w,itemHeight:k}=Cn(f,v),S=this.isHorizontal(),P=this._computeTitleHeight(),D=(y=S?{x:E(g,this.left+x,this.right-d[0]),y:this.top+x+P,line:0}:{x:this.left+x,y:E(g,this.top+P+x,this.bottom-c[0].height),line:0},Ci(this.ctx,h.textDirection),k+x);this.legendItems.forEach((t,e)=>{u.strokeStyle=t.fontColor,u.fillStyle=t.fontColor;var i=u.measureText(t.text).width,s=m.textAlign(t.textAlign||(t.textAlign=f.textAlign)),i=M+_+i;let a=y.x,n=y.y;m.setWidth(this.width),S?0this.right&&(n=y.y+=D,y.line++,a=y.x=E(g,this.left+x,this.right-d[y.line])):0this.bottom&&(a=y.x=a+c[y.line].width+x,y.line++,n=y.y=E(g,this.top+P+x,this.bottom-c[y.line].height));var e=m.x(a),o=n,r=t;if(!(isNaN(M)||M<=0||isNaN(w)||w<0)){u.save();var l=T(r.lineWidth,1);if(u.fillStyle=T(r.fillStyle,p),u.lineCap=T(r.lineCap,"butt"),u.lineDashOffset=T(r.lineDashOffset,0),u.lineJoin=T(r.lineJoin,"miter"),u.lineWidth=l,u.strokeStyle=T(r.strokeStyle,p),u.setLineDash(T(r.lineDash,[])),f.usePointStyle){const p={radius:w*Math.SQRT2/2,pointStyle:r.pointStyle,rotation:r.rotation,borderWidth:l},T=m.xPlus(e,M/2);Fe(u,p,T,o+_,f.pointStyleWidth&&M)}else{const f=o+Math.max((v-w)/2,0),p=m.leftForLtr(e,M),T=wi(r.borderRadius);u.beginPath(),Object.values(T).some(t=>0!==t)?je(u,{x:p,y:f,w:M,h:w,radius:T}):u.rect(p,f,M,w),u.fill(),0!==l&&u.stroke()}u.restore()}if(a=Ct(s,a+M+_,S?a+i:this.right,h.rtl),o=m.x(a),e=n,r=t,He(u,r.text,o,e+k/2,b,{strikethrough:r.hidden,textAlign:m.textAlign(r.textAlign)}),S)y.x+=i+x;else if("string"!=typeof t.text){const h=b.lineHeight;y.y+=An(t,h)+x}else y.y+=D}),Oi(this.ctx,h.textDirection)}drawTitle(){const s=this.options,a=s.title,n=z(a.font),o=I(a.padding);if(a.display){const l=Di(s.rtl,this.left,this.width),h=this.ctx,c=a.position,d=n.size/2,u=o.top+d;let t,e=this.left,i=this.width;if(this.isHorizontal())i=Math.max(...this.lineWidths),t=this.top+u,e=E(s.align,e,this.right-i);else{const a=this.columnSizes.reduce((t,e)=>Math.max(t,e.height),0);t=u+E(s.align,this.top,this.bottom-a-s.labels.padding-this._computeTitleHeight())}var r=E(c,e,e+i);h.textAlign=l.textAlign(Dt(c)),h.textBaseline="middle",h.strokeStyle=a.color,h.fillStyle=a.color,h.font=n.string,He(h,a.text,r,t,n)}}_computeTitleHeight(){var t=this.options.title,e=z(t.font),i=I(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,a;if(c(t,this.left,this.right)&&c(e,this.top,this.bottom))for(a=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const s=t.data.datasets,{usePointStyle:a,pointStyle:n,textAlign:o,color:r,useBorderRadius:l,borderRadius:h}=t.legend.options["labels"];return t._getSortedDatasetMetas().map(t=>{var e=t.controller.getStyle(a?0:void 0),i=I(e.borderWidth);return{text:s[t.index].label,fillStyle:e.backgroundColor,fontColor:r,hidden:!t.visible,lineCap:e.borderCapStyle,lineDash:e.borderDash,lineDashOffset:e.borderDashOffset,lineJoin:e.borderJoinStyle,lineWidth:(i.width+i.height)/4,strokeStyle:e.borderColor,pointStyle:n||e.pointStyle,rotation:e.rotation,textAlign:o||e.textAlign,borderRadius:l&&(h||e.borderRadius),datasetIndex:t.index}},this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class Ln extends e{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){var i=this.options;this.left=0,this.top=0,i.display?(this.width=this.right=t,this.height=this.bottom=e,t=O(i.text)?i.text.length:1,this._padding=I(i.padding),e=t*z(i.font).lineHeight+this._padding.height,this.isHorizontal()?this.height=e:this.width=e):this.width=this.height=this.right=this.bottom=0}isHorizontal(){var t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){var{top:e,left:i,bottom:s,right:a,options:n}=this,o=n.align;let r,l,h,c=0;return r=this.isHorizontal()?(l=E(o,i,a),h=e+t,a-i):(c="left"===n.position?(l=i+t,h=E(o,s,e),-.5*S):(l=a-t,h=E(o,e,s),.5*S),s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){var t,e,i,s,a,n=this.ctx,o=this.options;o.display&&(e=(t=z(o.font)).lineHeight/2+this._padding.top,{titleX:e,titleY:i,maxWidth:s,rotation:a}=this._drawArgs(e),He(n,o.text,0,0,t,{color:o.color,maxWidth:s,rotation:a,textAlign:Dt(o.align),textBaseline:"middle",translation:[e,i]}))}}var En={id:"title",_element:Ln,start(t,e,i){var s;t=t,i=i,s=new Ln({ctx:t.ctx,options:i,chart:t}),a.configure(t,s,i),a.addBox(t,s),t.titleBlock=s},stop(t){var e=t.titleBlock;a.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;a.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Rn=new WeakMap;var In={id:"subtitle",start(t,e,i){var s=new Ln({ctx:t.ctx,options:i,chart:t});a.configure(t,s,i),a.addBox(t,s),Rn.set(t,s)},stop(t){a.removeBox(t,Rn.get(t)),Rn.delete(t)},beforeUpdate(t,e,i){const s=Rn.get(t);a.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const zn={average(t){if(!t.length)return!1;let e,i,s=0,a=0,n=0;for(e=0,i=t.length;et+e.before.length+e.lines.length+e.after.length,0),x=(b+=t.beforeBody.length+t.afterBody.length,d&&(p+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b&&(p+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing),u&&(p+=e.footerMarginTop+u*c.lineHeight+(u-1)*e.footerSpacing),0);function v(t){m=Math.max(m,i.measureText(t).width+x)}return i.save(),i.font=h.string,k(t.title,v),i.font=l.string,k(t.beforeBody.concat(t.afterBody),v),x=e.displayColors?o+2+e.boxPadding:0,k(s,t=>{k(t.before,v),k(t.lines,v),k(t.after,v)}),x=0,i.font=c.string,k(t.footer,v),i.restore(),{width:m+=f.width,height:p}}function Bn(i,t,s){var e=s.yAlign||t.yAlign||function(){var{y:t,height:e}=s;return ti.height-e/2?"bottom":"center"}();return{xAlign:s.xAlign||t.xAlign||function(a,n,o,t){var{x:e,width:i}=o,{width:s,chartArea:{left:r,right:l}}=a;let h="center";return"center"===t?h=e<=(r+l)/2?"left":"right":e<=i/2?h="left":s-i/2<=e&&(h="right"),h=function(t){var{x:e,width:i}=o,s=n.caretSize+n.caretPadding;return"left"===t&&e+i+s>a.width||"right"===t&&e-i-s<0}(h)?"center":h}(i,t,s,e),yAlign:e}}function Wn(t,i,e,s){var{caretSize:t,caretPadding:a,cornerRadius:n}=t,{xAlign:o,yAlign:r}=e,l=t+a,{topLeft:e,topRight:a,bottomLeft:n,bottomRight:h}=wi(n);let c=function(){let{x:t,width:e}=i;return"right"===o?t-=e:"center"===o&&(t-=e/2),t}();var d=function(){let{y:t,height:e}=i;return"top"===r?t+=l:t-="bottom"===r?e+l:e/2,t}();return"center"===r?"left"===o?c+=l:"right"===o&&(c-=l):"left"===o?c-=Math.max(e,n)+t:"right"===o&&(c+=Math.max(a,h)+t),{x:C(c,0,s.width-i.width),y:C(d,0,s.height-i.height)}}function Nn(t,e,i){i=I(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-i.right:t.x+i.left}function Hn(t){return x([],Fn(t))}function jn(t,e){e=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return e?t.override(e):t}const Yn={beforeTitle:t,title(t){if(0{var e={before:[],lines:[],after:[]},i=jn(s,t);x(e.before,Fn(w(i,"beforeLabel",this,t))),x(e.lines,w(i,"label",this,t)),x(e.after,Fn(w(i,"afterLabel",this,t))),a.push(e)}),a}getAfterBody(t,e){return Hn(w(e.callbacks,"afterBody",this,t))}getFooter(t,e){var e=e["callbacks"],i=w(e,"beforeFooter",this,t),s=w(e,"footer",this,t),e=w(e,"afterFooter",this,t),t=x([],Fn(i));return t=x(t,Fn(s)),x(t,Fn(e))}_createItems(s){const t=this._active,a=this.chart.data,i=[],n=[],o=[];let e,r,l=[];for(e=0,r=t.length;es.filter(t,e,i,a))),k(l=s.itemSort?l.sort((t,e)=>s.itemSort(t,e,a)):l,t=>{var e=jn(s.callbacks,t);i.push(w(e,"labelColor",this,t)),n.push(w(e,"labelPointStyle",this,t)),o.push(w(e,"labelTextColor",this,t))}),this.labelColors=i,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let a,n=[];if(s.length){const t=zn[i.position].call(this,s,this._eventPosition),e=(n=this._createItems(i),this.title=this.getTitle(n,i),this.beforeBody=this.getBeforeBody(n,i),this.body=this.getBody(n,i),this.afterBody=this.getAfterBody(n,i),this.footer=this.getFooter(n,i),this._size=Vn(this,i)),o=Object.assign({},t,e),r=Bn(this.chart,i,o),l=Wn(i,o,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,a={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(a={opacity:0});this._tooltipItems=n,this.$context=void 0,a&&this._resolveAnimations().update(this,a),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){t=this.getCaretPosition(t,i,s);e.lineTo(t.x1,t.y1),e.lineTo(t.x2,t.y2),e.lineTo(t.x3,t.y3)}getCaretPosition(t,e,i){var{xAlign:s,yAlign:a}=this,{caretSize:i,cornerRadius:n}=i,{topLeft:n,topRight:o,bottomLeft:r,bottomRight:l}=wi(n),{x:t,y:h}=t,{width:e,height:c}=e;let d,u,g,f,p,m;return"center"===a?(p=h+c/2,m="left"===s?(d=t,u=d-i,f=p+i,p-i):(d=t+e,u=d+i,f=p-i,p+i),g=d):(u="left"===s?t+Math.max(n,r)+i:"right"===s?t+e-Math.max(o,l)-i:this.caretX,g="top"===a?(f=h,p=f-i,d=u-i,u+i):(f=h+c,p=f+i,d=u+i,u-i),m=f),{x1:d,x2:u,x3:g,y1:f,y2:p,y3:m}}drawTitle(t,e,i){var s=this.title,a=s.length;let n,o,r;if(a){const l=Di(i.rtl,this.x,this.width);for(t.x=Nn(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",n=z(i.titleFont),o=i.titleSpacing,e.fillStyle=i.titleColor,e.font=n.string,r=0;r0!==t)?(t.beginPath(),t.fillStyle=a.multiKeyBackground,je(t,{x:e,y:g,w:l,h:r,radius:o}),t.fill(),t.stroke(),t.fillStyle=n.backgroundColor,t.beginPath(),je(t,{x:i,y:g+1,w:l-2,h:r-2,radius:o}),t.fill()):(t.fillStyle=a.multiKeyBackground,t.fillRect(e,g,l,r),t.strokeRect(e,g,l,r),t.fillStyle=n.backgroundColor,t.fillRect(i,g+1,l-2,r-2))}t.fillStyle=this.labelTextColors[i]}drawBody(e,i,t){const s=this["body"],{bodySpacing:a,bodyAlign:n,displayColors:o,boxHeight:r,boxWidth:l,boxPadding:h}=t,c=z(t.bodyFont);let d=c.lineHeight,u=0;function g(t){i.fillText(t,f.x(e.x+u),e.y+d/2),e.y+=d+a}const f=Di(t.rtl,this.x,this.width),p=f.textAlign(n);let m,b,x,v,_,y,M;for(i.textAlign=n,i.textBaseline="middle",i.font=c.string,e.x=Nn(this,p,t),i.fillStyle=t.bodyColor,k(this.beforeBody,g),u=o&&"right"!==p?"center"===n?l/2+h:l+2+h:0,v=0,y=s.length;v{var i=this.chart.getDatasetMeta(t);if(i)return{datasetIndex:t,element:i.data[e],index:e};throw new Error("Cannot find a dataset at index "+t)}),i=!W(i,t),s=this._positionChanged(t,e);(i||s)&&(this._active=t,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;var s=this.options,a=this._active||[],i=this._getActiveElements(t,a,e,i),n=this._positionChanged(i,t),a=e||!W(i,a)||n;return a&&(this._active=i,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),a}_getActiveElements(t,e,i,s){var a=this.options;if("mouseout"===t.type)return[];if(!s)return e;const n=this.chart.getElementsAtEventForMode(t,a.mode,a,i);return a.reverse&&n.reverse(),n}_positionChanged(t,e){var{caretX:i,caretY:s,options:a}=this,a=zn[a.position].call(this,t,e);return!1!==a&&(i!==a.x||s!==a.y)}}var Un={id:"tooltip",_element:$n,positioners:zn,afterInit(t,e,i){i&&(t.tooltip=new $n({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;var i;e&&e._willRender()&&(!(i={tooltip:e})!==t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0})&&(e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)))},afterEvent(t,e){var i;t.tooltip&&(i=e.replay,t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0))},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Yn},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return i.register(ba,an,Ra,s),i.helpers={...Fi},i._adapters=ha,i.Animation=bs,i.Animations=xs,i.animator=l,i.controllers=b.controllers.items,i.DatasetController=Os,i.Element=e,i.elements=Ra,i.Interaction=Hi,i.layouts=a,i.platforms=Oe,i.Scale=zs,i.Ticks=ge,Object.assign(i,ba,an,Ra,s,Oe),i.Chart=i,"undefined"!=typeof window&&(window.Chart=i),i}); \ No newline at end of file