// ==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} \
\
- 已连接: {$online}
\
- {for(var i=0, len=$ip.length; iIP地址: {$ip[i]}{/for}
\
- MAC地址: {$mac}
\
\
\
| \
{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();
}
},
});
})();