Files
DCU_DesktopController/js/common/lan-picker.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

704 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* lan-picker.js — 局域网设备发现面板(一份实现,两个入口)
*
* 入口:tabs.html 顶栏的 🔍(外壳文档 = 扫描的宿主)与 pages/index.html IP 行
* 的「发现」。iframe 里的入口只请求外壳开面板;外壳是同源父文档,所以不需要
* 选举——同一时刻只有一份扫描状态。
*
* 分工(重要):
* - 判据只在服务侧一处:lan_probe.classify() 说是不是板子,页面不复算,
* 免得出现第二个"什么算设备"的口径。
* - 编排只在这里:候选表、分批、进度、取消、缓存、渲染。
* - 换端点只走 deviceLink.selectDevice()(唯一的写手 + 唯一的门)。
*
* 当前已选端点**不重复开连接**去探:Owner 那条 1Hz /system/status 轮询已经是
* 它的活体判决(板子 httpd 只有 6 个 socket 且开了 lru_purge,多开一条会踢掉
* 网页自己的 keep-alive)。所以候选表把当前端点剔掉,行内容由快照渲染。
*/
"use strict";
import { deviceLink } from './device-link.js';
import {
isValidIpv4, getLanDevices, saveLanDevices, getDeviceIp
} from './base-url.js';
import { escapeHtml, flashBusyNow } from './utils.js';
var PROBE_PATH = '/system/status?lite=1';
var BATCH_IPS = 64; // 一次 /api/lan/probe 的 IP 数(与服务侧 BATCH_MAX_IPS 一致)
var CLIENT_PARALLEL = 2; // 同时在途的批数(整段 /24 实测 ~3.5–6s)
var BATCH_TIMEOUT_MS = 6000; // 一批的 fetch 上限(服务侧一批最坏 ~1.8s)
var PER_IP_TIMEOUT_MS = 750; // 交给服务侧的单目标硬截止
var STALE_MS = 20000; // 结果"新鲜"窗口,过期行变灰、按钮改「尝试连接」
var SEED_IPS = ['192.168.31.127', '192.168.31.1'];
var MAX_ROWS = 40; // 未知/拒绝列表各截断到这里,但计数照全报
var state = {
open: false,
scanning: false,
cancel: false,
tier: '',
cidr: '',
local: null,
rows: [], // verdict=board
unknowns: [], // 连不上/读超时/形状合但落不进判据
rejected: [], // 有反证的
scan: null,
skipped: [],
detail: Object.create(null), // ip -> 完整 status(人点了「详情」才要)
direct: Object.create(null), // ip -> 本页直测结论(B 路证据)
note: null,
progress: { done: 0, total: 0 },
autoScanned: 0
};
/* ───────────────────────── 纯函数(不碰 DOM,便于推理与离线核对) ───────────────────────── */
/** @description 192.168.31.0/24 → 1..254 主机地址。只支持 /24(掩码更宽请直接填 CIDR)。 */
export function cidrToIps(cidr) {
var s = String(cidr || '').trim();
var bits = s.split('/');
var q = bits[0].split('.');
if (q.length !== 4 || !isValidIpv4(q.join('.'))) return { err: 'CIDR 不合法:' + s };
var mask = bits.length > 1 ? parseInt(bits[1], 10) : 24;
if (mask !== 24) return { err: '只支持 /24(当前 /' + mask + ');需要更小的段请逐个手填 IP' };
if (bits.length > 1 && q[3] !== '0') return { err: 'CIDR 网络地址段末应为 .0:' + s };
var base = q.slice(0, 3).join('.');
var ips = [];
for (var i = 1; i <= 254; i++) ips.push(base + '.' + i);
return { ips: ips, cidr: base + '.0/24' };
}
/** @description 私有网段判定(10/8、172.16/12、192.168/16)。 */
export function isPrivateIp(ip) {
var q = String(ip).split('.');
var a = parseInt(q[0], 10), b = parseInt(q[1], 10);
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
return false;
}
/**
* @description 候选表 = 去重 + 剔掉"探了也没意义"的地址。
* 被剔掉的每一条都带原因并显示出来——静默丢弃会让本机 :80 服务冒充板子。
* @param {{ips:string[], localIps?:string[], endpointIp?:string, allowPublic?:boolean}} o
*/
export function buildCandidates(o) {
var local = {};
(o.localIps || []).forEach(function (ip) { local[ip] = 1; });
var out = [], skipped = [], seen = {};
(o.ips || []).forEach(function (ip) {
var v = String(ip || '').trim();
if (!isValidIpv4(v)) { if (v) skipped.push([v, '不是 IPv4']); return; }
if (seen[v]) return;
seen[v] = 1;
if (/^127\./.test(v)) { skipped.push([v, '回环']); return; }
if (/^169\.254\./.test(v)) { skipped.push([v, '链路本地']); return; }
if (local[v]) { skipped.push([v, '本机地址']); return; }
if (o.endpointIp && o.endpointIp === v) {
skipped.push([v, '当前端点:由 1Hz 轮询判活,不重复开连接']); return;
}
if (!isPrivateIp(v) && !o.allowPublic) {
skipped.push([v, '非私有网段(要探请勾"允许公网段")']); return;
}
out.push(v);
});
return { ips: out, skipped: skipped };
}
/** @description 把服务侧逐 IP 结果分成三堆。判决本身来自服务侧,这里不重算。 */
export function groupResults(results) {
var g = { boards: [], unknowns: [], rejected: [] };
(results || []).forEach(function (r) {
if (!r || !r.ip) return;
/* 服务侧不返回时间戳,这里补上"本轮观测时刻"——否则行会被当成过期数据变灰。 */
if (!r.observedAt) r.observedAt = Date.now();
if (r.verdict === 'board') g.boards.push(r);
else if (r.verdict === 'rejected') g.rejected.push(r);
else g.unknowns.push(r);
});
return g;
}
/** @description 快扫种子 = 当前端点 + 历史默认 + 上次发现到的(都过一遍候选过滤)。 */
export function seedIps(endpointIp, lastSeen) {
var list = SEED_IPS.slice();
if (endpointIp) list.unshift(endpointIp);
(lastSeen || []).forEach(function (ip) { if (list.indexOf(ip) < 0) list.push(ip); });
return list.slice(0, 8);
}
/* ───────────────────────── 服务侧调用 ───────────────────────── */
async function apiProbe(body, timeoutMs) {
var resp = await fetch('/api/lan/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || BATCH_TIMEOUT_MS)
});
var text = await resp.text();
var j = null;
try { j = text ? JSON.parse(text) : null; } catch (_) {
throw new Error('探测响应非合法 JSON(HTTP ' + resp.status + ')');
}
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.json = j;
throw err;
}
return j || {};
}
/** @description 「本页直测」:让页面自己打一次 lite,证明*这个文档*能不能到它。
* 服务侧扫到(A)与页面能连(B)是两条独立证据,浏览器对 127.0.0.1→私有子网
* 的限制(Local Network Access)只会影响 B,所以这条要能单独出结论。 */
async function directProbe(ip, path, timeoutMs) {
var t0 = (window.performance || Date).now();
try {
var r = await fetch('http://' + ip + (path || PROBE_PATH), {
mode: 'cors', cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || 2500)
});
var j = null;
try { j = await r.json(); } catch (_) { j = null; }
return { ok: r.ok && j !== null, http: r.status, json: j, ms: Math.round((window.performance || Date).now() - t0) };
} catch (e) {
return {
ok: false,
name: (e && e.name) || 'Error',
msg: (e && e.message) || String(e),
ms: Math.round((window.performance || Date).now() - t0)
};
}
}
/* ───────────────────────── 扫描编排 ───────────────────────── */
function canStartScan() {
if (state.scanning) return '上一批还在跑(先取消或等它结束)';
/* 门放在发起处一次判完:刷写中扫段会抢设备 httpd 的 6 个 socket 并触发
* lru_purge 踢连接,等于把正在灌写的会话掐死。 */
if (flashBusyNow()) return '刷写/OTA 进行中,不发起扫描(会占设备 httpd 的 socket 并可能踢掉正在灌写的连接)';
return '';
}
async function runScan(tier) {
var refuse = canStartScan();
if (refuse) { say('warn', refuse); return; }
state.scanning = true;
state.cancel = false;
state.tier = tier;
state.rows = []; state.unknowns = []; state.rejected = [];
state.skipped = []; state.scan = null; state.note = null;
lastListHtml = null;
state.progress = { done: 0, total: 0 };
render();
var groups = { boards: [], unknowns: [], rejected: [] };
/* durationMs 是各批服务端耗时之和(并发下会大于真实等待),只用于对账;
* 给人看的"多久"用页面墙钟,别把累加值当耗时报。 */
var stats = { candidates: 0, tcpOpen: 0, httpProbed: 0, board: 0, rejected: 0, unknown: 0, durationMs: 0, wallMs: 0 };
var t0 = Date.now();
var err = '';
try {
var endpoint = deviceLink.getIp();
var lastSeen = getLanDevices().map(function (d) { return d.ip; });
// 一次空探测顺便取本机网段(不浪费往返;候选为 0 时服务侧立即返回)
var hello = await apiProbe({ ips: [], ports: [80], local: true }, 4000);
state.local = hello.local || null;
var localIps = (hello.local && hello.local.local_ips) || [];
function fullIps() {
var cidr = state.cidr || (hello.local && hello.local.cidr) || '';
if (!cidr) throw new Error('取不到本机默认路由,无法派生 /24。请在 CIDR 框手填(如 192.168.31.0/24)后重试。');
var c = cidrToIps(cidr);
if (c.err) throw new Error(c.err);
state.cidr = c.cidr;
return c.ips;
}
async function sweep(raw) {
var built = buildCandidates({
ips: raw, localIps: localIps, endpointIp: endpoint,
allowPublic: !!state.allowPublic
});
state.skipped = state.skipped.concat(built.skipped);
var ips = built.ips;
/* 当前端点不进候选表(由 Owner 的 1Hz 快照判活),但作为一行结果单独摆着。 */
stats.candidates += ips.length;
state.progress.total += ips.length;
render();
var queue = [];
for (var i = 0; i < ips.length; i += BATCH_IPS) queue.push(ips.slice(i, i + BATCH_IPS));
/* 批间并发 CLIENT_PARALLEL;服务侧一批内并发 48(跨不同地址,
* 一块板子全程只被占 1 个 socket)。JS 单线程,await 恢复后的累加是原子的。 */
async function worker() {
while (queue.length && !state.cancel) {
var batch = queue.shift();
if (!batch) break;
var out = await apiProbe({
ips: batch, ports: [80], path: PROBE_PATH,
timeout_ms: PER_IP_TIMEOUT_MS
});
var g = groupResults(out.results);
groups.boards = groups.boards.concat(g.boards);
groups.unknowns = groups.unknowns.concat(g.unknowns);
groups.rejected = groups.rejected.concat(g.rejected);
var s = out.scan || {};
['tcpOpen', 'httpProbed', 'board', 'rejected', 'unknown'].forEach(function (k) {
stats[k] = (stats[k] || 0) + (s[k] || 0);
});
stats.durationMs += (s.durationMs || 0);
if (s.truncated && s.pending && s.pending.length) {
state.skipped.push(['—', '本批预算用尽,未探 ' + s.pending.length + ' 个']);
}
(out.errors || []).forEach(function (e2) { state.skipped.push(['—', '服务侧: ' + e2]); });
state.progress.done = Math.min(state.progress.done + batch.length, state.progress.total);
state.rows = groups.boards; state.unknowns = groups.unknowns;
state.rejected = groups.rejected;
render();
}
}
var workers = [];
for (var w = 0; w < Math.max(1, CLIENT_PARALLEL); w++) workers.push(worker());
await Promise.all(workers);
}
if (tier === 'fast') {
await sweep(seedIps(endpoint, lastSeen));
/* 种子没命中就自动扩到整段:设备换 DHCP 地址正是最常见的失效原因,
* 而整段实测 ~4s(并发 48),没理由让用户再点一次按钮。 */
if (!groups.boards.length && !state.cancel) {
var seg = (state.local && state.local.cidr) || '/24';
state.skipped.push(['—', '种子未命中 → 自动扩到整段 ' + seg]);
say('warn', '种子里没有设备(多半是地址变了)——自动扩到整段 ' + seg);
await sweep(fullIps());
}
} else {
await sweep(fullIps());
}
if (state.cancel) say('warn', '已取消:已探测 ' + state.progress.done + '/'
+ state.progress.total + ' 个地址,结果保留在下面(取消只表示不再发下一批,在途那批仍会跑完)');
} catch (e) {
err = (e && e.message) || String(e);
}
state.scanning = false;
stats.wallMs = Date.now() - t0;
state.scan = stats;
if (err) state.note = { kind: 'error', text: err, at: Date.now() };
// 只把"确认是板子"的写回候选种子缓存;未知/拒绝的不配当种子。
persistSeen(groups.boards);
render();
if (!err) {
say('info', '扫描完成:候选 ' + stats.candidates + ' → 端口开 ' + stats.tcpOpen
+ ' → 板子 ' + groups.boards.length + ' 台,不可判定 ' + groups.unknowns.length
+ ',反证排除 ' + groups.rejected.length + '(用时 ' + stats.wallMs + 'ms)');
}
}
function persistSeen(boards) {
var prev = getLanDevices();
var now = Date.now();
var byIp = {};
(boards || []).forEach(function (b) { byIp[b.ip] = { ip: b.ip, observedAt: now, mode: b.mode, kind: b.kind }; });
// 本轮没探到的旧条目保留但不再刷新 observedAt —— 它会自然变灰,不会假装"还在"
prev.forEach(function (p) { if (!byIp[p.ip]) byIp[p.ip] = p; });
saveLanDevices(Object.keys(byIp).map(function (k) { return byIp[k]; }));
}
/* ───────────────────────── 反馈 ───────────────────────── */
function say(kind, text) {
state.note = { kind: kind, text: text, at: Date.now() };
renderNote();
var lvl = kind === 'error' ? 'E' : (kind === 'warn' ? 'W' : 'I');
/* 外壳文档(tabs.html)没有日志面板也没有 toast 容器,所以面板自己的这行
* 是**兜底的、必定可见的**出口;页面的 log/toast 只是额外一份。 */
if (typeof window.addLog === 'function') {
try { window.addLog(lvl, '设备', text); } catch (_) {}
}
if (kind !== 'info' && typeof window.showToast === 'function') {
try { window.showToast(text, kind === 'error' ? 'error' : 'warning', { force: true }); } catch (_) {}
}
}
/* ───────────────────────── 渲染 ───────────────────────── */
var pillEl = null, pillTxtEl = null, panelEl = null;
/* 面板每 2s 会被定时器刷一次;列表 HTML 不变就不重排 DOM(否则输入焦点/滚动
会被重建打掉)。扫描开始、选中设备时显式作废。 */
var lastListHtml = null;
function ageText(ts) {
if (!ts) return '—';
var s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return s + 's 前';
if (s < 3600) return Math.round(s / 60) + 'min 前';
return Math.round(s / 3600) + 'h 前';
}
function boardLabel(b) {
var d = state.detail[b.ip];
var bits = [b.kind === 'ap' ? 'AP 回退' : 'STA'];
if (d) {
bits.push('v' + (d.version || '?'));
if (d.git_hash) bits.push(d.git_hash.slice(0, 7));
if (d.wifi && Number.isFinite(d.wifi.rssi)) bits.push(d.wifi.rssi + 'dBm');
if (Number.isFinite(d.uptime_sec)) bits.push('up ' + Math.round(d.uptime_sec) + 's');
} else if (b.schema === 'core-only') {
bits.push('旧固件(无 gd32_features)');
}
if (b.schema === 'full' && !d) bits.push('schema 全');
return bits.join(' · ');
}
function rowHtml(b) {
var isSel = b.ip === deviceLink.getIp();
var stale = !b.observedAt || (Date.now() - b.observedAt) > STALE_MS;
var d = state.direct[b.ip];
/* 行用 layout.css 的 .jump-history-item(.jh-tag / .jh-msg / .jh-time 与
.ok/.info 状态色都是既有类),这里只加"可点选"与过期两种修饰。 */
var h = '<li class="jump-history-item ok' + (isSel ? ' lan-cur' : '')
+ (stale ? ' lan-stale' : '') + '" data-ip="' + escapeHtml(b.ip) + '" title="'
+ (stale ? '尝试连接(结果已过期)' : '设为当前设备') + '">';
h += '<span class="jh-tag">' + escapeHtml(b.kind === 'ap' ? 'AP' : 'STA') + '</span>';
h += '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(b.ip) + '</span> '
+ '<span class="lan-meta">' + escapeHtml(boardLabel(b)) + '</span></span>';
h += '<span class="lan-acts">'
+ '<button class="btn btn-ghost btn-sm" data-act="detail" data-ip="' + escapeHtml(b.ip) + '">详情</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="direct" data-ip="' + escapeHtml(b.ip) + '">本页直测</button>'
+ '</span>';
h += '<span class="jh-time">' + escapeHtml(ageText(b.observedAt)) + '</span>';
if (d) {
h += '<span class="lan-sub">' + (d.ok
? '本页可达 ✓ HTTP ' + d.http + ' · ' + d.ms + 'ms'
: '本页取不到:' + escapeHtml(d.name + ' ' + d.msg)
+ '(服务侧扫到了,多半是浏览器对本机访问私有网段的限制)')
+ '</span>';
}
h += '</li>';
return h;
}
function renderPill() {
if (!pillEl) return;
var snap = deviceLink.getSnapshot();
var ip = (snap && snap.ip) || deviceLink.getIp();
var st = snap && snap.esp32Status;
var on = !!(snap && snap.links && snap.links.esp32 && snap.links.esp32.online);
var txt = ip;
if (st && st.version) txt += ' · v' + st.version;
if (st && st.wifi && Number.isFinite(st.wifi.rssi)) txt += ' · ' + st.wifi.rssi + 'dBm';
/* 顶栏这颗就是各页在用的 .pill 组件(online/offline/processing 是既有状态类),
不另造配色。mismatch 用 processing(琥珀)而不是 offline:设备是活的,
只是"不像我选的那台"。 */
var cls = 'pill ' + (snap && snap.endpointCheck === 'mismatch' ? 'processing'
: (on ? 'online' : 'offline'));
if (pillTxtEl && pillTxtEl.textContent !== txt) pillTxtEl.textContent = txt;
if (pillEl.className !== cls) pillEl.className = cls;
pillEl.title = (snap && snap.endpointCheckNote)
? ('端点核对:' + snap.endpointCheck + ' — ' + snap.endpointCheckNote)
: ('端点核对:' + ((snap && snap.endpointCheck) || 'unverified'));
}
function renderNote() {
var n = panelEl && panelEl.querySelector('.lan-note');
if (!n) return;
var t = state.note ? state.note.text : '';
var cls = 'lan-note' + (state.note && state.note.kind !== 'info' ? ' lan-note-' + state.note.kind : '');
if (n.textContent !== t) n.textContent = t;
if (n.className !== cls) n.className = cls;
}
function render() {
if (!panelEl) { renderPill(); return; }
var mask = panelEl.querySelector('.lan-mask');
if (mask) mask.classList.toggle('show', !!state.open);
renderPill();
var head = panelEl.querySelector('.lan-ep');
if (head) {
var snap = deviceLink.getSnapshot();
var ec = snap ? snap.endpointCheck : 'unverified';
var t = '当前端点 ' + getDeviceIp() + ' · 核对 ' + ec
+ ((snap && snap.endpointCheckNote) ? ':' + snap.endpointCheckNote : '');
if (head.textContent !== t) head.textContent = t;
head.className = 'lan-ep' + (ec === 'mismatch' ? ' lan-note-warn' : '');
}
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn && cidrIn !== document.activeElement && cidrIn.value !== state.cidr) {
cidrIn.value = state.cidr || '';
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) pub.checked = !!state.allowPublic;
var prog = panelEl.querySelector('.lan-progress');
if (prog) {
var ptxt = state.scanning
? ('扫描中… ' + state.progress.done + '/' + state.progress.total
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length)
: (state.scan
? ('上一轮:候选 ' + state.scan.candidates + ' · 端口开 ' + state.scan.tcpOpen
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length
+ ' · 反证排除 ' + state.rejected.length + ' · 用时 ' + state.scan.wallMs + 'ms')
: '还没扫。快扫只打几个已知地址(≈1s);全段扫整条 /24(≈10s)。');
if (prog.textContent !== ptxt) prog.textContent = ptxt;
prog.classList.toggle('lan-busy', !!state.scanning);
}
var list = panelEl.querySelector('.lan-list');
if (list) {
var rows = state.rows.slice(0, MAX_ROWS);
var snapNow = deviceLink.getSnapshot();
var epIp = deviceLink.getIp();
var html = '';
if (snapNow) {
var on = snapNow.links && snapNow.links.esp32 && snapNow.links.esp32.online;
var st2 = snapNow.esp32Status || {};
html += '<li class="jump-history-item info lan-cur">'
+ '<span class="jh-tag">当前</span>'
+ '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(epIp) + '</span> '
+ '<span class="lan-meta">' + (on ? '在线' : '离线(连续 3 次拉不到 status)')
+ (st2.version ? ' · v' + escapeHtml(st2.version) : '')
+ (st2.wifi && Number.isFinite(st2.wifi.rssi) ? ' · ' + st2.wifi.rssi + 'dBm' : '')
+ (Number.isFinite(st2.uptime_sec) ? ' · up ' + Math.round(st2.uptime_sec) + 's' : '')
+ '</span></span></li>';
}
html += rows.map(function (b) { return rowHtml(b); }).join('');
if (state.rows.length > MAX_ROWS) {
html += '<li class="lan-cut">列表截断到 ' + MAX_ROWS + ' 行,实际 ' + state.rows.length + ' 台</li>';
}
if (!rows.length && !state.scanning) {
html += '<li class="lan-empty">本轮没扫到板子。别急着当成"板子没了",先看下面的计数与反证列表:</li>';
}
if (lastListHtml !== html) { list.innerHTML = html; lastListHtml = html; }
}
var misc = panelEl.querySelector('.lan-misc');
if (misc) {
var m = '';
if (state.unknowns.length) {
m += '<div class="lan-block"><b>不可判定 ' + state.unknowns.length + ' 个</b>'
+ '(连不上 / 读超时 / 形状合但落不进判据)——超时不等于"不在",'
+ 'ECAT 环跑与刷写期间设备 httpd 本来就可能不响应。<ul>'
+ state.unknowns.slice(0, MAX_ROWS).map(function (u) {
return '<li>' + escapeHtml(u.ip) + ' — ' + escapeHtml(u.reason || '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.rejected.length) {
m += '<div class="lan-block"><b>反证排除 ' + state.rejected.length + ' 个</b>(有证据不是板子,不是"没扫到")<ul>'
+ state.rejected.slice(0, MAX_ROWS).map(function (r) {
return '<li>' + escapeHtml(r.ip) + ':' + (r.port || 80) + ' — '
+ escapeHtml(r.reason || '') + (r.head ? ' 「' + escapeHtml(String(r.head).slice(0, 60)) + '」' : '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.skipped.length) {
m += '<div class="lan-block"><b>剔掉 ' + state.skipped.length + ' 个</b><ul>'
+ state.skipped.map(function (s) {
return '<li>' + escapeHtml(String(s[0])) + ' — ' + escapeHtml(String(s[1])) + '</li>';
}).join('') + '</ul></div>';
}
if (state.local) {
m += '<div class="lan-block">本机:' + escapeHtml(state.local.default_src_ip || '?')
+ ' → 段 ' + escapeHtml(state.local.cidr || '(取不到)')
+ ';本机地址 ' + escapeHtml((state.local.local_ips || []).join(', ') || '—') + '</div>';
}
m += '<div class="lan-block lan-hint">两块同款板除了 IP 无法区分(固件不上报 mac/chip_id)。'
+ '认领办法:选中一行看「详情」的 uptime,或按板上复位键后立刻快扫——新上电那台 uptime &lt; 30s。'
+ 'AP 回退的板子自称 192.168.31.1:PC 还连着路由器 WLAN 时它在三层上不可达,'
+ '要真人连热点 ESP32_OTA_RELAY 才探得到。</div>';
if (misc.innerHTML !== m) misc.innerHTML = m;
}
renderNote();
var busy = panelEl.querySelector('[data-act="fast"]');
if (busy) busy.disabled = state.scanning;
var full = panelEl.querySelector('[data-act="full"]');
if (full) full.disabled = state.scanning;
var cancelBtn = panelEl.querySelector('[data-act="cancel"]');
if (cancelBtn) cancelBtn.style.display = state.scanning ? '' : 'none';
}
/* ───────────────────────── 挂载 ───────────────────────── */
var PANEL_HTML =
'<div class="lan-mask" role="dialog" aria-modal="true" aria-label="局域网设备发现">'
+ '<div class="lan-box" tabindex="-1">'
+ '<div class="lan-head"><span>设备发现</span>'
+ '<span class="lan-ep"></span>'
+ '<button class="btn btn-ghost btn-sm" data-act="close">关闭 (Esc)</button>'
+ '</div>'
+ '<div class="lan-body">'
+ '<div class="lan-tools">'
+ '<button class="btn btn-primary btn-sm" data-act="fast">快扫</button>'
+ '<button class="btn btn-outline btn-sm" data-act="full">全段扫描</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="cancel">取消</button>'
+ '<span class="lan-field"><label class="form-label" for="lanCidr">CIDR</label>'
+ '<input class="input lan-cidr" id="lanCidr" type="text" placeholder="留空 = 本机 /24"></span>'
+ '<label class="lan-check"><input class="lan-pub" type="checkbox"> 允许公网段</label>'
+ '</div>'
+ '<div class="lan-progress"></div>'
+ '<ul class="lan-list" role="listbox" aria-label="发现的设备"></ul>'
+ '<div class="lan-note"></div>'
+ '<div class="lan-misc"></div>'
+ '</div>'
+ '</div>'
+ '</div>';
function bindPanel() {
panelEl.addEventListener('click', async function (ev) {
/* 遮罩空白处(就是 .lan-mask 自己)= 收起;行内按钮先被 data-act 接走。 */
if (ev.target && ev.target.classList && ev.target.classList.contains('lan-mask')) {
state.open = false;
render();
return;
}
var t = ev.target.closest ? ev.target.closest('[data-act],[data-ip]') : null;
if (!t) return;
var act = t.getAttribute('data-act');
var ip = t.getAttribute('data-ip') || '';
if (act === 'close') { state.open = false; render(); return; }
if (act === 'fast') { runScan('fast'); return; }
if (act === 'full') {
var c = panelEl.querySelector('.lan-cidr');
state.cidr = c && c.value ? c.value.trim() : '';
runScan('full');
return;
}
if (act === 'cancel') {
state.cancel = true;
say('warn', '取消中:不再发下一批;在途那批(≤' + BATCH_IPS + ' 个地址)仍会跑完');
render();
return;
}
if (act === 'detail') {
say('info', '取 ' + ip + ' 的完整 status…');
var r = await directProbe(ip, '/system/status', 3000);
if (r.ok && r.json) {
state.detail[ip] = r.json;
state.note = null;
} else {
state.note = { kind: 'warn', text: ip + ' 完整 status 取不到:'
+ (r.name || ('HTTP ' + r.http)) + ' ' + (r.msg || ''), at: Date.now() };
}
render();
return;
}
if (act === 'direct') {
var d = await directProbe(ip, PROBE_PATH, 2500);
state.direct[ip] = d;
render();
return;
}
/* 行本体 = 选择。唯一的写手是 deviceLink.selectDevice,门也在它里面。 */
if (ip) {
var rec = { ip: ip, source: 'lan' };
var det = state.detail[ip];
if (det && det.version) { rec.version = det.version; rec.git_hash = det.git_hash; }
var res = deviceLink.selectDevice(rec);
if (!res.ok) { say('error', '未切换:' + (res.reason || '未知原因')); return; }
say('info', '已选 ' + ip + ' —— 页面能不能连它,看顶栏灯(Owner 下一次 1Hz 轮询即出,≤1s)');
lastListHtml = null;
render();
}
});
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn) {
cidrIn.addEventListener('change', function () { state.cidr = cidrIn.value.trim(); });
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) {
pub.addEventListener('change', function () { state.allowPublic = pub.checked; });
}
}
function mountHost(anchor) {
panelEl = document.createElement('div');
panelEl.innerHTML = PANEL_HTML;
(anchor || document.body).appendChild(panelEl);
bindPanel();
/* 弹出式:遮罩铺满 + 周围高斯模糊;点遮罩空白处 / Esc / 关闭按钮都收起。 */
function openPanel() {
state.open = true;
render();
var box = panelEl.querySelector('.lan-box');
if (box && box.focus) { try { box.focus({ preventScroll: true }); } catch (_) { box.focus(); } }
if (!state.scan && !state.scanning) runScan('fast');
}
function closePanel() { state.open = false; render(); }
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && state.open) { state.cancel = true; closePanel(); }
});
pillEl = document.getElementById('lanPill');
pillTxtEl = document.getElementById('lanPillTxt');
var btn = document.getElementById('lanScanBtn');
if (btn) {
btn.addEventListener('click', function () {
if (state.open) closePanel(); else openPanel();
});
}
/* 外壳是扫描的宿主:iframe 里的入口只发消息叫它开面板。 */
window.addEventListener('message', function (ev) {
var d = ev && ev.data;
if (!d || d.__dcuLan !== 'open') return;
openPanel();
});
/* Owner 判离线后的一次性提候选:只扫种子,绝不自动改端点。 */
deviceLink.onAutoScan(function () {
if (state.scanning) return;
var now = Date.now();
if (now - state.autoScanned < 60000) return;
state.autoScanned = now;
say('warn', '当前端点连续拉不到 status,自动补一轮快扫(只列候选,不自动换设备)');
runScan('fast');
});
deviceLink.subscribe(function () { renderPill(); });
render();
setInterval(function () {
if (state.open) render(); else renderPill();
}, 2000);
}
function mountGuest() {
var btn = document.getElementById('lanScanBtnPage') || document.getElementById('lanScanBtn');
if (!btn) return;
btn.addEventListener('click', function () {
try {
window.parent.postMessage({ __dcuLan: 'open' }, window.location.origin);
} catch (e) {
say('error', '叫不开外壳面板:' + (e && e.message ? e.message : e));
}
});
}
function boot() {
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
/* file:// 下 localStorage 与 http://127.0.0.1:2625 是两份,flashBusyNow()
* 会静默失效、门成空的。不挂载,直接说清楚怎么打开。 */
var body = document.body;
if (body) {
var w = document.createElement('div');
w.className = 'lan-note lan-note-error';
w.textContent = '设备发现未挂载:页面必须经本地服务打开(双击 esp32_app.vbs,或 http://127.0.0.1:2625/tabs.html)。';
body.insertBefore(w, body.firstChild);
}
return;
}
if (window.self === window.top) {
mountHost(document.getElementById('lanMount'));
} else {
mountGuest();
}
}
boot();