Aaron a révisé ce gist . Aller à la révision
3 files changed, 900 insertions
network-fluctuation.js(fichier créé)
| @@ -0,0 +1,96 @@ | |||
| 1 | + | const selectorButton = | |
| 2 | + | "#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > section > div.flex.justify-center.w-full.max-w-\\[200px\\] > div > div > div.relative.cursor-pointer.rounded-3xl.px-2\\.5.py-\\[8px\\].text-\\[13px\\].font-\\[600\\].transition-all.duration-500.text-stone-400.dark\\:text-stone-500"; | |
| 3 | + | const selectorSection = | |
| 4 | + | "#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > section"; | |
| 5 | + | const selector3 = | |
| 6 | + | "#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > div:nth-child(3)"; | |
| 7 | + | const selector4 = | |
| 8 | + | "#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > div:nth-child(4)"; | |
| 9 | + | ||
| 10 | + | let hasClicked = false; | |
| 11 | + | let divVisible = false; | |
| 12 | + | let swapping = false; | |
| 13 | + | ||
| 14 | + | function forceBothVisible() { | |
| 15 | + | const div3 = document.querySelector(selector3); | |
| 16 | + | const div4 = document.querySelector(selector4); | |
| 17 | + | if (div3 && div4) { | |
| 18 | + | div3.style.display = "block"; | |
| 19 | + | div4.style.display = "block"; | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | function hideSection() { | |
| 24 | + | const section = document.querySelector(selectorSection); | |
| 25 | + | if (section) { | |
| 26 | + | section.style.display = "none"; | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function tryClickButton() { | |
| 31 | + | const btn = document.querySelector(selectorButton); | |
| 32 | + | if (btn && !hasClicked) { | |
| 33 | + | btn.click(); | |
| 34 | + | hasClicked = true; | |
| 35 | + | setTimeout(forceBothVisible, 500); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | function swapDiv3AndDiv4() { | |
| 40 | + | if (swapping) return; | |
| 41 | + | swapping = true; | |
| 42 | + | ||
| 43 | + | const div3 = document.querySelector(selector3); | |
| 44 | + | const div4 = document.querySelector(selector4); | |
| 45 | + | if (!div3 || !div4) { | |
| 46 | + | swapping = false; | |
| 47 | + | return; | |
| 48 | + | } | |
| 49 | + | const parent = div3.parentNode; | |
| 50 | + | if (parent !== div4.parentNode) { | |
| 51 | + | swapping = false; | |
| 52 | + | return; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // 交换 div3 和 div4 的位置 | |
| 56 | + | parent.insertBefore(div4, div3); | |
| 57 | + | parent.insertBefore(div3, div4.nextSibling); | |
| 58 | + | ||
| 59 | + | swapping = false; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | const observer = new MutationObserver(() => { | |
| 63 | + | const div3 = document.querySelector(selector3); | |
| 64 | + | const div4 = document.querySelector(selector4); | |
| 65 | + | ||
| 66 | + | const isDiv3Visible = div3 && getComputedStyle(div3).display !== "none"; | |
| 67 | + | const isDiv4Visible = div4 && getComputedStyle(div4).display !== "none"; | |
| 68 | + | ||
| 69 | + | const isAnyDivVisible = isDiv3Visible || isDiv4Visible; | |
| 70 | + | ||
| 71 | + | if (isAnyDivVisible && !divVisible) { | |
| 72 | + | hideSection(); | |
| 73 | + | tryClickButton(); | |
| 74 | + | setTimeout(swapDiv3AndDiv4, 100); | |
| 75 | + | } else if (!isAnyDivVisible && divVisible) { | |
| 76 | + | hasClicked = false; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | divVisible = isAnyDivVisible; | |
| 80 | + | ||
| 81 | + | if (div3 && div4) { | |
| 82 | + | if (!isDiv3Visible || !isDiv4Visible) { | |
| 83 | + | forceBothVisible(); | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | const root = document.querySelector("#root"); | |
| 89 | + | if (root) { | |
| 90 | + | observer.observe(root, { | |
| 91 | + | childList: true, | |
| 92 | + | attributes: true, | |
| 93 | + | subtree: true, | |
| 94 | + | attributeFilter: ["style", "class"], | |
| 95 | + | }); | |
| 96 | + | } | |
style.css(fichier créé)
| @@ -0,0 +1,245 @@ | |||
| 1 | + | /* 默认和浅色主题的CSS变量 */ | |
| 2 | + | :root, | |
| 3 | + | html.light { | |
| 4 | + | --custom-border-color: rgba(244, 244, 244, 0.382); | |
| 5 | + | --custom-background-color: rgba(244, 244, 244, 0.618); | |
| 6 | + | --custom-text-color: #333; | |
| 7 | + | --custom-shadow-color: rgba(244, 244, 244, 0.5); | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | /* 暗色主题的CSS变量 */ | |
| 11 | + | html.dark { | |
| 12 | + | --custom-border-color: rgba(11, 11, 11, 0.382); | |
| 13 | + | --custom-background-color: rgba(11, 11, 11, 0.618); | |
| 14 | + | --custom-text-color: #fff; | |
| 15 | + | --custom-shadow-color: rgba(11, 11, 11, 0.5); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | html.dark #root { | |
| 19 | + | background-color: unset !important; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /* 背景图片压暗和模糊, 开了背景图建议开启 */ | |
| 23 | + | .dark .bg-cover::after { | |
| 24 | + | content: ""; | |
| 25 | + | position: absolute; | |
| 26 | + | inset: 0; | |
| 27 | + | backdrop-filter: blur(6px); | |
| 28 | + | background-color: rgba(0, 0, 0, 0.3); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .light .bg-cover::after { | |
| 32 | + | content: ""; | |
| 33 | + | position: absolute; | |
| 34 | + | inset: 0; | |
| 35 | + | backdrop-filter: blur(3px); | |
| 36 | + | background-color: rgba(255, 255, 255, 0.3); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | .bg-card, | |
| 40 | + | html.dark .bg-card, | |
| 41 | + | html.light .bg-card { | |
| 42 | + | background-color: var(--custom-background-color); | |
| 43 | + | backdrop-filter: blur(4px); | |
| 44 | + | border: 1px solid var(--custom-border-color); | |
| 45 | + | box-shadow: 0 4px 6px var(--custom-shadow-color); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | .focus\:text-accent-foreground:focus { | |
| 49 | + | background-color: var(--custom-background-color); | |
| 50 | + | opacity: 0.8; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | .text-muted-foreground, | |
| 54 | + | html.dark .text-muted-foreground, | |
| 55 | + | html.light .text-muted-foreground { | |
| 56 | + | color: var(--custom-text-color); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | html.dark .dark\:bg-stone-700, | |
| 60 | + | html.light .bg-stone-700, | |
| 61 | + | .bg-stone-700 { | |
| 62 | + | --tw-bg-opacity: 0.5; | |
| 63 | + | background-color: var(--custom-background-color); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | html *, | |
| 67 | + | html.dark *, | |
| 68 | + | html.light * { | |
| 69 | + | border-color: var(--custom-border-color); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | html body, | |
| 73 | + | html.dark body, | |
| 74 | + | html.light body { | |
| 75 | + | color: var(--custom-text-color); | |
| 76 | + | background: unset; | |
| 77 | + | position: relative; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | img { | |
| 81 | + | border: none; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | html.dark .dark\:border-neutral-800, | |
| 85 | + | html.light .border-neutral-800, | |
| 86 | + | .border-neutral-800 { | |
| 87 | + | border-color: var(--custom-border-color); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | html.dark .dark\:bg-stone-800, | |
| 91 | + | html.light .bg-stone-800, | |
| 92 | + | .bg-stone-800 { | |
| 93 | + | --tw-bg-opacity: 0.5; | |
| 94 | + | background-color: var(--custom-background-color); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | html.dark .dark\:text-stone-500, | |
| 98 | + | html.light .text-stone-500, | |
| 99 | + | .text-stone-500 { | |
| 100 | + | color: var(--custom-text-color); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | .bg-secondary, | |
| 104 | + | html.dark .bg-secondary, | |
| 105 | + | html.light .bg-secondary { | |
| 106 | + | background-color: var(--custom-background-color); | |
| 107 | + | opacity: 0.7; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | .bg-popover, | |
| 111 | + | html.dark .bg-popover, | |
| 112 | + | html.light .bg-popover { | |
| 113 | + | background-color: var(--custom-background-color); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | .bg-muted, | |
| 117 | + | html.dark .bg-muted, | |
| 118 | + | html.light .bg-muted { | |
| 119 | + | background-color: var(--custom-border-color); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | html.dark .dark\:bg-black, | |
| 123 | + | html.light .bg-black, | |
| 124 | + | .bg-black { | |
| 125 | + | background-color: var(--custom-border-color); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | .bg-border, | |
| 129 | + | html.dark .bg-border, | |
| 130 | + | html.light .bg-border { | |
| 131 | + | background-color: var(--custom-border-color); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | .border-input, | |
| 135 | + | html.dark .border-input, | |
| 136 | + | html.light .border-input { | |
| 137 | + | border-color: var(--custom-border-color); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | html.dark .dark\:text-neutral-300\/50, | |
| 141 | + | html.light .text-neutral-300, | |
| 142 | + | .text-neutral-300 { | |
| 143 | + | color: var(--custom-text-color); | |
| 144 | + | opacity: 0.7; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | html.dark .dark\:text-stone-400, | |
| 148 | + | html.light .text-stone-400, | |
| 149 | + | .text-stone-400 { | |
| 150 | + | color: var(--custom-text-color); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | div#radix-\:r4\: { | |
| 154 | + | background: var(--custom-background-color); | |
| 155 | + | backdrop-filter: blur(4px); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | .text-green-600 { | |
| 159 | + | color: rgb(34, 197, 94); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | .bg-green-600 { | |
| 163 | + | background-color: rgb(34, 197, 94); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | .vps-info, | |
| 167 | + | html.dark .vps-info, | |
| 168 | + | html.light .vps-info { | |
| 169 | + | border-radius: 12px; | |
| 170 | + | padding: 12px; | |
| 171 | + | background-color: var(--custom-background-color); | |
| 172 | + | backdrop-filter: blur(4px); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | .font-medium.opacity-40, | |
| 176 | + | html.dark .font-medium.opacity-40, | |
| 177 | + | html.light .font-medium.opacity-40 { | |
| 178 | + | opacity: 0.8 !important; | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | .font-medium.opacity-50, | |
| 182 | + | html.dark .font-medium.opacity-50, | |
| 183 | + | html.light .font-medium.opacity-50 { | |
| 184 | + | opacity: 0.8 !important; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | .max-w-5xl.gap-4 > div:first-child, | |
| 188 | + | html.dark .max-w-5xl.gap-4 > div:first-child, | |
| 189 | + | html.light .max-w-5xl.gap-4 > div:first-child { | |
| 190 | + | background-color: var(--custom-background-color); | |
| 191 | + | backdrop-filter: blur(4px); | |
| 192 | + | border: 1px solid var(--custom-border-color); | |
| 193 | + | box-shadow: 0 4px 6px var(--custom-shadow-color); | |
| 194 | + | border-radius: 12px; | |
| 195 | + | padding: 12px; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | img[alt="BackIcon"] { | |
| 199 | + | margin-right: 12px; | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | .flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\], | |
| 203 | + | html.dark .flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\], | |
| 204 | + | html.light .flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\], | |
| 205 | + | html.dark .dark\:bg-stone-800 { | |
| 206 | + | background-color: var(--custom-background-color); | |
| 207 | + | backdrop-filter: blur(4px); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | .\[\&_\.recharts-cartesian-axis-tick_text\]\:fill-muted-foreground | |
| 211 | + | .recharts-cartesian-axis-tick | |
| 212 | + | text { | |
| 213 | + | fill: var(--custom-text-color); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | html.dark .dark\:fill-neutral-800, | |
| 217 | + | html.light .fill-neutral-800, | |
| 218 | + | .fill-neutral-800 { | |
| 219 | + | fill: var(--custom-text-color); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | .data-\[state\=unchecked\]\:bg-input[data-state="unchecked"] { | |
| 223 | + | background-color: var(--custom-background-color); | |
| 224 | + | backdrop-filter: blur(4px); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | .data-\[state\=checked\]\:bg-primary[data-state="checked"] { | |
| 228 | + | background-color: var(--custom-background-color); | |
| 229 | + | backdrop-filter: blur(4px); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | .rounded-lg { | |
| 233 | + | background-color: var(--custom-background-color) !important; | |
| 234 | + | backdrop-filter: blur(5px) !important; | |
| 235 | + | border-radius: 15px !important; | |
| 236 | + | border: 1px solid var(--custom-border-color) !important; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | html.dark .dark\:bg-black\/70, | |
| 240 | + | html.light .bg-black\/70, | |
| 241 | + | .bg-black\/70 { | |
| 242 | + | background-color: var(--custom-background-color) !important; | |
| 243 | + | backdrop-filter: blur(5px) !important; | |
| 244 | + | border: 1px solid var(--custom-border-color) !important; | |
| 245 | + | } | |
traffic-progress-bar.js(fichier créé)
| @@ -0,0 +1,559 @@ | |||
| 1 | + | const SCRIPT_VERSION = "v20250617"; | |
| 2 | + | // == 样式注入模块 == | |
| 3 | + | // 注入自定义CSS隐藏特定元素 | |
| 4 | + | function injectCustomCSS() { | |
| 5 | + | const style = document.createElement("style"); | |
| 6 | + | style.textContent = ` | |
| 7 | + | /* 隐藏父级类名为 mt-4 w-full mx-auto 下的所有 div */ | |
| 8 | + | .mt-4.w-full.mx-auto > div { | |
| 9 | + | display: none; | |
| 10 | + | } | |
| 11 | + | `; | |
| 12 | + | document.head.appendChild(style); | |
| 13 | + | } | |
| 14 | + | injectCustomCSS(); | |
| 15 | + | ||
| 16 | + | // == 工具函数模块 == | |
| 17 | + | const utils = (() => { | |
| 18 | + | /** | |
| 19 | + | * 格式化文件大小,自动转换单位 | |
| 20 | + | * @param {number} bytes - 字节数 | |
| 21 | + | * @returns {{value: string, unit: string}} 格式化后的数值和单位 | |
| 22 | + | */ | |
| 23 | + | function formatFileSize(bytes) { | |
| 24 | + | if (bytes === 0) return { value: "0", unit: "B" }; | |
| 25 | + | const units = ["B", "KB", "MB", "GB", "TB", "PB"]; | |
| 26 | + | let size = bytes; | |
| 27 | + | let unitIndex = 0; | |
| 28 | + | while (size >= 1024 && unitIndex < units.length - 1) { | |
| 29 | + | size /= 1024; | |
| 30 | + | unitIndex++; | |
| 31 | + | } | |
| 32 | + | return { | |
| 33 | + | value: size.toFixed(unitIndex === 0 ? 0 : 2), | |
| 34 | + | unit: units[unitIndex], | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * 计算百分比,输入可为大数,支持自动缩放 | |
| 40 | + | * @param {number} used - 已使用量 | |
| 41 | + | * @param {number} total - 总量 | |
| 42 | + | * @returns {string} 百分比字符串,保留2位小数 | |
| 43 | + | */ | |
| 44 | + | function calculatePercentage(used, total) { | |
| 45 | + | used = Number(used); | |
| 46 | + | total = Number(total); | |
| 47 | + | // 大数缩放,防止数值溢出 | |
| 48 | + | if (used > 1e15 || total > 1e15) { | |
| 49 | + | used /= 1e10; | |
| 50 | + | total /= 1e10; | |
| 51 | + | } | |
| 52 | + | return total === 0 ? "0.00" : ((used / total) * 100).toFixed(2); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * 格式化日期字符串,返回 yyyy-MM-dd 格式 | |
| 57 | + | * @param {string} dateString - 日期字符串 | |
| 58 | + | * @returns {string} 格式化日期 | |
| 59 | + | */ | |
| 60 | + | function formatDate(dateString) { | |
| 61 | + | const date = new Date(dateString); | |
| 62 | + | if (isNaN(date)) return ""; | |
| 63 | + | return date.toLocaleDateString("zh-CN", { | |
| 64 | + | year: "numeric", | |
| 65 | + | month: "2-digit", | |
| 66 | + | day: "2-digit", | |
| 67 | + | }); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** | |
| 71 | + | * 安全设置子元素文本内容,避免空引用错误 | |
| 72 | + | * @param {HTMLElement} parent - 父元素 | |
| 73 | + | * @param {string} selector - 子元素选择器 | |
| 74 | + | * @param {string} text - 要设置的文本 | |
| 75 | + | */ | |
| 76 | + | function safeSetTextContent(parent, selector, text) { | |
| 77 | + | const el = parent.querySelector(selector); | |
| 78 | + | if (el) el.textContent = text; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** | |
| 82 | + | * 根据百分比返回渐变HSL颜色(绿→橙→红) | |
| 83 | + | * @param {number} percentage - 0~100的百分比 | |
| 84 | + | * @returns {string} hsl颜色字符串 | |
| 85 | + | */ | |
| 86 | + | function getHslGradientColor(percentage) { | |
| 87 | + | const clamp = (val, min, max) => Math.min(Math.max(val, min), max); | |
| 88 | + | const lerp = (start, end, t) => start + (end - start) * t; | |
| 89 | + | const p = clamp(Number(percentage), 0, 100); | |
| 90 | + | let h, s, l; | |
| 91 | + | ||
| 92 | + | if (p <= 35) { | |
| 93 | + | const t = p / 35; | |
| 94 | + | h = lerp(142, 32, t); // 绿色到橙色 | |
| 95 | + | s = lerp(69, 85, t); | |
| 96 | + | l = lerp(45, 55, t); | |
| 97 | + | } else if (p <= 85) { | |
| 98 | + | const t = (p - 35) / 50; | |
| 99 | + | h = lerp(32, 0, t); // 橙色到红色 | |
| 100 | + | s = lerp(85, 75, t); | |
| 101 | + | l = lerp(55, 50, t); | |
| 102 | + | } else { | |
| 103 | + | const t = (p - 85) / 15; | |
| 104 | + | h = 0; // 红色加深 | |
| 105 | + | s = 75; | |
| 106 | + | l = lerp(50, 45, t); | |
| 107 | + | } | |
| 108 | + | return `hsl(${h.toFixed(0)}, ${s.toFixed(0)}%, ${l.toFixed(0)}%)`; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** | |
| 112 | + | * 透明度渐隐渐现切换内容 | |
| 113 | + | * @param {HTMLElement} element - 目标元素 | |
| 114 | + | * @param {string} newContent - 新HTML内容 | |
| 115 | + | * @param {number} duration - 动画持续时间,毫秒 | |
| 116 | + | */ | |
| 117 | + | function fadeOutIn(element, newContent, duration = 500) { | |
| 118 | + | element.style.transition = `opacity ${duration / 2}ms`; | |
| 119 | + | element.style.opacity = "0"; | |
| 120 | + | setTimeout(() => { | |
| 121 | + | element.innerHTML = newContent; | |
| 122 | + | element.style.transition = `opacity ${duration / 2}ms`; | |
| 123 | + | element.style.opacity = "1"; | |
| 124 | + | }, duration / 2); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | return { | |
| 128 | + | formatFileSize, | |
| 129 | + | calculatePercentage, | |
| 130 | + | formatDate, | |
| 131 | + | safeSetTextContent, | |
| 132 | + | getHslGradientColor, | |
| 133 | + | fadeOutIn, | |
| 134 | + | }; | |
| 135 | + | })(); | |
| 136 | + | ||
| 137 | + | // == 流量统计渲染模块 == | |
| 138 | + | const trafficRenderer = (() => { | |
| 139 | + | const toggleElements = []; // 存储需周期切换显示的元素及其内容 | |
| 140 | + | ||
| 141 | + | /** | |
| 142 | + | * 渲染流量统计条目 | |
| 143 | + | * @param {Object} trafficData - 后台返回的流量数据 | |
| 144 | + | * @param {Object} config - 配置项 | |
| 145 | + | */ | |
| 146 | + | function renderTrafficStats(trafficData, config) { | |
| 147 | + | const serverMap = new Map(); | |
| 148 | + | ||
| 149 | + | // 解析流量数据,按服务器名聚合 | |
| 150 | + | for (const cycleId in trafficData) { | |
| 151 | + | const cycle = trafficData[cycleId]; | |
| 152 | + | if (!cycle.server_name || !cycle.transfer) continue; | |
| 153 | + | for (const serverId in cycle.server_name) { | |
| 154 | + | const serverName = cycle.server_name[serverId]; | |
| 155 | + | const transfer = cycle.transfer[serverId]; | |
| 156 | + | const max = cycle.max; | |
| 157 | + | const from = cycle.from; | |
| 158 | + | const to = cycle.to; | |
| 159 | + | const next_update = cycle.next_update[serverId]; | |
| 160 | + | if (serverName && transfer !== undefined && max && from && to) { | |
| 161 | + | serverMap.set(serverName, { | |
| 162 | + | id: serverId, | |
| 163 | + | transfer, | |
| 164 | + | max, | |
| 165 | + | name: cycle.name, | |
| 166 | + | from, | |
| 167 | + | to, | |
| 168 | + | next_update, | |
| 169 | + | }); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | serverMap.forEach((serverData, serverName) => { | |
| 175 | + | // 查找对应显示区域 | |
| 176 | + | const targetElement = Array.from( | |
| 177 | + | document.querySelectorAll("section.grid.items-center.gap-2"), | |
| 178 | + | ).find((section) => { | |
| 179 | + | const firstText = section.querySelector("p")?.textContent.trim(); | |
| 180 | + | return firstText === serverName.trim(); | |
| 181 | + | }); | |
| 182 | + | if (!targetElement) return; | |
| 183 | + | ||
| 184 | + | // 格式化数据 | |
| 185 | + | const usedFormatted = utils.formatFileSize(serverData.transfer); | |
| 186 | + | const totalFormatted = utils.formatFileSize(serverData.max); | |
| 187 | + | const percentage = utils.calculatePercentage( | |
| 188 | + | serverData.transfer, | |
| 189 | + | serverData.max, | |
| 190 | + | ); | |
| 191 | + | const fromFormatted = utils.formatDate(serverData.from); | |
| 192 | + | const toFormatted = utils.formatDate(serverData.to); | |
| 193 | + | const nextUpdateFormatted = new Date( | |
| 194 | + | serverData.next_update, | |
| 195 | + | ).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); | |
| 196 | + | const uniqueClassName = "traffic-stats-for-server-" + serverData.id; | |
| 197 | + | const progressColor = utils.getHslGradientColor(percentage); | |
| 198 | + | const containerDiv = targetElement.closest("div"); | |
| 199 | + | if (!containerDiv) return; | |
| 200 | + | ||
| 201 | + | // 日志输出函数 | |
| 202 | + | const log = (...args) => { | |
| 203 | + | if (config.enableLog) console.log("[renderTrafficStats]", ...args); | |
| 204 | + | }; | |
| 205 | + | ||
| 206 | + | // 查找是否已有对应流量条目元素 | |
| 207 | + | const existing = Array.from( | |
| 208 | + | containerDiv.querySelectorAll(".new-inserted-element"), | |
| 209 | + | ).find((el) => el.classList.contains(uniqueClassName)); | |
| 210 | + | ||
| 211 | + | if (!config.showTrafficStats) { | |
| 212 | + | // 不显示时移除对应元素 | |
| 213 | + | if (existing) { | |
| 214 | + | existing.remove(); | |
| 215 | + | log(`移除流量条目: ${serverName}`); | |
| 216 | + | } | |
| 217 | + | return; | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | if (existing) { | |
| 221 | + | // 更新已存在元素内容 | |
| 222 | + | utils.safeSetTextContent( | |
| 223 | + | existing, | |
| 224 | + | ".used-traffic", | |
| 225 | + | usedFormatted.value, | |
| 226 | + | ); | |
| 227 | + | utils.safeSetTextContent(existing, ".used-unit", usedFormatted.unit); | |
| 228 | + | utils.safeSetTextContent( | |
| 229 | + | existing, | |
| 230 | + | ".total-traffic", | |
| 231 | + | totalFormatted.value, | |
| 232 | + | ); | |
| 233 | + | utils.safeSetTextContent(existing, ".total-unit", totalFormatted.unit); | |
| 234 | + | utils.safeSetTextContent(existing, ".from-date", fromFormatted); | |
| 235 | + | utils.safeSetTextContent(existing, ".to-date", toFormatted); | |
| 236 | + | utils.safeSetTextContent( | |
| 237 | + | existing, | |
| 238 | + | ".percentage-value", | |
| 239 | + | percentage + "%", | |
| 240 | + | ); | |
| 241 | + | utils.safeSetTextContent( | |
| 242 | + | existing, | |
| 243 | + | ".next-update", | |
| 244 | + | `next update: ${nextUpdateFormatted}`, | |
| 245 | + | ); | |
| 246 | + | ||
| 247 | + | const progressBar = existing.querySelector(".progress-bar"); | |
| 248 | + | if (progressBar) { | |
| 249 | + | progressBar.style.width = percentage + "%"; | |
| 250 | + | progressBar.style.backgroundColor = progressColor; | |
| 251 | + | } | |
| 252 | + | log(`更新流量条目: ${serverName}`); | |
| 253 | + | } else { | |
| 254 | + | // 插入新的流量条目元素 | |
| 255 | + | let oldSection = null; | |
| 256 | + | if (config.insertAfter) { | |
| 257 | + | oldSection = | |
| 258 | + | containerDiv.querySelector( | |
| 259 | + | "section.flex.items-center.w-full.justify-between.gap-1", | |
| 260 | + | ) || containerDiv.querySelector("section.grid.items-center.gap-3"); | |
| 261 | + | } else { | |
| 262 | + | oldSection = containerDiv.querySelector( | |
| 263 | + | "section.grid.items-center.gap-3", | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | if (!oldSection) return; | |
| 267 | + | ||
| 268 | + | // 时间区间内容,用于切换显示 | |
| 269 | + | const defaultTimeInfoHTML = `<span class="from-date">${fromFormatted}</span> | |
| 270 | + | <span class="text-neutral-500 dark:text-neutral-400">-</span> | |
| 271 | + | <span class="to-date">${toFormatted}</span>`; | |
| 272 | + | const contents = [ | |
| 273 | + | defaultTimeInfoHTML, | |
| 274 | + | `<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 percentage-value">${percentage}%</span>`, | |
| 275 | + | `<span class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300">${nextUpdateFormatted}</span>`, | |
| 276 | + | ]; | |
| 277 | + | ||
| 278 | + | const newElement = document.createElement("div"); | |
| 279 | + | newElement.classList.add( | |
| 280 | + | "space-y-1.5", | |
| 281 | + | "new-inserted-element", | |
| 282 | + | uniqueClassName, | |
| 283 | + | ); | |
| 284 | + | newElement.style.width = "100%"; | |
| 285 | + | newElement.innerHTML = ` | |
| 286 | + | <div class="flex items-center justify-between"> | |
| 287 | + | <div class="flex items-baseline gap-1"> | |
| 288 | + | <span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-traffic">${usedFormatted.value}</span> | |
| 289 | + | <span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-unit">${usedFormatted.unit}</span> | |
| 290 | + | <span class="text-[10px] text-neutral-500 dark:text-neutral-400">/ </span> | |
| 291 | + | <span class="text-[10px] text-neutral-500 dark:text-neutral-400 total-traffic">${totalFormatted.value}</span> | |
| 292 | + | <span class="text-[10px] text-neutral-500 dark:text-neutral-400 total-unit">${totalFormatted.unit}</span> | |
| 293 | + | </div> | |
| 294 | + | <div class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300 time-info" style="opacity:1; transition: opacity 0.3s;"> | |
| 295 | + | ${defaultTimeInfoHTML} | |
| 296 | + | </div> | |
| 297 | + | </div> | |
| 298 | + | <div class="relative h-1.5"> | |
| 299 | + | <div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800 rounded-full"></div> | |
| 300 | + | <div class="absolute inset-0 bg-emerald-500 rounded-full transition-all duration-300 progress-bar" style="width: ${percentage}%; max-width: 100%; background-color: ${progressColor};"></div> | |
| 301 | + | </div> | |
| 302 | + | `; | |
| 303 | + | ||
| 304 | + | oldSection.after(newElement); | |
| 305 | + | log(`插入新流量条目: ${serverName}`); | |
| 306 | + | ||
| 307 | + | // 启用切换时,将元素及其内容保存以便周期切换 | |
| 308 | + | if (config.toggleInterval > 0) { | |
| 309 | + | const timeInfoElement = newElement.querySelector(".time-info"); | |
| 310 | + | if (timeInfoElement) { | |
| 311 | + | toggleElements.push({ | |
| 312 | + | el: timeInfoElement, | |
| 313 | + | contents, | |
| 314 | + | }); | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | }); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /** | |
| 322 | + | * 启动周期切换内容显示(用于时间、百分比等轮播) | |
| 323 | + | * @param {number} toggleInterval - 切换间隔,毫秒 | |
| 324 | + | * @param {number} duration - 动画时长,毫秒 | |
| 325 | + | */ | |
| 326 | + | function startToggleCycle(toggleInterval, duration) { | |
| 327 | + | if (toggleInterval <= 0) return; | |
| 328 | + | let toggleIndex = 0; | |
| 329 | + | ||
| 330 | + | setInterval(() => { | |
| 331 | + | toggleIndex++; | |
| 332 | + | toggleElements.forEach(({ el, contents }) => { | |
| 333 | + | if (!document.body.contains(el)) return; | |
| 334 | + | const index = toggleIndex % contents.length; | |
| 335 | + | utils.fadeOutIn(el, contents[index], duration); | |
| 336 | + | }); | |
| 337 | + | }, toggleInterval); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | return { | |
| 341 | + | renderTrafficStats, | |
| 342 | + | startToggleCycle, | |
| 343 | + | }; | |
| 344 | + | })(); | |
| 345 | + | ||
| 346 | + | // == 数据请求和缓存模块 == | |
| 347 | + | const trafficDataManager = (() => { | |
| 348 | + | let trafficCache = null; | |
| 349 | + | ||
| 350 | + | /** | |
| 351 | + | * 请求流量数据,支持缓存 | |
| 352 | + | * @param {string} apiUrl - 接口地址 | |
| 353 | + | * @param {Object} config - 配置项 | |
| 354 | + | * @param {Function} callback - 请求成功后的回调,参数为流量数据 | |
| 355 | + | */ | |
| 356 | + | function fetchTrafficData(apiUrl, config, callback) { | |
| 357 | + | const now = Date.now(); | |
| 358 | + | // 使用缓存数据 | |
| 359 | + | if (trafficCache && now - trafficCache.timestamp < config.interval) { | |
| 360 | + | if (config.enableLog) console.log("[fetchTrafficData] 使用缓存数据"); | |
| 361 | + | callback(trafficCache.data); | |
| 362 | + | return; | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | if (config.enableLog) console.log("[fetchTrafficData] 请求新数据..."); | |
| 366 | + | fetch(apiUrl) | |
| 367 | + | .then((res) => res.json()) | |
| 368 | + | .then((data) => { | |
| 369 | + | if (!data.success) { | |
| 370 | + | if (config.enableLog) | |
| 371 | + | console.warn("[fetchTrafficData] 请求成功但数据异常"); | |
| 372 | + | return; | |
| 373 | + | } | |
| 374 | + | if (config.enableLog) console.log("[fetchTrafficData] 成功获取新数据"); | |
| 375 | + | const trafficData = data.data.cycle_transfer_stats; | |
| 376 | + | trafficCache = { | |
| 377 | + | timestamp: now, | |
| 378 | + | data: trafficData, | |
| 379 | + | }; | |
| 380 | + | callback(trafficData); | |
| 381 | + | }) | |
| 382 | + | .catch((err) => { | |
| 383 | + | if (config.enableLog) | |
| 384 | + | console.error("[fetchTrafficData] 请求失败:", err); | |
| 385 | + | }); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | return { | |
| 389 | + | fetchTrafficData, | |
| 390 | + | }; | |
| 391 | + | })(); | |
| 392 | + | ||
| 393 | + | // == DOM变化监听模块 == | |
| 394 | + | const domObserver = (() => { | |
| 395 | + | const TARGET_SELECTOR = | |
| 396 | + | "section.server-card-list, section.server-inline-list"; | |
| 397 | + | let currentSection = null; | |
| 398 | + | let childObserver = null; | |
| 399 | + | ||
| 400 | + | /** | |
| 401 | + | * DOM 子节点变更回调,调用传入的函数 | |
| 402 | + | * @param {Function} onChangeCallback - 变更处理函数 | |
| 403 | + | */ | |
| 404 | + | function onDomChildListChange(onChangeCallback) { | |
| 405 | + | onChangeCallback(); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /** | |
| 409 | + | * 监听指定section子节点变化 | |
| 410 | + | * @param {HTMLElement} section - 目标section元素 | |
| 411 | + | * @param {Function} onChangeCallback - 变更处理函数 | |
| 412 | + | */ | |
| 413 | + | function observeSection(section, onChangeCallback) { | |
| 414 | + | if (childObserver) { | |
| 415 | + | childObserver.disconnect(); | |
| 416 | + | } | |
| 417 | + | currentSection = section; | |
| 418 | + | childObserver = new MutationObserver((mutations) => { | |
| 419 | + | for (const m of mutations) { | |
| 420 | + | if ( | |
| 421 | + | m.type === "childList" && | |
| 422 | + | (m.addedNodes.length || m.removedNodes.length) | |
| 423 | + | ) { | |
| 424 | + | onDomChildListChange(onChangeCallback); | |
| 425 | + | break; | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | }); | |
| 429 | + | childObserver.observe(currentSection, { childList: true, subtree: false }); | |
| 430 | + | // 初始调用一次 | |
| 431 | + | onChangeCallback(); | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | /** | |
| 435 | + | * 启动顶层section监听,检测section切换 | |
| 436 | + | * @param {Function} onChangeCallback - section变化时回调 | |
| 437 | + | * @returns {MutationObserver} sectionDetector实例 | |
| 438 | + | */ | |
| 439 | + | function startSectionDetector(onChangeCallback) { | |
| 440 | + | const sectionDetector = new MutationObserver(() => { | |
| 441 | + | const section = document.querySelector(TARGET_SELECTOR); | |
| 442 | + | if (section && section !== currentSection) { | |
| 443 | + | observeSection(section, onChangeCallback); | |
| 444 | + | } | |
| 445 | + | }); | |
| 446 | + | const root = document.querySelector("main") || document.body; | |
| 447 | + | sectionDetector.observe(root, { childList: true, subtree: true }); | |
| 448 | + | return sectionDetector; | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | /** | |
| 452 | + | * 断开所有监听 | |
| 453 | + | * @param {MutationObserver} sectionDetector - 顶层section监听实例 | |
| 454 | + | */ | |
| 455 | + | function disconnectAll(sectionDetector) { | |
| 456 | + | if (childObserver) childObserver.disconnect(); | |
| 457 | + | if (sectionDetector) sectionDetector.disconnect(); | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | return { | |
| 461 | + | startSectionDetector, | |
| 462 | + | disconnectAll, | |
| 463 | + | }; | |
| 464 | + | })(); | |
| 465 | + | ||
| 466 | + | // == 主程序入口 == | |
| 467 | + | (function main() { | |
| 468 | + | // 默认配置 | |
| 469 | + | const defaultConfig = { | |
| 470 | + | showTrafficStats: true, | |
| 471 | + | insertAfter: true, | |
| 472 | + | interval: 60000, | |
| 473 | + | toggleInterval: 5000, | |
| 474 | + | duration: 500, | |
| 475 | + | apiUrl: "/api/v1/service", | |
| 476 | + | enableLog: false, | |
| 477 | + | }; | |
| 478 | + | // 合并用户自定义配置 | |
| 479 | + | const config = Object.assign( | |
| 480 | + | {}, | |
| 481 | + | defaultConfig, | |
| 482 | + | window.TrafficScriptConfig || {}, | |
| 483 | + | ); | |
| 484 | + | if (config.enableLog) { | |
| 485 | + | console.log(`[TrafficScript] 版本: ${SCRIPT_VERSION}`); | |
| 486 | + | console.log("[TrafficScript] 最终配置如下:", config); | |
| 487 | + | } | |
| 488 | + | /** | |
| 489 | + | * 获取并刷新流量统计 | |
| 490 | + | */ | |
| 491 | + | function updateTrafficStats() { | |
| 492 | + | trafficDataManager.fetchTrafficData( | |
| 493 | + | config.apiUrl, | |
| 494 | + | config, | |
| 495 | + | (trafficData) => { | |
| 496 | + | trafficRenderer.renderTrafficStats(trafficData, config); | |
| 497 | + | }, | |
| 498 | + | ); | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | /** | |
| 502 | + | * DOM变更处理函数,触发刷新 | |
| 503 | + | */ | |
| 504 | + | function onDomChange() { | |
| 505 | + | if (config.enableLog) console.log("[main] DOM变化,刷新流量数据"); | |
| 506 | + | updateTrafficStats(); | |
| 507 | + | if (!trafficTimer) startPeriodicRefresh(); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | // 定时器句柄,防止重复启动 | |
| 511 | + | let trafficTimer = null; | |
| 512 | + | ||
| 513 | + | /** | |
| 514 | + | * 启动周期刷新任务 | |
| 515 | + | */ | |
| 516 | + | function startPeriodicRefresh() { | |
| 517 | + | if (!trafficTimer) { | |
| 518 | + | if (config.enableLog) console.log("[main] 启动周期刷新任务"); | |
| 519 | + | trafficTimer = setInterval(() => { | |
| 520 | + | updateTrafficStats(); | |
| 521 | + | }, config.interval); | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // 启动内容切换轮播(如时间、百分比) | |
| 526 | + | trafficRenderer.startToggleCycle(config.toggleInterval, config.duration); | |
| 527 | + | // 监听section变化及其子节点变化 | |
| 528 | + | const sectionDetector = domObserver.startSectionDetector(onDomChange); | |
| 529 | + | // 初始化调用一次 | |
| 530 | + | onDomChange(); | |
| 531 | + | ||
| 532 | + | // 延迟 100ms 后尝试读取用户配置并覆盖 | |
| 533 | + | setTimeout(() => { | |
| 534 | + | const newConfig = Object.assign( | |
| 535 | + | {}, | |
| 536 | + | defaultConfig, | |
| 537 | + | window.TrafficScriptConfig || {}, | |
| 538 | + | ); | |
| 539 | + | // 判断配置是否变化(简单粗暴比较JSON字符串) | |
| 540 | + | if (JSON.stringify(newConfig) !== JSON.stringify(config)) { | |
| 541 | + | if (config.enableLog) | |
| 542 | + | console.log("[main] 100ms后检测到新配置,更新配置并重启任务"); | |
| 543 | + | config = newConfig; | |
| 544 | + | // 重新启动周期刷新任务 | |
| 545 | + | startPeriodicRefresh(); | |
| 546 | + | // 重新启动内容切换轮播(传入新配置) | |
| 547 | + | trafficRenderer.startToggleCycle(config.toggleInterval, config.duration); | |
| 548 | + | // 立即刷新数据 | |
| 549 | + | updateTrafficStats(); | |
| 550 | + | } else { | |
| 551 | + | if (config.enableLog) console.log("[main] 100ms后无新配置,保持原配置"); | |
| 552 | + | } | |
| 553 | + | }, 100); | |
| 554 | + | // 页面卸载时清理监听和定时器 | |
| 555 | + | window.addEventListener("beforeunload", () => { | |
| 556 | + | domObserver.disconnectAll(sectionDetector); | |
| 557 | + | if (trafficTimer) clearInterval(trafficTimer); | |
| 558 | + | }); | |
| 559 | + | })(); | |