Files
DCU_DesktopController/js/common/lan-picker.js
T

704 lines
31 KiB
JavaScript
Raw Normal View History

/**
* 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();