// ==UserScript== // @name 小米路由器WEB端功能优化 // @namespace // @version 0.12.1 // @description 小米路由器 Web 管理后台功能优化(按标签页分化)。【终端设备】每台设备名旁显示实时上下行网速与占全网比例(带进度条背景);【路由器】状态面板显示内存占用(带进度条)、运行时长、外网累计流量;【互联网】上下行实时网速折线图(120 秒滚动窗口,鼠标悬停可查看任意时刻数值)。配色统一取自路由器原生功能按钮色板。 // @author 羊雨橙 // @include /http:\/\/.*?\/cgi-bin\/luci\/;stok.*/ // @grant unsafeWindow // @run-at document-start // @original-author 木鱼 // @original-script https://greasyfork.org/zh-CN/scripts/425113-%E5%B0%8F%E7%B1%B3%E8%B7%AF%E7%94%B1%E5%99%A8%E5%A2%9E%E5%BC%BA%E8%84%9A%E6%9C%AC // @homepageURL https://mp.weixin.qq.com/s/MQIQa5Y3upTxGiZb-EKzsQ // @license MIT // ==/UserScript== // 备注:本脚本基于「小米路由器Web管理后台实时网速显示脚本」二次开发 // https://blog.iccfish.com/2024/03/11/小米路由器web管理后台实时网速显示脚本/ function boot() { let pathName = location.pathname; if (/\/web\/home/.test(pathName)) { initHomePage(); } } let token, jQuery, uw = unsafeWindow; function getToken() { if (!token) token = /;stok=([\da-f]+)/.exec(location.href) && RegExp.$1; return token; } function initHomePage() { var s = document.createElement("style"); s.id = "miwifi-enhanced-style"; s.textContent = ".device-speed { float: right; margin-right: 8%; } /* 8% = 100% - 92%,对齐 .devnetinfo 第 3 列(MAC地址)的右边界 */\ .up-speed, .down-speed { display: inline-block; padding-left: 15px; border: 1px solid #E2542C; color: #E2542C; position: relative; --percentage: 0; width: 134px; text-align: center; font-size: 90%; white-space: nowrap; }\ .up-speed:after, .down-speed:after { content: ''; position: absolute; left: 5px; top: 50%; width: 0; height: 0; margin-top: -3px; border-left: 4px solid transparent; border-right: 4px solid transparent; }\ .up-speed:before, .down-speed:before { content: ' '; position: absolute; left: 0; top: 0; bottom: 0; background: rgba(226,84,44,0.25); width: var(--percentage); }\ .down-speed { color:#5AA7E8; border-color:#5AA7E8; }\ .up-speed:after { border-bottom: 6px solid currentColor; }\ .down-speed:after { border-top: 6px solid currentColor; }\ .speed-ico { width: 9px; height: 9px; vertical-align: -1px; margin-right: 2px; }\ .speed-ico path { fill: currentColor; }\ .router-stat-wrap .ico-up { color: #E2542C; }\ .router-stat-wrap .ico-dn { color: #5AA7E8; }\ .down-speed:before { background-color: rgb(90 167 232 / 25%); }\ .speed-chart-wrap { padding: 12px 14px 8px; background: #fafbfc; border-radius: 8px; }\ .speed-chart-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #6b7280; margin-bottom: 6px; }\ .speed-chart-legend .item { display: inline-flex; align-items: center; gap: 6px; }\ .speed-chart-legend .item.up, .speed-chart-legend .item.down { font-size: 14px; }\ .speed-chart-legend .item i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; }\ .speed-chart-legend .item.up i { background: #E2542C; }\ .speed-chart-legend .item.down i { background: #5AA7E8; }\ .speed-chart-legend .item .v { color: #2b2f36; font-weight: 600; }\ .speed-chart-legend .range { margin-left: auto; color: #b3b8c2; }\ .speed-chart-canvas-box { position: relative; }\ .speed-chart-canvas { display: block; width: 100%; height: 160px; cursor: crosshair; }\ .speed-chart-tip { position: absolute; top: 2px; width: 118px; box-sizing: border-box; display: none; padding: 6px 9px; background: #8E82E0; color: #fff; font-size: 12px; line-height: 1.55; border-radius: 6px; pointer-events: none; box-shadow: 0 4px 14px rgba(0,0,0,.18); z-index: 6; }\ .speed-chart-tip .t { color: rgba(255,255,255,.78); margin-bottom: 2px; }\ .speed-chart-tip b { font-weight: 600; }\ .speed-chart-tip b.u { color: #fff; }\ .speed-chart-tip b.d { color: #fff; }\ .router-stat-wrap { display: flex; justify-content: space-between; gap: 12px; padding: 12px 14px 14px; background: #fafbfc; border-radius: 8px; }\ .router-stat-wrap .stat { flex: 1 1 0; min-width: 0; }\ .router-stat-wrap .stat:nth-child(2) { text-align: center; }\ .router-stat-wrap .stat:nth-child(3) { text-align: right; }\ .router-stat-wrap .k { font-size: 12px; color: #8b909a; margin-bottom: 4px; }\ .router-stat-wrap .v { font-size: 16px; font-weight: 600; color: #2b2f36; }\ .router-stat-wrap .v.small { font-size: 13px; }\ .router-stat-wrap .bar { height: 4px; max-width: 150px; margin-top: 7px; background: #e8ebef; border-radius: 2px; overflow: hidden; }\ .router-stat-wrap .bar i { display: block; height: 100%; width: 0; background: #4CAF50; border-radius: 2px; transition: width .3s; }\ .router-stat-wrap .bar.warn i { background: #F0A32A; }\ .router-stat-wrap .bar.hot i { background: #E2542C; }\ "; document.head.appendChild(s); // 替换模板 let devicesItemTmpl = document.querySelector("#tmpldevicesitem"); devicesItemTmpl.innerHTML = '\ \ \ \
\
{$name}   {if($isself)}| 本机{/if}
\ \
\ \ {if($d_is_ap != 8)}{$option}{/if}\ {if($d_is_ap == 8)}{/if}\ {if($hasDisk)}{$option2}{/if}\ '; // 处理数据 let lock; // ==================== 通用格式化 ==================== function fmtBytes(v) { v = +v || 0; try { if (uw && typeof uw.byteFormat === "function") return uw.byteFormat(v, 100); } catch (e) {} const u = ["B", "KB", "MB", "GB", "TB", "PB"]; let i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return (v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2)) + u[i]; } function fmtSpeed(v) { return fmtBytes(v) + "/S"; } // 内联 SVG 上下行图标(矢量、随文字颜色、无需外部资源) const ICO_UP = ''; const ICO_DOWN = ''; // ==================================================== // ==================== 上下行实时折线图 ==================== const CHART_POINTS = 120; // 采样窗口,约 120 秒 const CHART_UP = "#E2542C"; // 上行线颜色(调色板:局域网设置) const CHART_DOWN = "#5AA7E8"; // 下行线颜色(调色板:Wi-Fi设置) const chartUp = [], chartDown = []; let chartCanvas = null, chartCtx = null, chartLastSample = 0, chartResizeBound = false, chartLock = false, chartGeom = null, hoverIdx = -1; // 把 Y 轴最大值取整到 1/2/5 × 10^n,让刻度好看 function chartNiceMax(v) { if (!(v > 0)) return 1024; const exp = Math.floor(Math.log10(v)); const base = Math.pow(10, exp); const n = v / base; const m = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10; return m * base; } // 在设备列表上方创建图表(只创建一次)。 // 关键:沿用页面自己的「区块标题 + 表格」结构(table.mod-table-devices / th.s0), // 这样标题字号、分隔线、上下留白都会和「网线连网设备」等区块完全一致。 function chartEnsure() { let wrap = jQuery("div.speed-chart-wrap"); if (!wrap.length) { // 插进「互联网」标签容器(.mod-internetstatus), // 由页面自己的 tab 切换逻辑(switchTo)控制显示隐藏 const host = jQuery(".mod-internetstatus"); if (!host.length) return null; host.prepend( "
" + // 在「实时网速」上方留一个空行 '' + '' + '
实时网速
' + '
' + '
' + '上行 --' + '下行 --' + '最近 ' + CHART_POINTS + ' 秒 · 悬停查看' + "
" + '
' + '' + '
' + "
" + "
" + "
" ); wrap = jQuery("div.speed-chart-wrap"); chartBindHover(); } chartCanvas = wrap.find("canvas.speed-chart-canvas")[0]; chartCtx = chartCanvas ? chartCanvas.getContext("2d") : null; if (!chartResizeBound) { chartResizeBound = true; jQuery(window).on("resize", chartDraw); } return wrap; } // 绘制折线图(Canvas,自适应高分屏) function chartDraw() { if (!chartCtx || !chartCanvas) return; const canvas = chartCanvas; if (!canvas.clientWidth) return; // 所在标签页隐藏时跳过绘制(切回来会重绘) const dpr = window.devicePixelRatio || 1; const cssW = Math.max(160, canvas.clientWidth || 600); const cssH = Math.max(80, canvas.clientHeight || 160); // 高度由 CSS 决定,避免两处硬编码 if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) { canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); } const ctx = chartCtx; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const padL = 64, padR = 14, padT = 12, padB = 20; const plotW = Math.max(10, cssW - padL - padR); const plotH = Math.max(10, cssH - padT - padB); let maxV = 0; for (let i = 0; i < chartUp.length; i++) { if (chartUp[i] > maxV) maxV = chartUp[i]; if (chartDown[i] > maxV) maxV = chartDown[i]; } maxV = chartNiceMax(maxV); const n = chartUp.length; const xOf = i => padL + (n <= 1 ? plotW : (i / (n - 1)) * plotW); const yOf = v => padT + plotH - (Math.min(v, maxV) / maxV) * plotH; chartGeom = { padL: padL, plotW: plotW, cssW: cssW, n: n }; // 横向网格 + Y 轴刻度 ctx.font = "10px -apple-system, 'PingFang SC', Helvetica, Arial, sans-serif"; ctx.textBaseline = "middle"; const rows = 4; for (let r = 0; r <= rows; r++) { const y = padT + (r / rows) * plotH; ctx.beginPath(); ctx.strokeStyle = r === rows ? "#dfe3e8" : "#eceef1"; ctx.lineWidth = 1; ctx.moveTo(padL, y + 0.5); ctx.lineTo(padL + plotW, y + 0.5); ctx.stroke(); ctx.textAlign = "right"; ctx.fillStyle = "#9aa0a6"; ctx.fillText(fmtSpeed(maxV * (1 - r / rows)), padL - 6, y); } // X 轴时间说明 if (n > 1) { ctx.fillStyle = "#b0b0b0"; ctx.textAlign = "left"; ctx.fillText("-" + (n - 1) + "s", padL, cssH - 8); ctx.textAlign = "right"; ctx.fillText("现在", padL + plotW, cssH - 8); } if (n) { const line = (arr, color) => { // 半透明面积 ctx.beginPath(); ctx.moveTo(xOf(0), yOf(arr[0])); for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(arr[i])); ctx.lineTo(xOf(n - 1), padT + plotH); ctx.lineTo(xOf(0), padT + plotH); ctx.closePath(); ctx.fillStyle = color + "1f"; ctx.fill(); // 折线 ctx.beginPath(); ctx.moveTo(xOf(0), yOf(arr[0])); for (let i = 1; i < n; i++) ctx.lineTo(xOf(i), yOf(arr[i])); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.stroke(); // 末端数据点 ctx.beginPath(); ctx.arc(xOf(n - 1), yOf(arr[n - 1]), 3, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); }; line(chartDown, CHART_DOWN); line(chartUp, CHART_UP); // 悬停:竖向指示线 + 高亮点 if (hoverIdx >= 0 && hoverIdx < n) { const hx = xOf(hoverIdx); ctx.beginPath(); ctx.strokeStyle = "rgba(0,0,0,.16)"; ctx.lineWidth = 1; ctx.moveTo(hx + 0.5, padT); ctx.lineTo(hx + 0.5, padT + plotH); ctx.stroke(); [ [chartUp, CHART_UP], [chartDown, CHART_DOWN], ].forEach(function (p) { ctx.beginPath(); ctx.arc(hx, yOf(p[0][hoverIdx]), 4, 0, Math.PI * 2); ctx.fillStyle = "#fff"; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = p[1]; ctx.stroke(); }); } } chartTip(); } // 悬停浮层:显示该时间点的上下行数值 function chartTip() { const tip = jQuery("div.speed-chart-tip"); if (!tip.length) return; if (hoverIdx < 0 || !chartGeom || hoverIdx >= chartGeom.n) { tip.hide(); return; } const g = chartGeom; const idx = hoverIdx; const offset = g.n - 1 - idx; const x = g.padL + (g.n <= 1 ? 0 : (idx / (g.n - 1)) * g.plotW); const w = 118; tip.html( '
' + (offset === 0 ? "现在" : "-" + offset + " 秒") + "
" + '
' + ICO_UP + " " + fmtSpeed(chartUp[idx]) + "
" + '
' + ICO_DOWN + " " + fmtSpeed(chartDown[idx]) + "
" ) .css("left", Math.max(0, Math.min(g.cssW - w, x - w / 2)) + "px") .show(); } function chartHover(e) { if (!chartCanvas || !chartGeom || chartGeom.n < 1) return; const rect = chartCanvas.getBoundingClientRect(); const g = chartGeom; const i = Math.round(((e.clientX - rect.left - g.padL) / g.plotW) * (g.n - 1)); hoverIdx = Math.max(0, Math.min(g.n - 1, i)); chartDraw(); } function chartBindHover() { const canvas = jQuery("canvas.speed-chart-canvas"); canvas.off(".chartHover"); canvas.on("mousemove.chartHover", chartHover); canvas.on("mouseleave.chartHover", function () { hoverIdx = -1; jQuery("div.speed-chart-tip").hide(); chartDraw(); }); } // 记录一次采样并刷新图表(约 1 次/秒,自动滚动窗口) function chartPush(up, down) { const now = Date.now(); if (now - chartLastSample < 700) return; chartLastSample = now; chartUp.push(+up || 0); chartDown.push(+down || 0); while (chartUp.length > CHART_POINTS) { chartUp.shift(); chartDown.shift(); } if (hoverIdx >= 0) hoverIdx = Math.max(0, hoverIdx - 1); // 窗口左移时跟随 const wrap = jQuery("div.speed-chart-wrap"); wrap.find(".item.up .v").text(fmtSpeed(up)); wrap.find(".item.down .v").text(fmtSpeed(down)); chartDraw(); } // 独立采样:即使不在 #devices 视图,图表也能持续刷新 function chartTick() { chartEnsure(); // 确保面板存在(页面切标签可能重建内容) if (!chartCanvas) return; if (chartLock) return; chartLock = true; jQuery .getJSON(`/cgi-bin/luci/;stok=${getToken()}/api/misystem/devicelist`, {}) .done(function (data) { if (!data || data.code !== 0 || !data.list) return; let up = 0, down = 0; data.list.forEach(function (it) { if (it.statistics) { down += +it.statistics.downspeed || 0; up += +it.statistics.upspeed || 0; } }); chartPush(up, down); }) .always(function () { chartLock = false; }); } setInterval(chartTick, 1000); // 切换标签后重绘图表(隐藏期间画布宽度为 0,无法绘制) jQuery(document).on("click", ".nav-tab", function () { setTimeout(function () { chartDraw(); }, 60); }); // ==================================================== // ==================== 路由器状态面板 ==================== let statLock = false, statPrefilled = false, statBooted = false; function fmtDuration(sec) { sec = Math.floor(+sec || 0); const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); if (d) return d + " 天 " + h + " 小时"; if (h) return h + " 小时 " + m + " 分"; return m + " 分 " + (sec % 60) + " 秒"; } // 状态面板插进「路由状态」标签容器(.mod-routerstatus),由页面 tab 切换控制显示 function statEnsure() { let wrap = jQuery("div.router-stat-wrap"); if (wrap.length) return wrap; const host = jQuery(".mod-routerstatus"); if (!host.length) return null; host.prepend( "
" + '' + '' + '
路由器状态
' + '
' + '
内存占用
--
' + '
运行时长
--
' + '
外网累计流量
--
' + "
" + "
" ); return jQuery("div.router-stat-wrap"); } // 用路由器自带的历史数据预填折线图(省去开页面干等 2 分钟) function prefillWanHistory(wan) { const h = wan && wan.history; if (!Array.isArray(h) || h.length < 2) return; const ups = [], downs = []; for (let i = 0; i < h.length; i++) { const x = h[i]; if (!x || typeof x !== "object") { console.log("[网速增强] wan.history 是纯数字数组(上下行合计),无法拆分,跳过预填"); return; } ups.push(+x.upspeed || 0); downs.push(+x.downspeed || 0); } chartUp.length = 0; chartDown.length = 0; for (let i = 0; i < ups.length; i++) { chartUp.push(ups[i]); chartDown.push(downs[i]); } while (chartUp.length > CHART_POINTS) { chartUp.shift(); chartDown.shift(); } chartDraw(); console.log("[网速增强] 已用路由器历史数据预填图表 " + ups.length + " 个点"); } function refreshRouterStat() { if (statLock) return; statLock = true; jQuery .getJSON(`/cgi-bin/luci/;stok=${getToken()}/api/misystem/status`, {}) .done(function (data) { if (!data || data.code !== 0) return; const wrap = statEnsure(); if (!wrap || !wrap.length) return; const setV = (k, txt) => wrap.find('[data-k="' + k + '"]').text(txt); const setH = (k, html) => wrap.find('[data-k="' + k + '"]').html(html); const setBar = (k, pct) => { pct = Math.max(0, Math.min(100, Math.round(pct))); const el = wrap.find('[data-k="' + k + '"]'); el.css("width", pct + "%"); const box = el.parent(); box.removeClass("warn hot"); if (pct >= 85) box.addClass("hot"); else if (pct >= 60) box.addClass("warn"); }; const mem = data.mem || {}, wan = data.wan || {}; const memPct = (+mem.usage || 0) * 100; setV("mem", Math.round(memPct) + "%" + (mem.total ? " / " + mem.total : "")); setBar("membar", memPct); setV("uptime", fmtDuration(data.upTime)); setH( "wantotal", '' + ICO_UP + "" + fmtBytes(wan.upload) + ' ' + ICO_DOWN + "" + fmtBytes(wan.download) ); if (!statPrefilled) { statPrefilled = true; prefillWanHistory(wan); } }) .always(function () { statLock = false; }); } setInterval(refreshRouterStat, 3000); // ==================================================== function showSpeed(list) { let needFullReload = false; let totalUpload = 0, totalDownload = 0; list.filter(item => item.statistics).forEach(item => { totalDownload += +item.statistics.downspeed; totalUpload += +item.statistics.upspeed; }); list.forEach(item => { if (item.statistics) { let mac = item.mac; let tr = uw.$(`tr.device-item[data-mac='${mac}']`); if (!tr) { needFullReload = true; return; } let title = tr.find("div.name"); let upspeed = uw.byteFormat(+item.statistics.upspeed, 100) + "/S"; let downspeed = uw.byteFormat(+item.statistics.downspeed, 100) + "/S"; let online = jQuery.secondToDate(+item.statistics.online); let ups = title.find(".up-speed"); const pu = totalUpload ? Math.round((+item.statistics.upspeed * 10000) / totalUpload) / 100 : 0; const pd = totalDownload ? Math.round((+item.statistics.downspeed * 10000) / totalDownload) / 100 : 0; if (ups.length) { ups.html(`${upspeed} | ${pu}%`)[0].style.setProperty("--percentage", pu + "%"); title .find(".down-speed") .html(`${downspeed} | ${pd}%`)[0] .style.setProperty("--percentage", pd + "%"); } else { let speedTmpl = `${upspeed} | ${pu}% ${downspeed} | ${pd}%`; title.append(speedTmpl); } tr.find(".online-time").html(online); } }); // 折线图:位于设备列表上方 chartEnsure(); chartPush(totalUpload, totalDownload); // 路由器状态面板(插在图表区块下方) statEnsure(); if (!statBooted) { statBooted = true; refreshRouterStat(); } if (location.hash !== "#devices") return; if (needFullReload) { console.log("发现新设备,需要完全重新加载."); jQuery.pub("devices:getlist"); } else { setTimeout(refreshSpeed, 1000); } } function refreshSpeed() { if (lock) return; lock = true; let api = `/cgi-bin/luci/;stok=${getToken()}/api/misystem/devicelist`; jQuery .getJSON(api, {}) .done(function (data) { if (data.code !== 0) return; showSpeed(data.list); }) .fail(function () { setTimeout(refreshSpeed, 1000); }) .always(function () { lock = false; }); } jQuery(document).ajaxComplete(function (e, xhr, setting) { if (/misystem\/devicelist/.test(setting.url)) { let data; try { data = JSON.parse(xhr.responseText); } catch (e) { console.log(`invalid reponse: ${e}.`); return; } showSpeed(data.list); } }); // 两个面板各插进对应的标签容器,只在自己那个标签页里出现: // 实时网速 → .mod-internetstatus(#internet) // 路由器状态 → .mod-routerstatus(#router) // 设备网速标签 → 在设备行内部(#devices),天然只出现在设备页 chartEnsure(); statEnsure(); refreshRouterStat(); } (function () { let val, inited; Object.defineProperty(unsafeWindow, "jQuery", { get: function () { return val; }, set: function (v) { val = v; jQuery = v; if (!inited) { inited = true; boot(); } }, }); })();