From d8ef61adec98ba48ed50f4151a326e337ecd42b1 Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 27 Jun 2026 09:16:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BD=8E=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E4=BF=9D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/admin.html | 3 + public/assets/admin.js | 44 ++++++++++++- src/api.js | 26 ++++++++ src/modem.js | 144 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) diff --git a/public/admin.html b/public/admin.html index 02d1cde..93f7fa5 100644 --- a/public/admin.html +++ b/public/admin.html @@ -119,6 +119,9 @@
+ + 开启流量后才会显示消耗流量按钮。

diff --git a/public/assets/admin.js b/public/assets/admin.js index 64ac881..4d16df9 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -1,6 +1,8 @@ const state = { busy: false, mobileDataBusy: false, + trafficBusy: false, + currentMobileData: null, pushChannels: [], selectedPushChannel: -1, pushLoaded: false @@ -39,6 +41,8 @@ const elements = { mobileDataState: document.querySelector('#mobileDataState'), mobileDataHint: document.querySelector('#mobileDataHint'), mobileDataToggle: document.querySelector('#mobileDataToggle'), + mobileDataConsume: document.querySelector('#mobileDataConsume'), + mobileDataTrafficHint: document.querySelector('#mobileDataTrafficHint'), mobileDataStatus: document.querySelector('#mobileDataStatus'), moduleModel: document.querySelector('#moduleModel'), moduleVersion: document.querySelector('#moduleVersion'), @@ -180,6 +184,7 @@ function formatMobileDataContexts(contexts = []) { } function renderMobileData(mobileData = {}) { + state.currentMobileData = mobileData; const unknown = mobileData.status === 'unknown'; const enabled = Boolean(mobileData.enabled || mobileData.anyActive); const cid = mobileData.cid || 1; @@ -193,21 +198,30 @@ function renderMobileData(mobileData = {}) { setText(elements.mobileDataHint, mobileData.error || (isMipcall ? '未能确认应用层拨号状态,可先强制断开。' : '未能确认移动数据状态,可先强制关闭。')); elements.mobileDataToggle.textContent = isMipcall ? '强制断开拨号' : '强制关闭流量'; elements.mobileDataToggle.dataset.action = 'disable'; + elements.mobileDataConsume.classList.add('is-hidden'); + elements.mobileDataTrafficHint.classList.remove('is-hidden'); + elements.mobileDataTrafficHint.textContent = '状态确认后,开启流量才会显示消耗流量按钮。'; } else if (enabled) { setText(elements.mobileDataSummary, isMipcall ? '已连接' : '已开启'); setText(elements.mobileDataState, isMipcall ? '应用拨号已连接' : '流量已开启'); setText(elements.mobileDataHint, activeContexts ? `${mode} 检测到 ${activeContexts} 处于活动状态。` : `CID ${cid} 处于活动状态。`); elements.mobileDataToggle.textContent = isMipcall ? '断开拨号' : '关闭流量'; elements.mobileDataToggle.dataset.action = 'disable'; + elements.mobileDataConsume.classList.remove('is-hidden'); + elements.mobileDataTrafficHint.classList.add('is-hidden'); } else { setText(elements.mobileDataSummary, isMipcall ? '已断开' : '已关闭'); setText(elements.mobileDataState, isMipcall ? '应用拨号已断开' : '流量已关闭'); setText(elements.mobileDataHint, isMipcall ? `${mode} CID ${cid} 未建立应用层拨号连接;已保护性尝试停用PDP。` : `${mode} CID ${cid} 未建立移动数据连接,重启后也会先保持关闭。`); elements.mobileDataToggle.textContent = isMipcall ? '开启拨号' : '开启流量'; elements.mobileDataToggle.dataset.action = 'enable'; + elements.mobileDataConsume.classList.add('is-hidden'); + elements.mobileDataTrafficHint.classList.remove('is-hidden'); + elements.mobileDataTrafficHint.textContent = isMipcall ? '先开启拨号后,才可以执行一次MPING消耗少量流量。' : '先开启流量后,才可以执行一次MPING消耗少量流量。'; } elements.mobileDataToggle.disabled = state.mobileDataBusy; + elements.mobileDataConsume.disabled = state.mobileDataBusy || state.trafficBusy || !enabled; } function renderStatus(status, info) { @@ -681,7 +695,34 @@ async function toggleMobileData() { setStatusMessage(elements.mobileDataStatus, err.message, 'error'); } finally { state.mobileDataBusy = false; - elements.mobileDataToggle.disabled = false; + renderMobileData(state.currentMobileData || {}); + } +} + +async function consumeMobileDataTraffic() { + if (state.mobileDataBusy || state.trafficBusy) { + return; + } + + state.trafficBusy = true; + elements.mobileDataToggle.disabled = true; + elements.mobileDataConsume.disabled = true; + setStatusMessage(elements.mobileDataStatus, '正在执行MPING,会消耗少量流量...'); + + try { + const result = await requestJSON('/api/modem/mobile-data/consume', { + method: 'POST', + body: JSON.stringify({ target: '8.8.8.8' }) + }); + renderMobileData(result.data?.mobileData || state.currentMobileData || {}); + const pingMessage = result.data?.ping?.message || result.message || '已完成流量消耗测试。'; + setStatusMessage(elements.mobileDataStatus, pingMessage, 'success'); + await refreshLogs(); + } catch (err) { + setStatusMessage(elements.mobileDataStatus, err.message, 'error'); + } finally { + state.trafficBusy = false; + renderMobileData(state.currentMobileData || {}); } } @@ -787,6 +828,7 @@ elements.pushTestButton.addEventListener('click', testPushChannel); elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange); elements.refreshButton.addEventListener('click', refreshAll); elements.mobileDataToggle.addEventListener('click', toggleMobileData); +elements.mobileDataConsume.addEventListener('click', consumeMobileDataTraffic); elements.logsButton.addEventListener('click', refreshLogs); elements.messagesButton.addEventListener('click', refreshMessages); elements.smsForm.addEventListener('submit', sendSMS); diff --git a/src/api.js b/src/api.js index 6c55876..fda9e89 100644 --- a/src/api.js +++ b/src/api.js @@ -514,6 +514,32 @@ class APIServer { } }); + // 通过模组执行一次MPING,消耗少量流量验证连通性 + this.app.post('/api/modem/mobile-data/consume', async (req, res) => { + try { + const ping = await this.modem.consumeMobileDataTraffic({ + target: req.body?.target || '8.8.8.8' + }); + const mobileData = await this.modem.getMobileDataStatus(); + + res.json({ + success: true, + message: '已消耗少量流量完成连通性测试', + data: { + ping, + mobileData + } + }); + } catch (err) { + logger.error('流量消耗测试失败:', err); + res.status(err.statusCode || 500).json({ + success: false, + error: err.message, + data: err.data || null + }); + } + }); + // 发送AT命令 this.app.post('/api/modem/at', async (req, res) => { try { diff --git a/src/modem.js b/src/modem.js index 9d2a695..0d1475f 100644 --- a/src/modem.js +++ b/src/modem.js @@ -668,6 +668,150 @@ class ModemManager extends EventEmitter { } } + async consumeMobileDataTraffic(options = {}) { + const target = this.normalizePingTarget(options.target || '8.8.8.8'); + const status = await this.getMobileDataStatus(); + + if (status.status === 'unknown') { + const err = new Error(status.error || '未能确认移动数据状态,无法执行流量消耗测试'); + err.statusCode = 503; + throw err; + } + + if (!status.anyActive) { + const err = new Error('当前未开启流量,请先开启后再执行流量消耗测试'); + err.statusCode = 409; + throw err; + } + + return await this.runMPing(target, { + timeout: options.timeout || 35000, + pingTimeoutSeconds: options.pingTimeoutSeconds || 30, + count: options.count || 1 + }); + } + + normalizePingTarget(value) { + const target = String(value || '').trim(); + if (!target || !/^[A-Za-z0-9.:-]{1,253}$/.test(target)) { + throw new Error('Ping目标地址无效'); + } + + return target; + } + + runMPing(target, options = {}) { + if (!this.port || !this.port.isOpen) { + throw new Error('串口未打开'); + } + + const timeout = options.timeout || 35000; + const pingTimeoutSeconds = Math.min(Math.max(Number(options.pingTimeoutSeconds) || 30, 1), 255); + const count = Math.min(Math.max(Number(options.count) || 1, 1), 10); + const command = `AT+MPING="${target}",${pingTimeoutSeconds},${count}`; + + logger.info(`准备通过模组执行MPING,目标: ${target}`); + + return new Promise((resolve, reject) => { + const lines = []; + let settled = false; + let commandAccepted = false; + + const finish = (err, result = null) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + this.parser.removeListener('data', handler); + + if (err) { + reject(err); + } else { + resolve(result); + } + }; + + const timer = setTimeout(() => { + finish(new Error(`MPING超时,未收到结果: ${target}`)); + }, timeout); + + const handler = (line) => { + const trimmed = String(line).trim(); + if (!trimmed) { + return; + } + + lines.push(trimmed); + + if (trimmed === 'OK') { + commandAccepted = true; + return; + } + + if (trimmed.includes('ERROR')) { + finish(new Error(`MPING命令失败: ${this.formatATResponse(lines.join('\n'))}`)); + return; + } + + if (trimmed.startsWith('+MPING:')) { + const result = this.parseMPingResult(trimmed, target); + result.commandAccepted = commandAccepted; + result.rawResponse = lines.join('\n'); + + if (result.success) { + logger.info(`✓ ${result.message}`); + finish(null, result); + } else { + const err = new Error(result.message); + err.statusCode = 502; + err.data = result; + finish(err); + } + } + }; + + this.parser.on('data', handler); + + logger.debug(`>> ${command}`); + this.port.write(`${command}\r\n`, (err) => { + if (err) { + finish(err); + } + }); + }); + } + + parseMPingResult(line, target) { + const payload = line.slice('+MPING:'.length).trim(); + const params = payload.match(/"[^"]*"|[^,]+/g)?.map(item => { + const value = item.trim(); + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; + }) || []; + const resultCode = Number.parseInt(params[0], 10); + const responseTarget = params[1] || target; + const packetLength = Number.parseInt(params[2], 10); + const latencyMs = Number.parseInt(params[3], 10); + const ttl = Number.parseInt(params[4], 10); + const hasReplyFields = params.length >= 4; + const success = (resultCode === 0 || resultCode === 1) && hasReplyFields; + const message = success + ? `MPING成功: ${responseTarget}, 延迟 ${Number.isFinite(latencyMs) ? `${latencyMs}ms` : '未知'}, TTL ${Number.isFinite(ttl) ? ttl : '未知'}` + : `MPING失败或目标不可达,错误码: ${Number.isFinite(resultCode) ? resultCode : payload}`; + + return { + success, + target: responseTarget, + resultCode: Number.isFinite(resultCode) ? resultCode : null, + packetLength: Number.isFinite(packetLength) ? packetLength : null, + latencyMs: Number.isFinite(latencyMs) ? latencyMs : null, + ttl: Number.isFinite(ttl) ? ttl : null, + message, + rawLine: line + }; + } + async waitForMobileDataState(enabled, timeout = 10000) { const startedAt = Date.now(); let status = await this.getMobileDataStatus();