Files
sms_forwarding/public/assets/admin.js
T
2026-06-27 21:56:41 +08:00

1077 lines
35 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.
const state = {
busy: false,
mobileDataBusy: false,
trafficBusy: false,
simBusy: false,
currentMobileData: null,
currentSim: null,
pushChannels: [],
selectedPushChannel: -1,
pushLoaded: false
};
const elements = {
pushSettingsButton: document.querySelector('#pushSettingsButton'),
pushModal: document.querySelector('#pushModal'),
pushModalClose: document.querySelector('#pushModalClose'),
pushAddTypeSelect: document.querySelector('#pushAddTypeSelect'),
pushAddButton: document.querySelector('#pushAddButton'),
pushDeleteButton: document.querySelector('#pushDeleteButton'),
pushSaveButton: document.querySelector('#pushSaveButton'),
pushTestButton: document.querySelector('#pushTestButton'),
pushChannelList: document.querySelector('#pushChannelList'),
pushChannelForm: document.querySelector('#pushChannelForm'),
pushFormFields: document.querySelector('#pushFormFields'),
pushStatus: document.querySelector('#pushStatus'),
pushUrlLabel: document.querySelector('#pushUrlLabel'),
pushSecretLabel: document.querySelector('#pushSecretLabel'),
pushKey1Label: document.querySelector('#pushKey1Label'),
pushKey2Label: document.querySelector('#pushKey2Label'),
refreshButton: document.querySelector('#refreshButton'),
logsButton: document.querySelector('#logsButton'),
readyBadge: document.querySelector('#readyBadge'),
panelStatus: document.querySelector('#panelStatus'),
modelName: document.querySelector('#modelName'),
operator: document.querySelector('#operator'),
signalQuality: document.querySelector('#signalQuality'),
mobileDataSummary: document.querySelector('#mobileDataSummary'),
uptime: document.querySelector('#uptime'),
connectionState: document.querySelector('#connectionState'),
connectionHint: document.querySelector('#connectionHint'),
signalValue: document.querySelector('#signalValue'),
signalHint: document.querySelector('#signalHint'),
mobileDataState: document.querySelector('#mobileDataState'),
mobileDataHint: document.querySelector('#mobileDataHint'),
mobileDataToggle: document.querySelector('#mobileDataToggle'),
mobileDataConsume: document.querySelector('#mobileDataConsume'),
mobileDataTrafficHint: document.querySelector('#mobileDataTrafficHint'),
mobileDataStatus: document.querySelector('#mobileDataStatus'),
simMode: document.querySelector('#simMode'),
simStatus: document.querySelector('#simStatus'),
simSlot0: document.querySelector('#simSlot0'),
simSlot1: document.querySelector('#simSlot1'),
simSlotGrid: document.querySelector('.sim-slot-grid'),
simSlot0State: document.querySelector('#simSlot0State'),
simSlot1State: document.querySelector('#simSlot1State'),
simSlot0Phone: document.querySelector('#simSlot0Phone'),
simSlot1Phone: document.querySelector('#simSlot1Phone'),
simSwitchButtons: [...document.querySelectorAll('[data-sim-switch]')],
moduleModel: document.querySelector('#moduleModel'),
moduleVersion: document.querySelector('#moduleVersion'),
currentSim: document.querySelector('#currentSim'),
smsForm: document.querySelector('#smsForm'),
sendSimLabel: document.querySelector('#sendSimLabel'),
smsStatus: document.querySelector('#smsStatus'),
messagesButton: document.querySelector('#messagesButton'),
messageCount: document.querySelector('#messageCount'),
messageScope: document.querySelector('#messageScope'),
messageList: document.querySelector('#messageList'),
atForm: document.querySelector('#atForm'),
atOutput: document.querySelector('#atOutput'),
consoleState: document.querySelector('#consoleState'),
logWindow: document.querySelector('#logWindow')
};
const PUSH_TYPE_OPTIONS = {
dingtalk: '钉钉机器人',
feishu: '飞书机器人',
telegram: 'Telegram Bot',
pushplus: 'PushPlus',
serverchan: 'Server酱',
post_json: 'POST JSON',
bark: 'Bark',
get: 'GET',
custom: '自定义 POST'
};
const PUSH_TYPE_META = {
dingtalk: {
urlLabel: '钉钉 Webhook URL',
secretLabel: '加签密钥',
show: ['url', 'secret']
},
feishu: {
urlLabel: '飞书 Webhook URL',
secretLabel: '加签密钥',
show: ['url', 'secret']
},
telegram: {
urlLabel: 'Bot Token',
key1Label: 'Chat ID',
show: ['url', 'key1']
},
pushplus: {
key1Label: 'PushPlus Token',
show: ['key1']
},
serverchan: {
key1Label: 'Server酱 SendKey',
show: ['key1']
},
post_json: {
urlLabel: 'POST URL',
show: ['url']
},
bark: {
urlLabel: 'Bark 推送 URL',
show: ['url']
},
get: {
urlLabel: 'GET URL',
show: ['url']
},
custom: {
urlLabel: 'POST URL',
show: ['url', 'customBody']
}
};
function setText(node, value) {
if (!node) return;
node.textContent = value || '--';
}
function setStatusMessage(node, message, type = '') {
if (!node) return;
node.textContent = message;
node.classList.toggle('is-success', type === 'success');
node.classList.toggle('is-error', type === 'error');
}
async function requestJSON(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data;
}
function formatUptime(seconds) {
const total = Math.max(0, Math.floor(seconds || 0));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (days > 0) {
return `${days}${hours}小时 ${minutes}分`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}分`;
}
if (minutes > 0) {
return `${minutes}${secs}秒`;
}
return `${secs}秒`;
}
function formatModel(model = {}) {
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
}
function formatMobileDataMode(mode) {
if (mode === 'mipcall') {
return 'MIPCALL';
}
if (mode === 'cgact') {
return 'CGACT';
}
return 'AT';
}
function formatMobileDataContexts(contexts = []) {
const active = contexts
.filter(item => item.active)
.map(item => `CID ${item.cid}`)
.join('、');
return active || '';
}
function renderMobileData(mobileData = {}) {
state.currentMobileData = mobileData;
const unknown = mobileData.status === 'unknown';
const enabled = Boolean(mobileData.enabled || mobileData.anyActive);
const cid = mobileData.cid || 1;
const mode = formatMobileDataMode(mobileData.mode);
const isMipcall = mobileData.mode === 'mipcall';
const activeContexts = formatMobileDataContexts(mobileData.contexts || []);
if (unknown) {
setText(elements.mobileDataSummary, '未知');
setText(elements.mobileDataState, '状态未知');
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 getActiveSimSlot(sim = {}) {
return (sim.slots || []).find(slot => slot.active) || null;
}
function getSimDisplayLabel(slot) {
return slot?.phoneLabel || slot?.phoneNumber || slot?.name || 'SIM';
}
function getActiveSimLabel() {
return getSimDisplayLabel(getActiveSimSlot(state.currentSim || {}));
}
function renderSendSimLabel() {
setText(elements.sendSimLabel, getActiveSimLabel());
}
function getSimSlot(sim = {}, slotNumber) {
return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
slot: slotNumber,
name: `SIM${slotNumber + 1}`,
active: false,
phoneNumber: '',
phoneLabel: `SIM${slotNumber + 1}`
};
}
function formatSimSlotPhone(slot) {
if (slot?.phoneNumber) {
return slot.phoneNumber;
}
if (slot?.phoneLabel && slot.phoneLabel !== slot.name) {
return slot.phoneLabel;
}
return '';
}
function renderSimSlot(slot, card, stateNode, phoneNode) {
if (!card) {
return;
}
card.classList.toggle('is-active', Boolean(slot.active));
if (slot.active && slot.present === false) {
setText(stateNode, '当前未就绪');
} else if (slot.active) {
setText(stateNode, '当前使用');
} else if (slot.present === true) {
setText(stateNode, '可用');
} else if (slot.present === false) {
setText(stateNode, '未检测到SIM');
} else {
setText(stateNode, '未确认');
}
const phoneText = formatSimSlotPhone(slot);
if (phoneNode) {
phoneNode.textContent = phoneText;
phoneNode.hidden = !phoneText;
}
}
function getVisibleSimSlots(sim = {}, activeSlot = null) {
const slots = sim.slots || [];
const presentSlots = slots.filter(slot => slot.present === true);
if (presentSlots.length >= 2) {
return presentSlots;
}
if (activeSlot?.present === true) {
return [activeSlot];
}
return slots.filter(slot => slot.active || slot.present !== false);
}
function getSimModeLabel(sim = {}, activeSlot = null) {
const hardwareSwitchable = Boolean(sim.supported && sim.canSwitch);
if (!hardwareSwitchable) {
return '单卡/未检测到双卡';
}
const presentSlots = (sim.slots || []).filter(slot => slot.present === true);
if (presentSlots.length >= 2) {
return sim.modeLabel || '双卡';
}
if (activeSlot?.present === true) {
return `仅检测到 ${getSimDisplayLabel(activeSlot)}`;
}
return '未检测到可用SIM';
}
function renderSimStatus(sim = {}) {
state.currentSim = sim;
const supported = Boolean(sim.supported && sim.canSwitch);
const activeSlot = getActiveSimSlot(sim);
const modeLabel = getSimModeLabel(sim, activeSlot);
setText(elements.simMode, modeLabel);
const visibleSlots = getVisibleSimSlots(sim, activeSlot);
const singleSlot = visibleSlots.length === 1;
const visibleSlotNumbers = new Set(visibleSlots.map(slot => slot.slot));
elements.simSlotGrid?.classList.toggle('is-single', singleSlot);
[
[getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone],
[getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone]
].forEach(([slot, card, stateNode, phoneNode]) => {
if (card) {
card.hidden = !visibleSlotNumbers.has(slot.slot);
card.classList.toggle('is-single', singleSlot && visibleSlotNumbers.has(slot.slot));
}
renderSimSlot(slot, card, stateNode, phoneNode);
});
elements.simSwitchButtons.forEach((button) => {
const slot = Number.parseInt(button.dataset.simSwitch, 10);
const slotInfo = getSimSlot(sim, slot);
const isActive = activeSlot?.slot === slot;
const unavailable = slotInfo.present === false;
button.hidden = singleSlot;
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable;
button.textContent = isActive ? '正在使用' : `切到SIM${slot + 1}`;
});
if (state.simBusy || sim.switching) {
setStatusMessage(elements.simStatus, '正在切换SIM...');
} else if (supported && activeSlot?.present === true) {
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`, 'success');
} else if (supported && activeSlot?.present === false) {
setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`, 'error');
} else if (!supported) {
setStatusMessage(elements.simStatus, '双卡不可用');
} else {
setStatusMessage(elements.simStatus, '');
}
renderSendSimLabel();
}
function renderStatus(status, info, sim) {
const data = status.data || {};
const infoData = info.data || {};
const model = data.model || infoData.model || {};
const signal = data.signal || {};
const ready = Boolean(data.ready);
const simData = sim?.data || data.sim || {};
const activeSim = getActiveSimSlot(simData);
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
setText(elements.panelStatus, ready ? 'ready' : 'not ready');
setText(elements.modelName, formatModel(model));
setText(elements.operator, data.operator || '未知');
setText(elements.signalQuality, signal.quality || '未知');
setText(elements.uptime, formatUptime(data.uptime));
renderMobileData(data.mobileData || {});
setText(elements.connectionState, ready ? '运行中' : '未就绪');
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
setText(elements.signalValue, signal.rssi === undefined ? '--' : `RSSI ${signal.rssi}`);
setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`);
setText(elements.moduleModel, model.model || '--');
setText(elements.moduleVersion, model.version || '--');
setText(elements.currentSim, getSimDisplayLabel(activeSim) || infoData.simLabel || '--');
renderSimStatus(simData);
}
function parseLogLine(line) {
const levelMatch = line.match(/\[(INFO|WARN|ERROR)\]/);
const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
return {
time: timeMatch ? timeMatch[1].slice(11) : '',
level: levelMatch ? levelMatch[1] : '',
message: line.replace(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*/, '')
};
}
function renderLogs(logs) {
elements.logWindow.replaceChildren();
if (!logs.length) {
const empty = document.createElement('p');
empty.className = 'empty-state';
empty.textContent = '暂无日志。';
elements.logWindow.append(empty);
return;
}
const fragment = document.createDocumentFragment();
logs.forEach((line) => {
const item = parseLogLine(line);
const row = document.createElement('div');
row.className = `log-line is-${item.level.toLowerCase()}`;
const level = document.createElement('span');
level.className = 'log-level';
level.textContent = item.level ? `${item.time} ${item.level}` : item.time;
const message = document.createElement('span');
message.className = 'log-message';
message.textContent = item.message;
row.append(level, message);
fragment.append(row);
});
elements.logWindow.append(fragment);
elements.logWindow.scrollTop = elements.logWindow.scrollHeight;
}
function formatDateTime(value) {
if (!value) {
return '--';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function renderMessages(messages, meta = {}) {
elements.messageList.replaceChildren();
const countLabel = `最近 ${messages.length} 条`;
const scopeLabel = '新短信自动显示';
setText(elements.messageCount, countLabel);
setText(elements.messageScope, scopeLabel);
if (!messages.length) {
const empty = document.createElement('p');
empty.className = 'empty-state empty-state--light';
empty.textContent = '暂无收到的短信。';
elements.messageList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
messages.forEach((message) => {
const item = document.createElement('article');
item.className = `message-item is-${message.status || 'received'}`;
const header = document.createElement('div');
header.className = 'message-item__header';
const sender = document.createElement('strong');
sender.textContent = message.sender || '未知号码';
const status = document.createElement('span');
status.className = 'message-status';
status.textContent = message.statusText || '已接收';
const time = document.createElement('time');
time.textContent = formatDateTime(message.timestamp || message.receivedAt);
const text = document.createElement('p');
text.className = 'message-text';
text.textContent = message.text || '';
header.append(sender, status, time);
item.append(header, text);
fragment.append(item);
});
elements.messageList.append(fragment);
}
function getPushTypeLabel(type) {
return PUSH_TYPE_OPTIONS[type] || type || '推送通道';
}
function createDefaultPushChannel(type = 'dingtalk') {
return {
enabled: true,
type,
name: `${getPushTypeLabel(type)}通知`,
url: '',
secret: '',
key1: '',
key2: '',
customBody: type === 'custom' ? '{"from":"{sender}","message":"{message}","timestamp":"{timestamp}"}' : ''
};
}
function normalizePushChannel(channel = {}) {
const fallback = createDefaultPushChannel(channel.type || 'dingtalk');
return {
...fallback,
enabled: Boolean(channel.enabled),
type: String(channel.type || fallback.type),
name: String(channel.name || fallback.name),
url: String(channel.url || ''),
secret: String(channel.secret || ''),
key1: String(channel.key1 || ''),
key2: String(channel.key2 || ''),
customBody: String(channel.customBody || '')
};
}
function setPushStatus(message, type = '') {
setStatusMessage(elements.pushStatus, message, type);
}
function getPushControls() {
return elements.pushChannelForm.elements;
}
function readPushChannelForm() {
const controls = getPushControls();
return {
enabled: controls.enabled.checked,
type: String(controls.type.value || 'dingtalk'),
name: String(controls.name.value || '').trim(),
url: String(controls.url.value || '').trim(),
secret: String(controls.secret.value || '').trim(),
key1: String(controls.key1.value || '').trim(),
key2: String(controls.key2.value || '').trim(),
customBody: String(controls.customBody.value || '').trim()
};
}
function writePushChannelForm(channel) {
const controls = getPushControls();
controls.enabled.checked = Boolean(channel.enabled);
controls.type.value = channel.type;
controls.name.value = channel.name;
controls.url.value = channel.url;
controls.secret.value = channel.secret;
controls.key1.value = channel.key1;
controls.key2.value = channel.key2;
controls.customBody.value = channel.customBody;
updatePushFieldVisibility(channel.type);
}
function clearPushChannelForm() {
writePushChannelForm(createDefaultPushChannel());
}
function setPushFieldVisible(name, visible) {
const row = elements.pushChannelForm.querySelector(`[data-push-field="${name}"]`);
if (row) {
row.classList.toggle('is-hidden', !visible);
}
}
function updatePushFieldVisibility(type) {
const meta = PUSH_TYPE_META[type] || PUSH_TYPE_META.dingtalk;
const visibleFields = new Set(meta.show || []);
elements.pushUrlLabel.textContent = meta.urlLabel || 'Webhook URL';
elements.pushSecretLabel.textContent = meta.secretLabel || '密钥';
elements.pushKey1Label.textContent = meta.key1Label || 'Key1';
elements.pushKey2Label.textContent = meta.key2Label || 'Key2';
['url', 'secret', 'key1', 'key2', 'customBody'].forEach((name) => {
setPushFieldVisible(name, visibleFields.has(name));
});
}
function syncCurrentPushChannel() {
const index = state.selectedPushChannel;
if (index < 0 || !state.pushChannels[index] || elements.pushFormFields.disabled) {
return;
}
state.pushChannels[index] = normalizePushChannel(readPushChannelForm());
}
function renderPushChannelList() {
elements.pushChannelList.replaceChildren();
if (!state.pushChannels.length) {
const empty = document.createElement('p');
empty.className = 'empty-state empty-state--light';
empty.textContent = '暂无推送通道。';
elements.pushChannelList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
state.pushChannels.forEach((channel, index) => {
const item = document.createElement('button');
item.type = 'button';
item.className = `channel-list__item${index === state.selectedPushChannel ? ' is-active' : ''}`;
const name = document.createElement('span');
name.className = 'channel-list__name';
name.textContent = channel.name || getPushTypeLabel(channel.type);
const meta = document.createElement('span');
meta.className = 'channel-list__meta';
const type = document.createElement('span');
type.textContent = getPushTypeLabel(channel.type);
const status = document.createElement('span');
status.textContent = channel.enabled ? '启用' : '停用';
meta.append(type, status);
item.append(name, meta);
item.addEventListener('click', () => {
selectPushChannel(index);
});
fragment.append(item);
});
elements.pushChannelList.append(fragment);
}
function renderPushChannelForm() {
const channel = state.pushChannels[state.selectedPushChannel];
const hasChannel = Boolean(channel);
elements.pushFormFields.disabled = !hasChannel;
elements.pushTestButton.disabled = !hasChannel;
elements.pushDeleteButton.disabled = !hasChannel;
elements.pushSaveButton.disabled = !state.pushLoaded;
if (!hasChannel) {
clearPushChannelForm();
return;
}
writePushChannelForm(channel);
}
function renderPushSettings() {
renderPushChannelList();
renderPushChannelForm();
}
function selectPushChannel(index) {
syncCurrentPushChannel();
state.selectedPushChannel = index;
renderPushSettings();
setPushStatus('');
}
function addPushChannel() {
syncCurrentPushChannel();
const type = elements.pushAddTypeSelect?.value || 'dingtalk';
state.pushChannels.push(createDefaultPushChannel(type));
state.selectedPushChannel = state.pushChannels.length - 1;
renderPushSettings();
setPushStatus('');
}
function deletePushChannel() {
if (state.selectedPushChannel < 0) {
return;
}
state.pushChannels.splice(state.selectedPushChannel, 1);
state.selectedPushChannel = Math.min(state.selectedPushChannel, state.pushChannels.length - 1);
renderPushSettings();
setPushStatus('');
}
async function loadPushChannels() {
elements.pushAddButton.disabled = true;
elements.pushSaveButton.disabled = true;
elements.pushTestButton.disabled = true;
elements.pushDeleteButton.disabled = true;
setPushStatus('加载中...');
try {
const result = await requestJSON('/api/push/channels');
state.pushChannels = (result.data || []).map(normalizePushChannel);
state.selectedPushChannel = state.pushChannels.length ? 0 : -1;
state.pushLoaded = true;
renderPushSettings();
setPushStatus('');
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushAddButton.disabled = false;
elements.pushSaveButton.disabled = !state.pushLoaded;
}
}
function openPushModal() {
elements.pushModal.classList.add('is-open');
elements.pushModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
if (!state.pushLoaded) {
loadPushChannels();
} else {
renderPushSettings();
setPushStatus('');
}
}
function closePushModal() {
syncCurrentPushChannel();
elements.pushModal.classList.remove('is-open');
elements.pushModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
}
async function savePushChannels(event) {
event.preventDefault();
syncCurrentPushChannel();
elements.pushSaveButton.disabled = true;
setPushStatus('保存中...');
try {
const result = await requestJSON('/api/push/channels', {
method: 'PUT',
body: JSON.stringify({ channels: state.pushChannels })
});
state.pushChannels = (result.data || []).map(normalizePushChannel);
if (state.selectedPushChannel >= state.pushChannels.length) {
state.selectedPushChannel = state.pushChannels.length - 1;
}
renderPushSettings();
setPushStatus(result.message || '推送通道配置已保存。', 'success');
await refreshLogs();
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushSaveButton.disabled = false;
}
}
async function testPushChannel() {
syncCurrentPushChannel();
const channel = state.pushChannels[state.selectedPushChannel];
if (!channel) {
setPushStatus('请先新增一个推送通道。', 'error');
return;
}
elements.pushTestButton.disabled = true;
setPushStatus('测试发送中...');
try {
const result = await requestJSON('/api/push/test', {
method: 'POST',
body: JSON.stringify({ channel })
});
setPushStatus(result.message || '测试推送已发送。', 'success');
await refreshLogs();
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushTestButton.disabled = false;
}
}
function handlePushTypeChange() {
const controls = getPushControls();
const channel = state.pushChannels[state.selectedPushChannel];
const previousType = channel?.type || 'dingtalk';
const previousDefaultName = createDefaultPushChannel(previousType).name;
const type = controls.type.value;
const nextDefaultName = createDefaultPushChannel(type).name;
const currentName = controls.name.value.trim();
updatePushFieldVisibility(type);
if (!currentName || currentName === previousDefaultName) {
controls.name.value = nextDefaultName;
}
if (type === 'custom' && !controls.customBody.value.trim()) {
controls.customBody.value = createDefaultPushChannel('custom').customBody;
}
syncCurrentPushChannel();
}
async function refreshLogs() {
const logs = await requestJSON('/api/logs');
renderLogs(logs.data || []);
}
async function refreshMessages() {
const params = new URLSearchParams({ limit: '50', scope: 'all' });
const messages = await requestJSON(`/api/sms/received?${params.toString()}`);
renderMessages(messages.data || [], messages.meta || {});
}
async function refreshSim() {
const sim = await requestJSON('/api/modem/sim?refresh=true');
renderSimStatus(sim.data || {});
return sim;
}
async function switchSim(slot) {
if (state.simBusy) {
return;
}
const slotNumber = Number.parseInt(slot, 10);
if (!Number.isInteger(slotNumber)) {
return;
}
if (!window.confirm(`切换到SIM${slotNumber + 1}会重新初始化SIM、驻网和短信功能,期间可能短暂收不到短信。确定继续吗?`)) {
return;
}
state.simBusy = true;
renderSimStatus({
...(state.currentSim || {}),
switching: true
});
try {
const result = await requestJSON('/api/modem/sim/switch', {
method: 'POST',
body: JSON.stringify({ slot: slotNumber })
});
renderSimStatus(result.data || {});
setStatusMessage(elements.simStatus, result.message || `已切换到SIM${slotNumber + 1}`, 'success');
await refreshAll();
} catch (err) {
setStatusMessage(elements.simStatus, err.message, 'error');
await refreshSim().catch(() => {});
} finally {
state.simBusy = false;
renderSimStatus(state.currentSim || {});
}
}
async function toggleMobileData() {
if (state.mobileDataBusy) {
return;
}
const action = elements.mobileDataToggle.dataset.action || 'disable';
const enabled = action === 'enable';
if (enabled && !window.confirm('开启移动数据可能产生流量费用,确定要开启吗?')) {
return;
}
state.mobileDataBusy = true;
elements.mobileDataToggle.disabled = true;
setStatusMessage(elements.mobileDataStatus, enabled ? '正在开启...' : '正在关闭...');
try {
const result = await requestJSON('/api/modem/mobile-data', {
method: 'POST',
body: JSON.stringify({ enabled })
});
renderMobileData(result.data || {});
setStatusMessage(elements.mobileDataStatus, result.message || (enabled ? '已开启。' : '已关闭。'), 'success');
await refreshLogs();
} catch (err) {
setStatusMessage(elements.mobileDataStatus, err.message, 'error');
} finally {
state.mobileDataBusy = 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 || {});
}
}
async function refreshAll() {
if (state.busy) {
return;
}
state.busy = true;
elements.refreshButton.disabled = true;
elements.refreshButton.textContent = '刷新中';
try {
const status = await requestJSON('/api/status');
const info = await requestJSON('/api/modem/info');
const sim = await requestJSON('/api/modem/sim?refresh=true');
renderStatus(status, info, sim);
setStatusMessage(elements.mobileDataStatus, '');
await refreshMessages();
await refreshLogs();
} catch (err) {
setText(elements.readyBadge, '刷新失败');
setText(elements.panelStatus, 'error');
setText(elements.connectionState, '查询失败');
setText(elements.connectionHint, err.message);
} finally {
elements.refreshButton.disabled = false;
elements.refreshButton.textContent = '刷新状态';
state.busy = false;
}
}
async function sendSMS(event) {
event.preventDefault();
const submit = elements.smsForm.querySelector('button[type="submit"]');
const formData = new FormData(elements.smsForm);
const phone = String(formData.get('phone') || '').trim();
const message = String(formData.get('message') || '').trim();
if (!phone || !message) {
setStatusMessage(elements.smsStatus, '号码和内容都不能为空。', 'error');
return;
}
submit.disabled = true;
renderSendSimLabel();
setStatusMessage(elements.smsStatus, `正在使用 ${getActiveSimLabel()} 发送...`);
try {
const result = await requestJSON('/api/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, message })
});
setStatusMessage(elements.smsStatus, result.message || '短信发送成功。', result.success ? 'success' : 'error');
await refreshLogs();
} catch (err) {
setStatusMessage(elements.smsStatus, err.message, 'error');
} finally {
submit.disabled = false;
}
}
async function runATCommand(event) {
event.preventDefault();
const submit = elements.atForm.querySelector('button[type="submit"]');
const command = String(new FormData(elements.atForm).get('command') || '').trim();
if (!command) {
return;
}
submit.disabled = true;
setText(elements.consoleState, 'running');
elements.atOutput.textContent = ` ${command}\n等待响应...`;
try {
const result = await requestJSON('/api/modem/at', {
method: 'POST',
body: JSON.stringify({ command })
});
elements.atOutput.textContent = ` ${command}\n${result.data.response || ''}`.trim();
setText(elements.consoleState, 'done');
await refreshLogs();
} catch (err) {
elements.atOutput.textContent = ` ${command}\nERROR: ${err.message}`;
setText(elements.consoleState, 'error');
} finally {
submit.disabled = false;
}
}
elements.pushSettingsButton.addEventListener('click', openPushModal);
elements.pushModalClose.addEventListener('click', closePushModal);
elements.pushModal.addEventListener('click', (event) => {
if (event.target.matches('[data-close-push-modal]')) {
closePushModal();
}
});
elements.pushAddButton.addEventListener('click', addPushChannel);
elements.pushDeleteButton.addEventListener('click', deletePushChannel);
elements.pushChannelForm.addEventListener('submit', savePushChannels);
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.simSwitchButtons.forEach((button) => {
button.addEventListener('click', () => {
switchSim(button.dataset.simSwitch);
});
});
elements.logsButton.addEventListener('click', refreshLogs);
elements.messagesButton.addEventListener('click', refreshMessages);
elements.smsForm.addEventListener('submit', sendSMS);
elements.atForm.addEventListener('submit', runATCommand);
const ctaPushButton = document.querySelector('#ctaPushButton');
const ctaRefreshButton = document.querySelector('#ctaRefreshButton');
const footerPushLink = document.querySelector('#footerPushLink');
const footerYear = document.querySelector('#footerYear');
if (ctaPushButton) ctaPushButton.addEventListener('click', openPushModal);
if (ctaRefreshButton) ctaRefreshButton.addEventListener('click', refreshAll);
if (footerPushLink) footerPushLink.addEventListener('click', (event) => {
event.preventDefault();
openPushModal();
});
if (footerYear) footerYear.textContent = String(new Date().getFullYear());
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
closePushModal();
}
});
refreshAll();
setInterval(() => {
refreshMessages().catch(() => {});
}, 5000);