init: DCU ESP32 调试控制台前端与本地页面服务
纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、 不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。 - tabs.html 单窗口多标签外壳, iframe 承载各功能页 - index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble - can.html / can_monitor.html CAN 通信与接收监控, js/can - motor.html 电机 CAN FD 控制台, js/motor - continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含) - 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试 - 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、 .gitattributes(* -text 保持 Windows 换行原样)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* base-url.js - 设备 IP / 基础 URL 公共助手
|
||||
*
|
||||
* 各页面共用 localStorage key dcu_esp_ip(OTA / CAN / 电机页一致)。
|
||||
* 本模块只提供纯 localStorage + 默认值的通用实现;页面特有的输入框回退
|
||||
* (如 CAN 页 el.toolIp、OTA 页 #espIp)由各自入口在此基础上包装。
|
||||
*/
|
||||
export var LS_ESP_IP = 'dcu_esp_ip';
|
||||
export var DEFAULT_ESP_IP = '192.168.31.127';
|
||||
|
||||
/** @description 读取 localStorage 中保存的设备 IP,无有效值返回空串。 */
|
||||
export function getSavedDeviceIp() {
|
||||
try {
|
||||
var saved = localStorage.getItem(LS_ESP_IP);
|
||||
if (saved && saved.trim()) return saved.trim();
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @description 保存设备 IP 到 localStorage(空值忽略)。 */
|
||||
export function saveDeviceIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
|
||||
}
|
||||
|
||||
/** @description 设备 IP:localStorage 优先,否则默认值。 */
|
||||
export function getDeviceIp() {
|
||||
return getSavedDeviceIp() || DEFAULT_ESP_IP;
|
||||
}
|
||||
|
||||
/** @description 拼接设备基础 URL(http://<ip>)。 */
|
||||
export function getBaseUrl() {
|
||||
return 'http://' + getDeviceIp();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* heap-low-history.js - 堆"最低空闲创新低"记录的浏览器端持久化
|
||||
*
|
||||
* 目的:设备 RAM 只保留最新 1 条创新低;长时间挂机早期记录会被覆盖。
|
||||
* 前端定时拉 /system/heap_low_log,发现新记录就追加到 IndexedDB,
|
||||
* 弹窗里合并显示「设备当前 1 条 + 浏览器累积历史」,掉电/重启不丢本地史。
|
||||
*
|
||||
* 存储设计:
|
||||
* - 单 objectStore 'heap_low_records',keyPath 'id'(自增)
|
||||
* - 每条记录附前端写入时的时间戳 saved_at_ms(wall clock)
|
||||
* - 设备重启会重置 ts_ms(启动后毫秒)和 min_free_heap(水位清零),
|
||||
* 所以去重 key 用 (ts_ms, min_free_heap, free_heap, alloc_count) 组合,
|
||||
* 重启后第一条 min_free_heap 必然高于上一次会话最低值 → 自然不会误并入。
|
||||
* - 上限 HEAP_HISTORY_MAX 条,超出按 saved_at_ms 升序删最早的。
|
||||
*/
|
||||
|
||||
var IDB_NAME = 'esp_dcu_heap_low';
|
||||
var IDB_STORE = 'heap_low_records';
|
||||
var IDB_VERSION = 1; // 独立库,与 file-handle.js 的 dcu_ota_fw_v1 不冲突
|
||||
/** 浏览器可保存的创新低条数上限(设备仅 1 槽;多条全靠这里)。 */
|
||||
export var HEAP_HISTORY_MAX = 500;
|
||||
|
||||
var _dbPromise = null;
|
||||
|
||||
function openDb() {
|
||||
if (!_dbPromise) {
|
||||
_dbPromise = new Promise(function (resolve, reject) {
|
||||
if (!window.indexedDB) {
|
||||
reject(new Error('indexedDB unavailable'));
|
||||
return;
|
||||
}
|
||||
var req = indexedDB.open(IDB_NAME, IDB_VERSION);
|
||||
req.onupgradeneeded = function () {
|
||||
var db = req.result;
|
||||
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
||||
var store = db.createObjectStore(IDB_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
// 去重查询索引:同一会话内 (ts_ms,min_free_heap,free_heap,alloc_count) 唯一
|
||||
store.createIndex('dedup', ['ts_ms', 'min_free_heap', 'free_heap', 'alloc_count'], { unique: false });
|
||||
store.createIndex('saved_at', 'saved_at_ms', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
|
||||
});
|
||||
_dbPromise.catch(function () { _dbPromise = null; });
|
||||
}
|
||||
return _dbPromise;
|
||||
}
|
||||
|
||||
/** 计算单条记录的去重 key 字符串(用于 in-memory 比对,避免每次都走 IDB 索引) */
|
||||
function dedupKey(r) {
|
||||
return r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0);
|
||||
}
|
||||
|
||||
/** 把设备拉到的一批 records(最新在前)增量写入 IndexedDB,去重。
|
||||
* 返回 Promise<number>:本次实际新增的条数。 */
|
||||
export function appendHeapLowRecords(records) {
|
||||
if (!Array.isArray(records) || !records.length) return Promise.resolve(0);
|
||||
return openDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
var store = tx.objectStore(IDB_STORE);
|
||||
var idx = store.index('dedup');
|
||||
var newCount = 0;
|
||||
var savedAt = Date.now();
|
||||
|
||||
// 顺序处理:每条先查 dedup 索引,不存在则 put
|
||||
var i = 0;
|
||||
function next() {
|
||||
if (i >= records.length) {
|
||||
// 全部处理完后再裁剪到上限
|
||||
pruneToMax(store, function () {
|
||||
tx.oncomplete ? null : null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
var r = records[i++];
|
||||
var key = [r.ts_ms, r.min_free_heap, r.free_heap, r.alloc_count || 0];
|
||||
var getReq = idx.get(key);
|
||||
getReq.onsuccess = function () {
|
||||
if (!getReq.result) {
|
||||
// 新记录:写入
|
||||
var rec = Object.assign({}, r, { saved_at_ms: savedAt });
|
||||
var putReq = store.put(rec);
|
||||
putReq.onsuccess = function () { newCount++; next(); };
|
||||
putReq.onerror = function () { next(); }; // 单条失败不阻断
|
||||
} else {
|
||||
next(); // 已存在跳过
|
||||
}
|
||||
};
|
||||
getReq.onerror = function () { next(); };
|
||||
}
|
||||
next();
|
||||
|
||||
tx.oncomplete = function () { resolve(newCount); };
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
tx.onabort = function () { reject(tx.error || new Error('tx aborted')); };
|
||||
});
|
||||
}).catch(function (e) {
|
||||
// IndexedDB 不可用(无痕模式等)静默降级
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 删最早的记录直到条数 <= HEAP_HISTORY_MAX。需在 readwrite tx 内调用。 */
|
||||
function pruneToMax(store, done) {
|
||||
var countReq = store.count();
|
||||
countReq.onsuccess = function () {
|
||||
var total = countReq.result;
|
||||
if (total <= HEAP_HISTORY_MAX) { done(); return; }
|
||||
var toRemove = total - HEAP_HISTORY_MAX;
|
||||
// 按 saved_at 索引升序游标删前 toRemove 条
|
||||
var idx = store.index('saved_at');
|
||||
var curReq = idx.openCursor();
|
||||
var removed = 0;
|
||||
curReq.onsuccess = function (ev) {
|
||||
var cursor = ev.target.result;
|
||||
if (!cursor || removed >= toRemove) { done(); return; }
|
||||
var delReq = store.delete(cursor.primaryKey);
|
||||
delReq.onsuccess = function () { removed++; cursor.continue(); };
|
||||
delReq.onerror = function () { cursor.continue(); };
|
||||
};
|
||||
curReq.onerror = function () { done(); };
|
||||
};
|
||||
countReq.onerror = function () { done(); };
|
||||
}
|
||||
|
||||
/** 读全部累积历史,按设备时间 ts_ms 升序(旧的在前),便于弹窗"历史段"显示。
|
||||
* 返回 Promise<Array>。IndexedDB 不可用时返回 []。 */
|
||||
export function loadHeapLowHistory() {
|
||||
return openDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(IDB_STORE, 'readonly');
|
||||
var store = tx.objectStore(IDB_STORE);
|
||||
var all = [];
|
||||
var curReq = store.openCursor();
|
||||
curReq.onsuccess = function (ev) {
|
||||
var cursor = ev.target.result;
|
||||
if (!cursor) return;
|
||||
all.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
curReq.onerror = function () { reject(curReq.error); };
|
||||
tx.oncomplete = function () {
|
||||
// 按 ts_ms 升序;同 ts_ms 时按 saved_at 升序(保证稳定)
|
||||
all.sort(function (a, b) {
|
||||
if (a.ts_ms !== b.ts_ms) return a.ts_ms - b.ts_ms;
|
||||
return (a.saved_at_ms || 0) - (b.saved_at_ms || 0);
|
||||
});
|
||||
resolve(all);
|
||||
};
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
}).catch(function () { return []; });
|
||||
}
|
||||
|
||||
/** 清空全部历史(用于"清空"按钮,可选实现)。返回 Promise<boolean>。 */
|
||||
export function clearHeapLowHistory() {
|
||||
return openDb().then(function (db) {
|
||||
return new Promise(function (resolve) {
|
||||
var tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
var req = tx.objectStore(IDB_STORE).clear();
|
||||
req.onsuccess = function () { resolve(true); };
|
||||
req.onerror = function () { resolve(false); };
|
||||
});
|
||||
}).catch(function () { return false; });
|
||||
}
|
||||
|
||||
/** 返回当前累积条数(仅用于 UI 提示,不需要数据)。返回 Promise<number>。 */
|
||||
export function countHeapLowHistory() {
|
||||
return openDb().then(function (db) {
|
||||
return new Promise(function (resolve) {
|
||||
var tx = db.transaction(IDB_STORE, 'readonly');
|
||||
var req = tx.objectStore(IDB_STORE).count();
|
||||
req.onsuccess = function () { resolve(req.result || 0); };
|
||||
req.onerror = function () { resolve(0); };
|
||||
});
|
||||
}).catch(function () { return 0; });
|
||||
}
|
||||
|
||||
/** 计算给定 records 数组(设备拉到的最新在前)与参考集合(已存历史)相比的新条数。
|
||||
* 纯内存比对,不写库,用于先判断是否需要写。 */
|
||||
export function diffNewCount(records, existingHistory) {
|
||||
if (!Array.isArray(records) || !records.length) return 0;
|
||||
var seen = {};
|
||||
if (Array.isArray(existingHistory)) {
|
||||
for (var i = 0; i < existingHistory.length; ++i) {
|
||||
seen[dedupKey(existingHistory[i])] = true;
|
||||
}
|
||||
}
|
||||
var n = 0;
|
||||
for (var j = 0; j < records.length; ++j) {
|
||||
if (!seen[dedupKey(records[j])]) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* log.js - 日志系统(带去重 + 自动滚动 + 上限裁剪)
|
||||
*
|
||||
* 日志环形缓冲、去重、过滤、自动滚动逻辑。
|
||||
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
|
||||
*/
|
||||
import { formatTime, escapeHtml } from './utils.js';
|
||||
|
||||
// ==================== 日志系统状态 ====================
|
||||
var MAX_LOGS = 1000; // 缓冲上限,超出后丢弃最旧条目
|
||||
var logFilter = 'all';
|
||||
var logEntries = [];
|
||||
var lastLogHash = null;
|
||||
var autoScroll = true;
|
||||
|
||||
// 缓存 DOM 引用,避免高频日志下重复 getElementById
|
||||
var scrollEl = document.getElementById('logScroll');
|
||||
var countEl = document.getElementById('logCount');
|
||||
var scrollPending = false;
|
||||
|
||||
/** rAF 批量自动滚动:/logs 一轮可能追加几十行,逐行读 scrollHeight 会每行
|
||||
* 触发一次强制布局;合并到下一帧只滚一次,行为不变。 */
|
||||
function scheduleAutoScroll() {
|
||||
if (!autoScroll || !scrollEl || scrollPending) return;
|
||||
scrollPending = true;
|
||||
requestAnimationFrame(function () {
|
||||
scrollPending = false;
|
||||
if (autoScroll && scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 日志系统 (带去重 + 自动滚动) ====================
|
||||
function makeLogHash(level, tag, message) {
|
||||
return level + '|' + tag + '|' + message;
|
||||
}
|
||||
/** 构造一条日志 DOM 节点(tag/message 经 HTML 转义,防 XSS)。 */
|
||||
function createLogLine(entry) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
div.innerHTML =
|
||||
'<span class="lt">[' + escapeHtml(entry.time) + ']</span>' +
|
||||
'<span class="lv ' + escapeHtml(entry.level) + '">' + escapeHtml(entry.level) + '</span>' +
|
||||
'<span class="lg">[' + escapeHtml(entry.tag) + ']</span>' +
|
||||
'<span class="lm">' + escapeHtml(entry.message) + '</span>';
|
||||
return div;
|
||||
}
|
||||
/** 同步 #logCount 为当前可见条目数。 */
|
||||
function syncCount() {
|
||||
if (countEl) countEl.textContent = logEntries.length;
|
||||
}
|
||||
/** 超过上限时丢弃最旧条目,并同步移除其 DOM 节点。 */
|
||||
function trimIfNeeded() {
|
||||
while (logEntries.length > MAX_LOGS) {
|
||||
var old = logEntries.shift();
|
||||
if (old && old.div && old.div.parentNode) old.div.parentNode.removeChild(old.div);
|
||||
}
|
||||
}
|
||||
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
|
||||
export function addLog(level, tag, message) {
|
||||
var hash = makeLogHash(level, tag, message);
|
||||
if (hash === lastLogHash) return; // 去重
|
||||
lastLogHash = hash;
|
||||
var entry = {
|
||||
time: formatTime(new Date()),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
div: null
|
||||
};
|
||||
logEntries.push(entry);
|
||||
trimIfNeeded();
|
||||
if (logFilter !== 'all' && level !== logFilter) {
|
||||
syncCount();
|
||||
return;
|
||||
}
|
||||
var div = createLogLine(entry);
|
||||
entry.div = div;
|
||||
if (scrollEl) {
|
||||
scrollEl.appendChild(div);
|
||||
scheduleAutoScroll();
|
||||
}
|
||||
syncCount();
|
||||
}
|
||||
export function clearLogs() {
|
||||
if (scrollEl) scrollEl.innerHTML = '';
|
||||
logEntries = [];
|
||||
lastLogHash = null;
|
||||
syncCount();
|
||||
}
|
||||
export function setLogFilter(level, btn) {
|
||||
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
|
||||
if (btn) btn.classList.add('active');
|
||||
logFilter = level;
|
||||
if (!scrollEl) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
logEntries.forEach(function (e) {
|
||||
if (level === 'all' || e.level === level) {
|
||||
var div = createLogLine(e);
|
||||
e.div = div;
|
||||
frag.appendChild(div);
|
||||
} else {
|
||||
e.div = null;
|
||||
}
|
||||
});
|
||||
scrollEl.innerHTML = '';
|
||||
scrollEl.appendChild(frag);
|
||||
scheduleAutoScroll();
|
||||
}
|
||||
export function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
var btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
|
||||
*
|
||||
* 递归 setTimeout 调度 /system/status + 递归 setTimeout 调度 /logs,避免 setInterval 慢响应被 abort / 重叠抢 socket。
|
||||
*
|
||||
* 模块级状态(仅本模块使用,不导出):
|
||||
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / logsInFlight / statusPollIntervalMs
|
||||
* uploadPollChip / pageVisible
|
||||
*
|
||||
* 共享状态(通过 window.* 引用):
|
||||
* window.pollingPausedForUpload — 上传期间暂停轮询标志
|
||||
* window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入)
|
||||
* window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入)
|
||||
* window.lastRelayState — 上次成功的 relay_state(connection.js 写入)
|
||||
* window.isEsp32Rebooting — ESP32 重启中标志
|
||||
* window.rebootStartTime — 重启开始时间戳
|
||||
* window.rebootObservedDown — 已观察到设备下线
|
||||
* window.otaSessionState — OTA 会话状态机当前状态
|
||||
* window.OtaSessionState — OTA 会话状态枚举
|
||||
* window.activeUploadChip — 当前上传目标芯片
|
||||
* window.currentOtaTarget — 当前 OTA 目标
|
||||
*
|
||||
* 复用的函数(通过 window.* 引用):
|
||||
* window.getEspIp / window.getBaseUrl
|
||||
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
|
||||
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
|
||||
* window.applyDeviceBusyRuntimeState
|
||||
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
|
||||
* window.updateUploadButtonLabel
|
||||
*/
|
||||
import { addLog } from './log.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { updateConnectionUi, diagnoseNetwork } from './connection.js';
|
||||
|
||||
// ==================== 状态码常量(与后端 ota_coordinator.h / ota_relay.h 枚举对齐) ====================
|
||||
// sys_state_code
|
||||
const SYS_GD32_STORING = 1;
|
||||
const SYS_GD32_RELAYING = 3;
|
||||
const SYS_ESP32_RECEIVING = 5;
|
||||
const SYS_ESP32_FINALIZING = 6;
|
||||
const SYS_ESP32_REBOOTING = 7;
|
||||
// relay_state(1~4 为握手/协商/传输/收尾,非 IDLE)
|
||||
const RELAY_ACTIVE_MIN = 1;
|
||||
const RELAY_ACTIVE_MAX = 4;
|
||||
|
||||
// ==================== 轮询状态 ====================
|
||||
// 共享状态(初始化到 window,供 connection.js 等其他模块读写)
|
||||
window.consecutivePollFailures = 0;
|
||||
window.lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙
|
||||
window.lastRelayState = 0; // 上次成功的 relay_state
|
||||
|
||||
// 模块级状态(仅本模块使用)
|
||||
var statusTimerId = null;
|
||||
var logsTimerId = null;
|
||||
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(5s 超时用)
|
||||
var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
|
||||
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
|
||||
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
|
||||
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
|
||||
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
|
||||
|
||||
/**
|
||||
* 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
|
||||
* 结束后恢复 1000ms,避免常态堆/栈卡片过刷。
|
||||
*/
|
||||
export function setStatusPollIntervalMs(ms) {
|
||||
var n = Number(ms);
|
||||
if (!Number.isFinite(n) || n < 200) n = 1000;
|
||||
if (n > 5000) n = 5000;
|
||||
statusPollIntervalMs = n;
|
||||
}
|
||||
|
||||
/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。
|
||||
* 默认 delay = statusPollIntervalMs;pollStatus 完成后会按「固定周期」
|
||||
* 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
|
||||
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
|
||||
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
|
||||
* ★ 上传暂停期间(window.pollingPausedForUpload)不重挂定时器,
|
||||
* 避免在途请求的 finally 把刚清掉的轮询又挂回来。
|
||||
*/
|
||||
export function scheduleStatusPoll(delayMs) {
|
||||
if (window.pollingPausedForUpload) return;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
var interval = statusPollIntervalMs;
|
||||
statusTimerId = setTimeout(function() {
|
||||
statusTimerId = null;
|
||||
pollStatus();
|
||||
}, delayMs !== undefined ? delayMs : interval);
|
||||
}
|
||||
|
||||
/** @description 调度下一次 /logs 轮询(递归 setTimeout,仿 scheduleStatusPoll)。 */
|
||||
function scheduleLogsPoll(delayMs) {
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
logsTimerId = setTimeout(function() {
|
||||
logsTimerId = null;
|
||||
pollLogs();
|
||||
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
|
||||
}
|
||||
|
||||
/** @description 启动定时轮询(状态 + 日志)。 */
|
||||
|
||||
export function startPolling() {
|
||||
window.consecutivePollFailures = 0;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
|
||||
scheduleLogsPoll(0);
|
||||
}
|
||||
export function pausePollingForUpload() {
|
||||
if (window.pollingPausedForUpload) return;
|
||||
window.pollingPausedForUpload = true;
|
||||
uploadPollChip = window.activeUploadChip || window.flashTarget || null;
|
||||
window.consecutivePollFailures = 0;
|
||||
// ★ 上传大固件期间必须停掉 status/logs 轮询。
|
||||
// httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉
|
||||
// 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完),
|
||||
// 但永远等不到 HTTP 响应,卡在「已上传 xxx / xxx」。
|
||||
if (statusTimerId) {
|
||||
clearTimeout(statusTimerId);
|
||||
statusTimerId = null;
|
||||
}
|
||||
if (logsTimerId) {
|
||||
clearTimeout(logsTimerId);
|
||||
logsTimerId = null;
|
||||
}
|
||||
if (statusAbortCtrl) {
|
||||
try { statusAbortCtrl.abort(); } catch (e) { /* ignore */ }
|
||||
statusAbortCtrl = null;
|
||||
statusPollInFlight = false;
|
||||
}
|
||||
}
|
||||
export function resumePollingAfterUpload() {
|
||||
if (!window.pollingPausedForUpload) return;
|
||||
window.pollingPausedForUpload = false;
|
||||
uploadPollChip = null;
|
||||
window.consecutivePollFailures = 0;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
} else {
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
scheduleLogsPoll(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 统一的 ESP32 重启完成处理:清重启标志 → 推进阶段 6 → 状态机转 DONE → 刷新按钮。
|
||||
* 抽出来供 pollStatus 成功分支与 pollLogs 启动 banner 分支复用,避免两处逻辑漂移。
|
||||
* 以 isEsp32Rebooting 作幂等守卫:已处理过的重启不再重复弹 toast / 写日志 / 重复转 DONE。
|
||||
* @param {string} source 日志来源描述(如 '启动 banner 收到'),用于区分日志文案。
|
||||
*/
|
||||
function markEsp32RebootComplete(source) {
|
||||
if (!window.isEsp32Rebooting) return;
|
||||
window.isEsp32Rebooting = false;
|
||||
window.rebootObservedDown = false;
|
||||
/* 重启后重新拉 memory.layout(换固件后静态段可能变化) */
|
||||
window._memLayout = null;
|
||||
window.updateEsp32StagesFromPhase(6); // 启动完成
|
||||
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
// ★ 状态机:ESP32 重启完成 → DONE
|
||||
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
window.otaSessionTransition(window.OtaSessionState.DONE);
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 判断设备最近一次成功轮询时是否处于「忙」状态(OTA 存储/中继/接收/收尾/重启 或 relay 握手~收尾)。 */
|
||||
function isLastDeviceBusy() {
|
||||
var s = window.lastSysStateCode;
|
||||
var r = window.lastRelayState;
|
||||
return s === SYS_GD32_STORING || s === SYS_GD32_RELAYING ||
|
||||
s === SYS_ESP32_RECEIVING || s === SYS_ESP32_FINALIZING ||
|
||||
s === SYS_ESP32_REBOOTING ||
|
||||
(r >= RELAY_ACTIVE_MIN && r <= RELAY_ACTIVE_MAX);
|
||||
}
|
||||
|
||||
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
|
||||
export async function pollStatus() {
|
||||
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
|
||||
if (statusPollInFlight) return;
|
||||
statusPollInFlight = true;
|
||||
var pollStartedAt = Date.now();
|
||||
statusAbortCtrl = new AbortController();
|
||||
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
|
||||
try {
|
||||
/* 首次/无缓存:?layout=1 取静态内存布局;之后只轮询动态字段 */
|
||||
var statusUrl = (window.systemStatusUrl
|
||||
? window.systemStatusUrl(false)
|
||||
: (window.getBaseUrl() + '/system/status' + (window._memLayout ? '' : '?layout=1')));
|
||||
var resp = await fetch(statusUrl, {
|
||||
signal: statusAbortCtrl.signal
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
|
||||
if (window.consecutivePollFailures > 0) {
|
||||
window.consecutivePollFailures = 0;
|
||||
addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
|
||||
showToast('设备已恢复在线', 'success');
|
||||
// 仅清理终态残留。禁止对活跃会话 otaSessionReset:
|
||||
// 旧逻辑任意非 IDLE 都 reset -> 中继中一次 status 超时恢复后会把 RELAYING
|
||||
// 清成 IDLE,UI 不再跟踪;REBOOTING 被清掉后永远进不了启动完成。
|
||||
var ssRec = window.otaSessionState;
|
||||
if (ssRec === window.OtaSessionState.FAILED ||
|
||||
ssRec === window.OtaSessionState.DONE) {
|
||||
window.otaSessionReset();
|
||||
addLog('I', '系统', '已清理终态 OTA 会话残留');
|
||||
}
|
||||
}
|
||||
if (window.isEsp32Rebooting ||
|
||||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
|
||||
/* 重启完成的证据(任一即可):
|
||||
* 1) rebootObservedDown:已观察到设备下线后恢复。
|
||||
* 2) freshBoot:设备回报 uptime 很小(<120s,与 resolveEsp32VerifyAmbiguous
|
||||
* 的 freshBoot 阈值一致)。★ 关键:设备掉线仅 3~5s 时(FAST_SCAN 生效后
|
||||
* 常见),在途轮询的 TCP SYN 重传会在设备起来后完成握手、全部成功,
|
||||
* rebootObservedDown 永远不置位,旧逻辑只能干等 10s 兜底——
|
||||
* 实测设备 3s 就绪、页面却 10s 才恢复。uptime 是新固件启动的权威信号。
|
||||
* 3) rbElapsed >= 10:安全兜底(兼容 uptime 缺失 / 设备未真重启)。
|
||||
* pre-restart 窗口(finalize 响应后约 1s 设备仍在跑旧固件)uptime 很大,
|
||||
* 不会误判为 freshBoot。 */
|
||||
var upS = Number(data && data.uptime_sec);
|
||||
var freshBoot = Number.isFinite(upS) && upS >= 0 && upS < 120;
|
||||
if (window.rebootObservedDown || freshBoot || rbElapsed >= 10) {
|
||||
markEsp32RebootComplete();
|
||||
}
|
||||
}
|
||||
try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
// pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中"
|
||||
if (window.isEsp32Rebooting) {
|
||||
window.applyRebootingRuntimeState();
|
||||
}
|
||||
}
|
||||
clearTimeout(pollTimer);
|
||||
} catch (e) {
|
||||
clearTimeout(pollTimer);
|
||||
// 自身 AbortController 触发的取消不算失败,静默返回
|
||||
if (e && e.name === "AbortError") return;
|
||||
// 先判定 OTA 是否进行中:OTA 期间所有失败都不计入 consecutivePollFailures,
|
||||
// 也不触发诊断。避免"上传完成→finalize→重启"窗口期失败计数累积,
|
||||
// 切回 IDLE 瞬间立即触发 5-fail 诊断,干扰进度显示。
|
||||
var lastDeviceBusy0 = isLastDeviceBusy();
|
||||
var sessionActive0 = window.otaSessionActive();
|
||||
var sessionFailed0 = window.otaSessionState === window.OtaSessionState.FAILED;
|
||||
var otaOngoing0 = window.pollingPausedForUpload ||
|
||||
sessionActive0 || sessionFailed0 ||
|
||||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy0;
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
// OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。
|
||||
if (pageVisible && !otaOngoing0) window.consecutivePollFailures++;
|
||||
var lastDeviceBusy = lastDeviceBusy0;
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
|
||||
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
|
||||
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
|
||||
var sessionActive = sessionActive0;
|
||||
var sessionFailed = sessionFailed0;
|
||||
// ★ OTA 期间(含暂停轮询、会话活跃、设备忙、正在重启、FAILED、当前 OTA 目标存在)
|
||||
// 一律不触发诊断日志/UI 覆盖。这些 W 级日志会刷在日志区,与"上传 xx/xx" "重启中 Xs"
|
||||
// "中继 N/M" 等进度行混在一起,用户反馈干扰 OTA 进度显示。
|
||||
// 之前条件漏了 pollingPausedForUpload,且 sessionActive 可能在 finalize/reboot 短窗口
|
||||
// 翻 false,导致 5 次失败后误触发诊断。这里把所有 OTA 相关态统一兜底。
|
||||
var otaOngoing = window.pollingPausedForUpload ||
|
||||
sessionActive || sessionFailed ||
|
||||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
|
||||
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
updateConnectionUi(false, '诊断中');
|
||||
window.applyDisconnectedRuntimeState();
|
||||
addLog('W', '诊断', '诊断结果: ' + diag);
|
||||
}
|
||||
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
|
||||
if (sessionFailed) {
|
||||
if (lastDeviceBusy) {
|
||||
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
|
||||
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
|
||||
updateConnectionUi(true, '设备忙');
|
||||
} else {
|
||||
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
|
||||
updateConnectionUi(false, '未连接');
|
||||
}
|
||||
} else if (window.otaSessionState === window.OtaSessionState.UPLOADING) {
|
||||
window.applyUploadingRuntimeState();
|
||||
} else if (window.otaSessionState === window.OtaSessionState.REBOOTING || window.isEsp32Rebooting) {
|
||||
window.rebootObservedDown = true; // 确认设备已下线(正在重启)
|
||||
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
|
||||
if (elapsed > 60) {
|
||||
// ★ 统一走 cleanupAfterOtaFailure:清 xhr/定时器/按钮/阶段,避免只改 state 留下假死 UI
|
||||
addLog('E', '系统', 'ESP32 重启超时,设备未恢复');
|
||||
showToast('ESP32 重启超时,请检查设备', 'error');
|
||||
if (typeof window.cleanupAfterOtaFailure === 'function') {
|
||||
window.cleanupAfterOtaFailure('ESP32 重启超时');
|
||||
} else {
|
||||
window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时');
|
||||
window.isEsp32Rebooting = false;
|
||||
window.rebootObservedDown = false;
|
||||
}
|
||||
updateConnectionUi(false, '未连接');
|
||||
window.applyDisconnectedRuntimeState();
|
||||
} else {
|
||||
window.applyRebootingRuntimeState();
|
||||
}
|
||||
} else if (sessionActive || lastDeviceBusy) {
|
||||
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
|
||||
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
|
||||
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA/重启中),保持 1s 轮询', '设备忙');
|
||||
updateConnectionUi(true, '设备忙');
|
||||
} else {
|
||||
updateConnectionUi(false, '未连接');
|
||||
window.applyDisconnectedRuntimeState();
|
||||
}
|
||||
} finally {
|
||||
statusPollInFlight = false;
|
||||
statusAbortCtrl = null;
|
||||
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
|
||||
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
|
||||
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。
|
||||
* ★ 上传暂停期间不重挂(由 scheduleStatusPoll 内部判断),避免在途请求 abort 后
|
||||
* 的 finally 把刚清掉的轮询又挂回来,导致上传中途再打 /system/status 抢 socket。 */
|
||||
var elapsed = Date.now() - pollStartedAt;
|
||||
var delay = Math.max(0, statusPollIntervalMs - elapsed);
|
||||
scheduleStatusPoll(delay);
|
||||
}
|
||||
}
|
||||
// 注意:startPoll() 由入口模块(app.js / ble_ota.js)在所有模块加载完成后调用,
|
||||
// 此处不在模块顶层自动启动,避免 ESM 求值顺序导致 window.OtaSessionState 尚未初始化。
|
||||
// ==================== Page Visibility 补偿 ====================
|
||||
// Chrome/Edge 在标签隐藏/窗口最小化时会把 setInterval 节流到最低 1 分钟/次。
|
||||
// 切回前台时立即拉一次状态 + 日志,并清零后台期间累积的失败计数,
|
||||
// 避免在"刚切回前台那一轮"被误判为 5-fail 离线。
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
pageVisible = !document.hidden;
|
||||
if (!pageVisible) return;
|
||||
window.consecutivePollFailures = 0;
|
||||
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
} else {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
pollLogs();
|
||||
});
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
|
||||
export async function pollLogs() {
|
||||
// ★ ESP32 上传期间也允许日志轮询;in-flight 守卫避免与 /system/status、/ota/upload 抢 socket
|
||||
if (logsInFlight) return;
|
||||
logsInFlight = true;
|
||||
try {
|
||||
var resp = await fetch(window.getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
var logs = await resp.json();
|
||||
if (!Array.isArray(logs) || logs.length === 0) return;
|
||||
logs.forEach(function (line) {
|
||||
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
|
||||
if (m) {
|
||||
addLog(m[1], m[2], m[3].trimEnd());
|
||||
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
|
||||
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
|
||||
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
|
||||
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
|
||||
/* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */
|
||||
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) {
|
||||
markEsp32RebootComplete('启动 banner 收到');
|
||||
}
|
||||
/*
|
||||
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
|
||||
* status 轮询可能仍停在旧 relay_seq,否则会出现 FINISH 日志之后还刷 94%。
|
||||
*/
|
||||
if (m[2] === 'OTA' && /全部数据包已转发/.test(m[3]) &&
|
||||
typeof window.emitRelayProgress === 'function') {
|
||||
var otaSnap = (window.latestSystemStatus && window.latestSystemStatus.ota) || {};
|
||||
var tot = Number(otaSnap.relay_total_packets) || 0;
|
||||
if (tot > 0) {
|
||||
window.emitRelayProgress(tot, tot, { force100: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addLog('I', 'ESP32', line.trimEnd());
|
||||
}
|
||||
});
|
||||
} catch (e) { /* 静默 */ } finally {
|
||||
logsInFlight = false;
|
||||
// 递归调度下一轮:固定周期扣减本轮耗时,避免慢响应时重叠抢 socket
|
||||
if (!window.pollingPausedForUpload) {
|
||||
scheduleLogsPoll(statusPollIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
window.updateUploadButtonLabel();
|
||||
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
|
||||
// IP 输入变化时清零失败计数并立即刷新状态
|
||||
var ipInput = document.getElementById('espIp');
|
||||
if (ipInput) {
|
||||
ipInput.addEventListener('change', function () {
|
||||
window.consecutivePollFailures = 0;
|
||||
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
|
||||
if (!statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* toast.js - Toast 提示组件
|
||||
*
|
||||
* showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
|
||||
* 支持 success / error / warning 三类;info 走 warning 同款样式(历史兼容,无专属配色)。
|
||||
* 内置 5s 去重防抖,相同 (type|message) 5s 内不重复弹出。
|
||||
*/
|
||||
/** @description 显示 Toast 提示消息,支持 success/error/warning(info 同 warning)。 */
|
||||
const TOAST_DEDUPE_MS = 5000;
|
||||
const TOAST_AUTOHIDE_MS = 3000;
|
||||
|
||||
var _toastHideTimer = null;
|
||||
var _toastLastKey = '';
|
||||
var _toastLastAt = 0;
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {string} [type] success/error/warning
|
||||
* @param {{ force?: boolean }} [opts] force=true 时跳过 5s 去重,
|
||||
* 用于用户主动操作(如批量发送完成)的反馈,避免 alert 阻塞后时间漂移导致被去重吞掉。
|
||||
*/
|
||||
export function showToast(message, type, opts) {
|
||||
type = type || 'success';
|
||||
var toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
|
||||
var key = type + '|' + message;
|
||||
var now = Date.now();
|
||||
if (!(opts && opts.force) && key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
|
||||
return;
|
||||
}
|
||||
_toastLastKey = key;
|
||||
_toastLastAt = now;
|
||||
|
||||
var ico = document.getElementById('toastIco');
|
||||
var msg = document.getElementById('toastMsg');
|
||||
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
|
||||
if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
|
||||
if (msg) msg.textContent = message;
|
||||
if (_toastHideTimer) clearTimeout(_toastHideTimer);
|
||||
_toastHideTimer = setTimeout(function () {
|
||||
toast.classList.remove('show');
|
||||
_toastHideTimer = null;
|
||||
}, TOAST_AUTOHIDE_MS);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* utils.js - 通用工具函数
|
||||
*
|
||||
* 格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
|
||||
*/
|
||||
// ==================== 工具函数 ====================
|
||||
/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */
|
||||
/** @description 字节格式化为 B / KB(不升到 MB,避免 1024KB 显示成 1.00MB)。 */
|
||||
export function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '--';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
var kb = bytes / 1024;
|
||||
if (Math.abs(kb - Math.round(kb)) < 1e-9) return Math.round(kb) + ' KB';
|
||||
return kb.toFixed(1) + ' KB';
|
||||
}
|
||||
/** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */
|
||||
export function formatTime(date) {
|
||||
var h = String(date.getHours()).padStart(2, '0');
|
||||
var m = String(date.getMinutes()).padStart(2, '0');
|
||||
var s = String(date.getSeconds()).padStart(2, '0');
|
||||
var ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return h + ':' + m + ':' + s + '.' + ms;
|
||||
}
|
||||
/** @description 格式化秒数为人类可读运行时间(d/h/m/s)。 */
|
||||
export function formatUptime(sec) {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '--';
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
|
||||
if (h > 0) return h + 'h ' + m + 'm ' + s + 's';
|
||||
if (m > 0) return m + 'm ' + s + 's';
|
||||
return s + 's';
|
||||
}
|
||||
/** @description 转义 HTML 特殊字符,防止 XSS 注入。 */
|
||||
export function escapeHtml(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
/**
|
||||
* @description 将字节数组(Uint8Array 或 ArrayBuffer)转为小写 hex 字符串。
|
||||
* SHA-256 等哈希显示前需要 toUpperCase()。
|
||||
* @param {Uint8Array|ArrayBuffer} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bytesToHex(bytes) {
|
||||
var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
var out = '';
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
var h = arr[i].toString(16);
|
||||
if (h.length < 2) h = '0' + h;
|
||||
out += h;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user