network-fluctuation.js
· 2.7 KiB · JavaScript
Неформатований
const selectorButton =
"#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";
const selectorSection =
"#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > section";
const selector3 =
"#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > div:nth-child(3)";
const selector4 =
"#root > div > main > div.mx-auto.w-full.max-w-5xl.px-0.flex.flex-col.gap-4.server-info > div:nth-child(4)";
let hasClicked = false;
let divVisible = false;
let swapping = false;
function forceBothVisible() {
const div3 = document.querySelector(selector3);
const div4 = document.querySelector(selector4);
if (div3 && div4) {
div3.style.display = "block";
div4.style.display = "block";
}
}
function hideSection() {
const section = document.querySelector(selectorSection);
if (section) {
section.style.display = "none";
}
}
function tryClickButton() {
const btn = document.querySelector(selectorButton);
if (btn && !hasClicked) {
btn.click();
hasClicked = true;
setTimeout(forceBothVisible, 500);
}
}
function swapDiv3AndDiv4() {
if (swapping) return;
swapping = true;
const div3 = document.querySelector(selector3);
const div4 = document.querySelector(selector4);
if (!div3 || !div4) {
swapping = false;
return;
}
const parent = div3.parentNode;
if (parent !== div4.parentNode) {
swapping = false;
return;
}
// 交换 div3 和 div4 的位置
parent.insertBefore(div4, div3);
parent.insertBefore(div3, div4.nextSibling);
swapping = false;
}
const observer = new MutationObserver(() => {
const div3 = document.querySelector(selector3);
const div4 = document.querySelector(selector4);
const isDiv3Visible = div3 && getComputedStyle(div3).display !== "none";
const isDiv4Visible = div4 && getComputedStyle(div4).display !== "none";
const isAnyDivVisible = isDiv3Visible || isDiv4Visible;
if (isAnyDivVisible && !divVisible) {
hideSection();
tryClickButton();
setTimeout(swapDiv3AndDiv4, 100);
} else if (!isAnyDivVisible && divVisible) {
hasClicked = false;
}
divVisible = isAnyDivVisible;
if (div3 && div4) {
if (!isDiv3Visible || !isDiv4Visible) {
forceBothVisible();
}
}
});
const root = document.querySelector("#root");
if (root) {
observer.observe(root, {
childList: true,
attributes: true,
subtree: true,
attributeFilter: ["style", "class"],
});
}
| 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 | } |
| 97 |
style.css
· 5.5 KiB · CSS
Неформатований
/* 默认和浅色主题的CSS变量 */
:root,
html.light {
--custom-border-color: rgba(244, 244, 244, 0.382);
--custom-background-color: rgba(244, 244, 244, 0.618);
--custom-text-color: #333;
--custom-shadow-color: rgba(244, 244, 244, 0.5);
}
/* 暗色主题的CSS变量 */
html.dark {
--custom-border-color: rgba(11, 11, 11, 0.382);
--custom-background-color: rgba(11, 11, 11, 0.618);
--custom-text-color: #fff;
--custom-shadow-color: rgba(11, 11, 11, 0.5);
}
html.dark #root {
background-color: unset !important;
}
/* 背景图片压暗和模糊, 开了背景图建议开启 */
.dark .bg-cover::after {
content: "";
position: absolute;
inset: 0;
backdrop-filter: blur(6px);
background-color: rgba(0, 0, 0, 0.3);
}
.light .bg-cover::after {
content: "";
position: absolute;
inset: 0;
backdrop-filter: blur(3px);
background-color: rgba(255, 255, 255, 0.3);
}
.bg-card,
html.dark .bg-card,
html.light .bg-card {
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
border: 1px solid var(--custom-border-color);
box-shadow: 0 4px 6px var(--custom-shadow-color);
}
.focus\:text-accent-foreground:focus {
background-color: var(--custom-background-color);
opacity: 0.8;
}
.text-muted-foreground,
html.dark .text-muted-foreground,
html.light .text-muted-foreground {
color: var(--custom-text-color);
}
html.dark .dark\:bg-stone-700,
html.light .bg-stone-700,
.bg-stone-700 {
--tw-bg-opacity: 0.5;
background-color: var(--custom-background-color);
}
html *,
html.dark *,
html.light * {
border-color: var(--custom-border-color);
}
html body,
html.dark body,
html.light body {
color: var(--custom-text-color);
background: unset;
position: relative;
}
img {
border: none;
}
html.dark .dark\:border-neutral-800,
html.light .border-neutral-800,
.border-neutral-800 {
border-color: var(--custom-border-color);
}
html.dark .dark\:bg-stone-800,
html.light .bg-stone-800,
.bg-stone-800 {
--tw-bg-opacity: 0.5;
background-color: var(--custom-background-color);
}
html.dark .dark\:text-stone-500,
html.light .text-stone-500,
.text-stone-500 {
color: var(--custom-text-color);
}
.bg-secondary,
html.dark .bg-secondary,
html.light .bg-secondary {
background-color: var(--custom-background-color);
opacity: 0.7;
}
.bg-popover,
html.dark .bg-popover,
html.light .bg-popover {
background-color: var(--custom-background-color);
}
.bg-muted,
html.dark .bg-muted,
html.light .bg-muted {
background-color: var(--custom-border-color);
}
html.dark .dark\:bg-black,
html.light .bg-black,
.bg-black {
background-color: var(--custom-border-color);
}
.bg-border,
html.dark .bg-border,
html.light .bg-border {
background-color: var(--custom-border-color);
}
.border-input,
html.dark .border-input,
html.light .border-input {
border-color: var(--custom-border-color);
}
html.dark .dark\:text-neutral-300\/50,
html.light .text-neutral-300,
.text-neutral-300 {
color: var(--custom-text-color);
opacity: 0.7;
}
html.dark .dark\:text-stone-400,
html.light .text-stone-400,
.text-stone-400 {
color: var(--custom-text-color);
}
div#radix-\:r4\: {
background: var(--custom-background-color);
backdrop-filter: blur(4px);
}
.text-green-600 {
color: rgb(34, 197, 94);
}
.bg-green-600 {
background-color: rgb(34, 197, 94);
}
.vps-info,
html.dark .vps-info,
html.light .vps-info {
border-radius: 12px;
padding: 12px;
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
}
.font-medium.opacity-40,
html.dark .font-medium.opacity-40,
html.light .font-medium.opacity-40 {
opacity: 0.8 !important;
}
.font-medium.opacity-50,
html.dark .font-medium.opacity-50,
html.light .font-medium.opacity-50 {
opacity: 0.8 !important;
}
.max-w-5xl.gap-4 > div:first-child,
html.dark .max-w-5xl.gap-4 > div:first-child,
html.light .max-w-5xl.gap-4 > div:first-child {
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
border: 1px solid var(--custom-border-color);
box-shadow: 0 4px 6px var(--custom-shadow-color);
border-radius: 12px;
padding: 12px;
}
img[alt="BackIcon"] {
margin-right: 12px;
}
.flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\],
html.dark .flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\],
html.light .flex.items-center.gap-1.rounded-\[50px\].bg-stone-100.p-\[3px\],
html.dark .dark\:bg-stone-800 {
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
}
.\[\&_\.recharts-cartesian-axis-tick_text\]\:fill-muted-foreground
.recharts-cartesian-axis-tick
text {
fill: var(--custom-text-color);
}
html.dark .dark\:fill-neutral-800,
html.light .fill-neutral-800,
.fill-neutral-800 {
fill: var(--custom-text-color);
}
.data-\[state\=unchecked\]\:bg-input[data-state="unchecked"] {
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
}
.data-\[state\=checked\]\:bg-primary[data-state="checked"] {
background-color: var(--custom-background-color);
backdrop-filter: blur(4px);
}
.rounded-lg {
background-color: var(--custom-background-color) !important;
backdrop-filter: blur(5px) !important;
border-radius: 15px !important;
border: 1px solid var(--custom-border-color) !important;
}
html.dark .dark\:bg-black\/70,
html.light .bg-black\/70,
.bg-black\/70 {
background-color: var(--custom-background-color) !important;
backdrop-filter: blur(5px) !important;
border: 1px solid var(--custom-border-color) !important;
}
| 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
· 18 KiB · JavaScript
Неформатований
const SCRIPT_VERSION = "v20250617";
// == 样式注入模块 ==
// 注入自定义CSS隐藏特定元素
function injectCustomCSS() {
const style = document.createElement("style");
style.textContent = `
/* 隐藏父级类名为 mt-4 w-full mx-auto 下的所有 div */
.mt-4.w-full.mx-auto > div {
display: none;
}
`;
document.head.appendChild(style);
}
injectCustomCSS();
// == 工具函数模块 ==
const utils = (() => {
/**
* 格式化文件大小,自动转换单位
* @param {number} bytes - 字节数
* @returns {{value: string, unit: string}} 格式化后的数值和单位
*/
function formatFileSize(bytes) {
if (bytes === 0) return { value: "0", unit: "B" };
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return {
value: size.toFixed(unitIndex === 0 ? 0 : 2),
unit: units[unitIndex],
};
}
/**
* 计算百分比,输入可为大数,支持自动缩放
* @param {number} used - 已使用量
* @param {number} total - 总量
* @returns {string} 百分比字符串,保留2位小数
*/
function calculatePercentage(used, total) {
used = Number(used);
total = Number(total);
// 大数缩放,防止数值溢出
if (used > 1e15 || total > 1e15) {
used /= 1e10;
total /= 1e10;
}
return total === 0 ? "0.00" : ((used / total) * 100).toFixed(2);
}
/**
* 格式化日期字符串,返回 yyyy-MM-dd 格式
* @param {string} dateString - 日期字符串
* @returns {string} 格式化日期
*/
function formatDate(dateString) {
const date = new Date(dateString);
if (isNaN(date)) return "";
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/**
* 安全设置子元素文本内容,避免空引用错误
* @param {HTMLElement} parent - 父元素
* @param {string} selector - 子元素选择器
* @param {string} text - 要设置的文本
*/
function safeSetTextContent(parent, selector, text) {
const el = parent.querySelector(selector);
if (el) el.textContent = text;
}
/**
* 根据百分比返回渐变HSL颜色(绿→橙→红)
* @param {number} percentage - 0~100的百分比
* @returns {string} hsl颜色字符串
*/
function getHslGradientColor(percentage) {
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
const lerp = (start, end, t) => start + (end - start) * t;
const p = clamp(Number(percentage), 0, 100);
let h, s, l;
if (p <= 35) {
const t = p / 35;
h = lerp(142, 32, t); // 绿色到橙色
s = lerp(69, 85, t);
l = lerp(45, 55, t);
} else if (p <= 85) {
const t = (p - 35) / 50;
h = lerp(32, 0, t); // 橙色到红色
s = lerp(85, 75, t);
l = lerp(55, 50, t);
} else {
const t = (p - 85) / 15;
h = 0; // 红色加深
s = 75;
l = lerp(50, 45, t);
}
return `hsl(${h.toFixed(0)}, ${s.toFixed(0)}%, ${l.toFixed(0)}%)`;
}
/**
* 透明度渐隐渐现切换内容
* @param {HTMLElement} element - 目标元素
* @param {string} newContent - 新HTML内容
* @param {number} duration - 动画持续时间,毫秒
*/
function fadeOutIn(element, newContent, duration = 500) {
element.style.transition = `opacity ${duration / 2}ms`;
element.style.opacity = "0";
setTimeout(() => {
element.innerHTML = newContent;
element.style.transition = `opacity ${duration / 2}ms`;
element.style.opacity = "1";
}, duration / 2);
}
return {
formatFileSize,
calculatePercentage,
formatDate,
safeSetTextContent,
getHslGradientColor,
fadeOutIn,
};
})();
// == 流量统计渲染模块 ==
const trafficRenderer = (() => {
const toggleElements = []; // 存储需周期切换显示的元素及其内容
/**
* 渲染流量统计条目
* @param {Object} trafficData - 后台返回的流量数据
* @param {Object} config - 配置项
*/
function renderTrafficStats(trafficData, config) {
const serverMap = new Map();
// 解析流量数据,按服务器名聚合
for (const cycleId in trafficData) {
const cycle = trafficData[cycleId];
if (!cycle.server_name || !cycle.transfer) continue;
for (const serverId in cycle.server_name) {
const serverName = cycle.server_name[serverId];
const transfer = cycle.transfer[serverId];
const max = cycle.max;
const from = cycle.from;
const to = cycle.to;
const next_update = cycle.next_update[serverId];
if (serverName && transfer !== undefined && max && from && to) {
serverMap.set(serverName, {
id: serverId,
transfer,
max,
name: cycle.name,
from,
to,
next_update,
});
}
}
}
serverMap.forEach((serverData, serverName) => {
// 查找对应显示区域
const targetElement = Array.from(
document.querySelectorAll("section.grid.items-center.gap-2"),
).find((section) => {
const firstText = section.querySelector("p")?.textContent.trim();
return firstText === serverName.trim();
});
if (!targetElement) return;
// 格式化数据
const usedFormatted = utils.formatFileSize(serverData.transfer);
const totalFormatted = utils.formatFileSize(serverData.max);
const percentage = utils.calculatePercentage(
serverData.transfer,
serverData.max,
);
const fromFormatted = utils.formatDate(serverData.from);
const toFormatted = utils.formatDate(serverData.to);
const nextUpdateFormatted = new Date(
serverData.next_update,
).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
const uniqueClassName = "traffic-stats-for-server-" + serverData.id;
const progressColor = utils.getHslGradientColor(percentage);
const containerDiv = targetElement.closest("div");
if (!containerDiv) return;
// 日志输出函数
const log = (...args) => {
if (config.enableLog) console.log("[renderTrafficStats]", ...args);
};
// 查找是否已有对应流量条目元素
const existing = Array.from(
containerDiv.querySelectorAll(".new-inserted-element"),
).find((el) => el.classList.contains(uniqueClassName));
if (!config.showTrafficStats) {
// 不显示时移除对应元素
if (existing) {
existing.remove();
log(`移除流量条目: ${serverName}`);
}
return;
}
if (existing) {
// 更新已存在元素内容
utils.safeSetTextContent(
existing,
".used-traffic",
usedFormatted.value,
);
utils.safeSetTextContent(existing, ".used-unit", usedFormatted.unit);
utils.safeSetTextContent(
existing,
".total-traffic",
totalFormatted.value,
);
utils.safeSetTextContent(existing, ".total-unit", totalFormatted.unit);
utils.safeSetTextContent(existing, ".from-date", fromFormatted);
utils.safeSetTextContent(existing, ".to-date", toFormatted);
utils.safeSetTextContent(
existing,
".percentage-value",
percentage + "%",
);
utils.safeSetTextContent(
existing,
".next-update",
`next update: ${nextUpdateFormatted}`,
);
const progressBar = existing.querySelector(".progress-bar");
if (progressBar) {
progressBar.style.width = percentage + "%";
progressBar.style.backgroundColor = progressColor;
}
log(`更新流量条目: ${serverName}`);
} else {
// 插入新的流量条目元素
let oldSection = null;
if (config.insertAfter) {
oldSection =
containerDiv.querySelector(
"section.flex.items-center.w-full.justify-between.gap-1",
) || containerDiv.querySelector("section.grid.items-center.gap-3");
} else {
oldSection = containerDiv.querySelector(
"section.grid.items-center.gap-3",
);
}
if (!oldSection) return;
// 时间区间内容,用于切换显示
const defaultTimeInfoHTML = `<span class="from-date">${fromFormatted}</span>
<span class="text-neutral-500 dark:text-neutral-400">-</span>
<span class="to-date">${toFormatted}</span>`;
const contents = [
defaultTimeInfoHTML,
`<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 percentage-value">${percentage}%</span>`,
`<span class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300">${nextUpdateFormatted}</span>`,
];
const newElement = document.createElement("div");
newElement.classList.add(
"space-y-1.5",
"new-inserted-element",
uniqueClassName,
);
newElement.style.width = "100%";
newElement.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-baseline gap-1">
<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-traffic">${usedFormatted.value}</span>
<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-unit">${usedFormatted.unit}</span>
<span class="text-[10px] text-neutral-500 dark:text-neutral-400">/ </span>
<span class="text-[10px] text-neutral-500 dark:text-neutral-400 total-traffic">${totalFormatted.value}</span>
<span class="text-[10px] text-neutral-500 dark:text-neutral-400 total-unit">${totalFormatted.unit}</span>
</div>
<div class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300 time-info" style="opacity:1; transition: opacity 0.3s;">
${defaultTimeInfoHTML}
</div>
</div>
<div class="relative h-1.5">
<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800 rounded-full"></div>
<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>
</div>
`;
oldSection.after(newElement);
log(`插入新流量条目: ${serverName}`);
// 启用切换时,将元素及其内容保存以便周期切换
if (config.toggleInterval > 0) {
const timeInfoElement = newElement.querySelector(".time-info");
if (timeInfoElement) {
toggleElements.push({
el: timeInfoElement,
contents,
});
}
}
}
});
}
/**
* 启动周期切换内容显示(用于时间、百分比等轮播)
* @param {number} toggleInterval - 切换间隔,毫秒
* @param {number} duration - 动画时长,毫秒
*/
function startToggleCycle(toggleInterval, duration) {
if (toggleInterval <= 0) return;
let toggleIndex = 0;
setInterval(() => {
toggleIndex++;
toggleElements.forEach(({ el, contents }) => {
if (!document.body.contains(el)) return;
const index = toggleIndex % contents.length;
utils.fadeOutIn(el, contents[index], duration);
});
}, toggleInterval);
}
return {
renderTrafficStats,
startToggleCycle,
};
})();
// == 数据请求和缓存模块 ==
const trafficDataManager = (() => {
let trafficCache = null;
/**
* 请求流量数据,支持缓存
* @param {string} apiUrl - 接口地址
* @param {Object} config - 配置项
* @param {Function} callback - 请求成功后的回调,参数为流量数据
*/
function fetchTrafficData(apiUrl, config, callback) {
const now = Date.now();
// 使用缓存数据
if (trafficCache && now - trafficCache.timestamp < config.interval) {
if (config.enableLog) console.log("[fetchTrafficData] 使用缓存数据");
callback(trafficCache.data);
return;
}
if (config.enableLog) console.log("[fetchTrafficData] 请求新数据...");
fetch(apiUrl)
.then((res) => res.json())
.then((data) => {
if (!data.success) {
if (config.enableLog)
console.warn("[fetchTrafficData] 请求成功但数据异常");
return;
}
if (config.enableLog) console.log("[fetchTrafficData] 成功获取新数据");
const trafficData = data.data.cycle_transfer_stats;
trafficCache = {
timestamp: now,
data: trafficData,
};
callback(trafficData);
})
.catch((err) => {
if (config.enableLog)
console.error("[fetchTrafficData] 请求失败:", err);
});
}
return {
fetchTrafficData,
};
})();
// == DOM变化监听模块 ==
const domObserver = (() => {
const TARGET_SELECTOR =
"section.server-card-list, section.server-inline-list";
let currentSection = null;
let childObserver = null;
/**
* DOM 子节点变更回调,调用传入的函数
* @param {Function} onChangeCallback - 变更处理函数
*/
function onDomChildListChange(onChangeCallback) {
onChangeCallback();
}
/**
* 监听指定section子节点变化
* @param {HTMLElement} section - 目标section元素
* @param {Function} onChangeCallback - 变更处理函数
*/
function observeSection(section, onChangeCallback) {
if (childObserver) {
childObserver.disconnect();
}
currentSection = section;
childObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (
m.type === "childList" &&
(m.addedNodes.length || m.removedNodes.length)
) {
onDomChildListChange(onChangeCallback);
break;
}
}
});
childObserver.observe(currentSection, { childList: true, subtree: false });
// 初始调用一次
onChangeCallback();
}
/**
* 启动顶层section监听,检测section切换
* @param {Function} onChangeCallback - section变化时回调
* @returns {MutationObserver} sectionDetector实例
*/
function startSectionDetector(onChangeCallback) {
const sectionDetector = new MutationObserver(() => {
const section = document.querySelector(TARGET_SELECTOR);
if (section && section !== currentSection) {
observeSection(section, onChangeCallback);
}
});
const root = document.querySelector("main") || document.body;
sectionDetector.observe(root, { childList: true, subtree: true });
return sectionDetector;
}
/**
* 断开所有监听
* @param {MutationObserver} sectionDetector - 顶层section监听实例
*/
function disconnectAll(sectionDetector) {
if (childObserver) childObserver.disconnect();
if (sectionDetector) sectionDetector.disconnect();
}
return {
startSectionDetector,
disconnectAll,
};
})();
// == 主程序入口 ==
(function main() {
// 默认配置
const defaultConfig = {
showTrafficStats: true,
insertAfter: true,
interval: 60000,
toggleInterval: 5000,
duration: 500,
apiUrl: "/api/v1/service",
enableLog: false,
};
// 合并用户自定义配置
const config = Object.assign(
{},
defaultConfig,
window.TrafficScriptConfig || {},
);
if (config.enableLog) {
console.log(`[TrafficScript] 版本: ${SCRIPT_VERSION}`);
console.log("[TrafficScript] 最终配置如下:", config);
}
/**
* 获取并刷新流量统计
*/
function updateTrafficStats() {
trafficDataManager.fetchTrafficData(
config.apiUrl,
config,
(trafficData) => {
trafficRenderer.renderTrafficStats(trafficData, config);
},
);
}
/**
* DOM变更处理函数,触发刷新
*/
function onDomChange() {
if (config.enableLog) console.log("[main] DOM变化,刷新流量数据");
updateTrafficStats();
if (!trafficTimer) startPeriodicRefresh();
}
// 定时器句柄,防止重复启动
let trafficTimer = null;
/**
* 启动周期刷新任务
*/
function startPeriodicRefresh() {
if (!trafficTimer) {
if (config.enableLog) console.log("[main] 启动周期刷新任务");
trafficTimer = setInterval(() => {
updateTrafficStats();
}, config.interval);
}
}
// 启动内容切换轮播(如时间、百分比)
trafficRenderer.startToggleCycle(config.toggleInterval, config.duration);
// 监听section变化及其子节点变化
const sectionDetector = domObserver.startSectionDetector(onDomChange);
// 初始化调用一次
onDomChange();
// 延迟 100ms 后尝试读取用户配置并覆盖
setTimeout(() => {
const newConfig = Object.assign(
{},
defaultConfig,
window.TrafficScriptConfig || {},
);
// 判断配置是否变化(简单粗暴比较JSON字符串)
if (JSON.stringify(newConfig) !== JSON.stringify(config)) {
if (config.enableLog)
console.log("[main] 100ms后检测到新配置,更新配置并重启任务");
config = newConfig;
// 重新启动周期刷新任务
startPeriodicRefresh();
// 重新启动内容切换轮播(传入新配置)
trafficRenderer.startToggleCycle(config.toggleInterval, config.duration);
// 立即刷新数据
updateTrafficStats();
} else {
if (config.enableLog) console.log("[main] 100ms后无新配置,保持原配置");
}
}, 100);
// 页面卸载时清理监听和定时器
window.addEventListener("beforeunload", () => {
domObserver.disconnectAll(sectionDetector);
if (trafficTimer) clearInterval(trafficTimer);
});
})();
| 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 | })(); |
| 560 |