Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a52add4e | ||
|
|
f63fd6bc75 | ||
|
|
038668b47e | ||
|
|
cba3973632 | ||
|
|
83118db289 |
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.env
|
||||
.npm
|
||||
.npm-cache
|
||||
.vscode
|
||||
.idea
|
||||
node_modules
|
||||
logs
|
||||
data
|
||||
config.json
|
||||
*.log
|
||||
*.tgz
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
AGENTS.md
|
||||
DESIGN.md
|
||||
ML307A_*.md
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
config.json
|
||||
.DS_Store
|
||||
@@ -10,4 +11,6 @@ config.json
|
||||
*.swo
|
||||
*~
|
||||
.npm
|
||||
.npm-cache/
|
||||
*.tgz
|
||||
.eslintcache
|
||||
|
||||
-310
@@ -1,310 +0,0 @@
|
||||
# 4G模块AT指令交互文档
|
||||
|
||||
## 串口配置
|
||||
|
||||
- **波特率**: 115200
|
||||
- **数据位**: 8
|
||||
- **停止位**: 1
|
||||
- **校验**: None
|
||||
- **流控**: None
|
||||
|
||||
## 初始化流程
|
||||
|
||||
### 1. 基础握手
|
||||
```
|
||||
AT
|
||||
响应: OK
|
||||
作用: 测试模组是否响应
|
||||
```
|
||||
|
||||
### 2. 查询模组信息
|
||||
```
|
||||
ATI
|
||||
响应:
|
||||
厂商名称
|
||||
型号
|
||||
固件版本
|
||||
OK
|
||||
作用: 获取模组厂商、型号、固件版本
|
||||
```
|
||||
|
||||
### 3. 禁用数据连接(防止流量消耗)
|
||||
```
|
||||
AT+CGACT=0,1
|
||||
响应: OK
|
||||
作用: 关闭4G数据连接,防止自动联网消耗流量
|
||||
注意: ML307Y型号此命令有bug,需跳过
|
||||
```
|
||||
|
||||
### 4. 配置短信URC上报
|
||||
```
|
||||
AT+CNMI=2,2,0,0,0
|
||||
响应: OK
|
||||
作用: 配置短信到达时主动上报(URC模式)
|
||||
参数说明:
|
||||
- 第1参数=2: 缓冲URC到TE
|
||||
- 第2参数=2: 短信直接上报+CMT URC(PDU模式)
|
||||
- 其余参数=0: 禁用状态报告、广播消息等
|
||||
```
|
||||
|
||||
### 5. 设置PDU模式
|
||||
```
|
||||
AT+CMGF=0
|
||||
响应: OK
|
||||
作用: 设置短信为PDU模式(支持中文、长短信)
|
||||
```
|
||||
|
||||
### 6. 等待网络注册
|
||||
```
|
||||
AT+CEREG?
|
||||
响应: +CEREG: <n>,<stat>
|
||||
OK
|
||||
作用: 查询LTE网络注册状态
|
||||
<stat>值:
|
||||
- 0: 未注册,未搜索
|
||||
- 1: 已注册,本地网络
|
||||
- 2: 未注册,正在搜索
|
||||
- 3: 注册被拒绝
|
||||
- 4: 未知状态
|
||||
- 5: 已注册,漫游网络
|
||||
成功条件: <stat>=1 或 5
|
||||
```
|
||||
|
||||
## 短信接收(URC自动上报)
|
||||
|
||||
当收到短信时,模组会主动发送:
|
||||
|
||||
```
|
||||
+CMT: ,<length>
|
||||
<PDU_HEX_STRING>
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```
|
||||
+CMT: ,26
|
||||
0891683108200005F0040D91683158148764F30000222151114270802C4F60597D
|
||||
```
|
||||
|
||||
### PDU解析
|
||||
|
||||
PDU是十六进制字符串,需要使用PDU解析库(如 `node-pdu`)解析:
|
||||
- **发送者号码**
|
||||
- **时间戳**(YYMMDDHHMMSS格式,带时区)
|
||||
- **短信内容**(支持中文UCS2编码)
|
||||
- **长短信信息**(参考号、当前段号、总段数)
|
||||
|
||||
### 长短信处理
|
||||
|
||||
长短信PDU包含额外头部(UDH):
|
||||
- **参考号** (Reference Number): 用于标识同一条长短信的不同分段
|
||||
- **总段数** (Total Parts): 该长短信分为几段
|
||||
- **当前段号** (Part Number): 当前是第几段(从1开始)
|
||||
|
||||
**合并逻辑**:
|
||||
1. 检测到长短信(总段数 > 1)时,创建缓存槽位
|
||||
2. 使用 `(参考号 + 发送者号码)` 作为唯一标识
|
||||
3. 收到每一段后,存入对应位置
|
||||
4. 收齐所有分段后,按段号顺序拼接内容
|
||||
5. 30秒超时保护:未收齐也强制转发已收到的部分
|
||||
|
||||
## 短信发送(PDU模式)
|
||||
|
||||
### 1. 编码PDU
|
||||
使用PDU编码库将目标号码和短信内容编码为PDU十六进制字符串。
|
||||
|
||||
### 2. 发送AT+CMGS命令
|
||||
```
|
||||
AT+CMGS=<length>
|
||||
响应: > (提示符)
|
||||
作用: 准备发送短信,<length>为PDU数据长度(字节数,不含SMSC)
|
||||
```
|
||||
|
||||
### 3. 发送PDU数据
|
||||
```
|
||||
<PDU_HEX_STRING><Ctrl+Z>
|
||||
响应: +CMGS: <mr>
|
||||
OK
|
||||
作用: 发送PDU数据,以Ctrl+Z(0x1A)结束
|
||||
<mr>: 消息参考号
|
||||
```
|
||||
|
||||
**完整流程示例**:
|
||||
```
|
||||
-> AT+CMGS=23
|
||||
<- >
|
||||
-> 0011000D91683158148764F30000AA05E4BDA0E5A5BD1A
|
||||
<- +CMGS: 123
|
||||
OK
|
||||
```
|
||||
|
||||
## 其他常用命令
|
||||
|
||||
### 查询信号强度
|
||||
```
|
||||
AT+CSQ
|
||||
响应: +CSQ: <rssi>,<ber>
|
||||
OK
|
||||
<rssi>: 信号强度 (0-31, 99=未知)
|
||||
- 0-9: 弱
|
||||
- 10-14: 一般
|
||||
- 15-19: 好
|
||||
- 20-31: 很好
|
||||
- 99: 未知或不可检测
|
||||
```
|
||||
|
||||
### 查询SIM卡状态
|
||||
```
|
||||
AT+CPIN?
|
||||
响应: +CPIN: READY (或其他状态)
|
||||
OK
|
||||
状态:
|
||||
- READY: SIM卡已就绪
|
||||
- SIM PIN: 需要PIN码
|
||||
- SIM PUK: 需要PUK码
|
||||
```
|
||||
|
||||
### 查询ICCID
|
||||
```
|
||||
AT+CCID
|
||||
响应: +CCID: <iccid>
|
||||
OK
|
||||
作用: 获取SIM卡ICCID(集成电路卡识别码)
|
||||
```
|
||||
|
||||
### 查询IMEI
|
||||
```
|
||||
AT+GSN
|
||||
响应: <imei>
|
||||
OK
|
||||
作用: 获取模组IMEI号
|
||||
```
|
||||
|
||||
### 查询运营商
|
||||
```
|
||||
AT+COPS?
|
||||
响应: +COPS: <mode>,<format>,<oper>
|
||||
OK
|
||||
示例: +COPS: 0,0,"CHINA MOBILE"
|
||||
```
|
||||
|
||||
### 临时激活数据连接(用于Ping)
|
||||
```
|
||||
AT+CGACT=1,1
|
||||
响应: OK
|
||||
作用: 激活数据连接(用于网络测试后需关闭)
|
||||
```
|
||||
|
||||
### Ping测试
|
||||
```
|
||||
AT+CPING="www.baidu.com",1,4,64,1000,10000,0
|
||||
响应: +CPING: 1,<ip>,<time>,<ttl>
|
||||
+CPING: 2,0,0,0,0
|
||||
OK
|
||||
参数: 域名,回显次数,数据包大小,超时,间隔,最大等待,保留
|
||||
注意: 需要先激活数据连接(AT+CGACT=1,1)
|
||||
```
|
||||
|
||||
### 删除所有短信(清理存储空间)
|
||||
```
|
||||
AT+CMGD=1,4
|
||||
响应: OK
|
||||
作用: 删除所有短信(参数4=删除所有)
|
||||
```
|
||||
|
||||
## URC(主动上报)消息
|
||||
|
||||
### 短信到达
|
||||
```
|
||||
+CMT: ,<length>
|
||||
<PDU_HEX_STRING>
|
||||
```
|
||||
|
||||
### 网络注册状态变化
|
||||
```
|
||||
+CEREG: <stat>
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见ERROR原因
|
||||
- **命令格式错误**: 检查AT命令语法
|
||||
- **参数错误**: 检查参数范围和类型
|
||||
- **模组未就绪**: 确保模组已完成初始化
|
||||
- **网络未注册**: 等待CEREG状态变为1或5
|
||||
- **SIM卡未就绪**: 检查SIM卡是否插好
|
||||
|
||||
### 超时处理
|
||||
- **AT命令超时**: 一般1-2秒,网络相关命令5-10秒
|
||||
- **发送短信超时**: 30秒
|
||||
- **网络注册超时**: 30次重试(约30-60秒)
|
||||
|
||||
### 重试策略
|
||||
- 初始化命令失败:立即重试
|
||||
- 网络注册失败:等待后重试
|
||||
- 发送短信失败:记录日志,不重试
|
||||
|
||||
## Node.js实现要点
|
||||
|
||||
### 串口读取
|
||||
- 使用 `serialport` 库
|
||||
- 设置行解析器 (`@serialport/parser-readline`)
|
||||
- 监听数据事件,逐行处理
|
||||
|
||||
### AT命令发送
|
||||
```javascript
|
||||
async function sendATCommand(cmd, timeout = 2000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
port.write(cmd + '\r\n');
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Timeout'));
|
||||
}, timeout);
|
||||
|
||||
const handler = (data) => {
|
||||
buffer += data;
|
||||
if (buffer.includes('OK') || buffer.includes('ERROR')) {
|
||||
clearTimeout(timer);
|
||||
port.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
port.on('data', handler);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### PDU解析
|
||||
使用 `node-pdu` 库:
|
||||
```javascript
|
||||
const PDU = require('node-pdu');
|
||||
|
||||
const parsed = PDU.parse(pduHexString);
|
||||
// parsed.sender: 发送者号码
|
||||
// parsed.text: 短信内容
|
||||
// parsed.time: 时间戳
|
||||
```
|
||||
|
||||
### URC监听
|
||||
```javascript
|
||||
parser.on('data', (line) => {
|
||||
if (line.startsWith('+CMT:')) {
|
||||
// 下一行是PDU数据
|
||||
isWaitingPDU = true;
|
||||
} else if (isWaitingPDU && /^[0-9A-Fa-f]+$/.test(line)) {
|
||||
// 收到PDU数据
|
||||
handleSMS(line);
|
||||
isWaitingPDU = false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 状态机设计
|
||||
|
||||
```
|
||||
IDLE状态
|
||||
└─ 收到 +CMT: → WAIT_PDU状态
|
||||
└─ 收到十六进制行 → 解析PDU → 处理短信 → IDLE状态
|
||||
└─ 收到非十六进制行 → IDLE状态
|
||||
```
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NPM_CONFIG_AUDIT=false \
|
||||
NPM_CONFIG_FUND=false \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NPM_CONFIG_AUDIT=false \
|
||||
NPM_CONFIG_FUND=false \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY config.example.json ./
|
||||
|
||||
RUN mkdir -p logs data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
# 跨平台支持说明
|
||||
|
||||
本项目支持在 **Windows** 和 **Linux** 上运行。
|
||||
|
||||
## 串口配置
|
||||
|
||||
### Windows
|
||||
|
||||
Windows 使用 `COMx` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以直接写数字,程序会自动添加 `COM` 前缀:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "3",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查看可用串口**:
|
||||
```powershell
|
||||
# 方法1: 设备管理器
|
||||
devmgmt.msc
|
||||
|
||||
# 方法2: PowerShell
|
||||
Get-WmiObject Win32_SerialPort | Select-Object Name, DeviceID
|
||||
|
||||
# 方法3: 使用 mode 命令
|
||||
mode
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 使用 `/dev/ttyUSBx` 或 `/dev/ttyACMx` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "/dev/ttyUSB0",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查看可用串口**:
|
||||
```bash
|
||||
# 查看所有USB串口设备
|
||||
ls -l /dev/ttyUSB*
|
||||
ls -l /dev/ttyACM*
|
||||
|
||||
# 查看详细信息
|
||||
dmesg | grep tty
|
||||
|
||||
# 使用 lsusb 查看USB设备
|
||||
lsusb
|
||||
```
|
||||
|
||||
**权限配置**(Linux必需):
|
||||
```bash
|
||||
# 将当前用户添加到 dialout 组
|
||||
sudo usermod -a -G dialout $USER
|
||||
|
||||
# 注销并重新登录后生效,或临时授权
|
||||
sudo chmod 666 /dev/ttyUSB0
|
||||
```
|
||||
|
||||
## 平台差异
|
||||
|
||||
### 文件路径
|
||||
- Windows: 使用反斜杠 `\` 或正斜杠 `/` (Node.js会自动处理)
|
||||
- Linux: 使用正斜杠 `/`
|
||||
|
||||
本项目使用 Node.js 的 `path` 模块,会自动处理平台差异。
|
||||
|
||||
### PM2 守护进程
|
||||
|
||||
Windows 和 Linux 都支持 PM2,但启动脚本略有不同:
|
||||
|
||||
**Windows**:
|
||||
```powershell
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# 按提示执行生成的命令(需要管理员权限)
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# 按提示执行生成的命令(使用 sudo)
|
||||
```
|
||||
|
||||
### 日志目录
|
||||
|
||||
两个平台都会在项目根目录下创建 `logs/` 目录:
|
||||
- Windows: `.\logs\`
|
||||
- Linux: `./logs/`
|
||||
|
||||
## 测试串口连接
|
||||
|
||||
可以使用以下工具测试串口是否正常:
|
||||
|
||||
### Windows
|
||||
- **PuTTY**: https://www.putty.org/
|
||||
- **Tera Term**: https://ttssh2.osdn.jp/
|
||||
- **串口调试助手**
|
||||
|
||||
### Linux
|
||||
- **minicom**: `sudo apt install minicom && sudo minicom -D /dev/ttyUSB0 -b 115200`
|
||||
- **screen**: `sudo screen /dev/ttyUSB0 115200`
|
||||
- **picocom**: `sudo apt install picocom && sudo picocom /dev/ttyUSB0 -b 115200`
|
||||
|
||||
测试方法:
|
||||
1. 打开串口工具,连接到对应COM口
|
||||
2. 发送 `AT` 命令
|
||||
3. 应该收到 `OK` 响应
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Windows
|
||||
|
||||
**Q: 提示串口被占用**
|
||||
```
|
||||
Error: Port is already open
|
||||
```
|
||||
A: 检查是否有其他程序(如Arduino IDE、串口调试助手)占用了该端口
|
||||
|
||||
**Q: 找不到COM口**
|
||||
A:
|
||||
1. 检查设备管理器中4G模块是否正确识别
|
||||
2. 确认驱动是否已安装
|
||||
3. 尝试拔插USB重新识别
|
||||
|
||||
**Q: 权限不足**
|
||||
A: Windows通常不需要特殊权限,如果遇到问题,尝试以管理员身份运行
|
||||
|
||||
### Linux
|
||||
|
||||
**Q: 权限不足**
|
||||
```
|
||||
Error: Opening COM1: Permission denied
|
||||
```
|
||||
A:
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
# 注销重新登录
|
||||
```
|
||||
|
||||
**Q: 串口不存在**
|
||||
```
|
||||
Error: No such file or directory, cannot open /dev/ttyUSB0
|
||||
```
|
||||
A:
|
||||
```bash
|
||||
# 检查设备是否识别
|
||||
dmesg | tail -20
|
||||
lsusb
|
||||
# 可能是 ttyACM0 而不是 ttyUSB0
|
||||
ls /dev/tty*
|
||||
```
|
||||
|
||||
**Q: ModemManager占用串口**
|
||||
A: 某些Linux发行版的ModemManager会自动占用4G模块
|
||||
```bash
|
||||
# 临时停用
|
||||
sudo systemctl stop ModemManager
|
||||
|
||||
# 永久禁用(不推荐,可能影响系统网络功能)
|
||||
sudo systemctl disable ModemManager
|
||||
```
|
||||
|
||||
## 开发环境
|
||||
|
||||
### Node.js 版本
|
||||
- 推荐: Node.js 18.x LTS 或更高
|
||||
- 最低: Node.js 16.x
|
||||
|
||||
### 依赖库
|
||||
所有依赖都是跨平台的:
|
||||
- `serialport` - 底层使用 C++ 绑定,支持 Windows/Linux/macOS
|
||||
- `express` - Web框架
|
||||
- `nodemailer` - 邮件发送
|
||||
- `axios` - HTTP客户端
|
||||
- `winston` - 日志库
|
||||
|
||||
## 性能建议
|
||||
|
||||
### Windows
|
||||
- 关闭Windows Defender实时扫描对 `node_modules/` 的监控
|
||||
- 使用SSD存储项目文件
|
||||
|
||||
### Linux
|
||||
- 使用 `noatime` 挂载选项减少磁盘IO
|
||||
- 调整串口缓冲区大小(如有需要):
|
||||
```bash
|
||||
setserial /dev/ttyUSB0 low_latency
|
||||
```
|
||||
|
||||
## 部署建议
|
||||
|
||||
### Windows Server
|
||||
- 使用 PM2 作为守护进程
|
||||
- 配置 Windows 防火墙允许 API 端口(默认3000)
|
||||
- 设置 PM2 开机自启动
|
||||
|
||||
### Linux Server
|
||||
- 使用 PM2 + systemd
|
||||
- 配置 UFW/iptables 防火墙
|
||||
- 考虑使用 Nginx 反向代理
|
||||
@@ -1,212 +0,0 @@
|
||||
# Node.js 4G SMS Gateway
|
||||
|
||||
基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 4G模块AT指令交互
|
||||
- ✅ 自动接收短信(PDU模式)
|
||||
- ✅ 长短信自动合并
|
||||
- ✅ 多通道推送(邮件/钉钉/飞书/Telegram等)
|
||||
- ✅ 短信发送功能
|
||||
- ✅ 号码黑名单
|
||||
- ✅ 管理员远程命令
|
||||
- ✅ RESTful API接口
|
||||
- ✅ Web管理界面
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 18.x
|
||||
- 4G模块通过USB转串口连接到服务器
|
||||
- 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
复制配置模板:
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
编辑 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"_comment": "Windows使用COM1/COM2/COM3等; Linux使用/dev/ttyUSB0"
|
||||
},
|
||||
"smtp": {
|
||||
"server": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "your@qq.com",
|
||||
"pass": "your_auth_code",
|
||||
"sendTo": "recipient@example.com"
|
||||
},
|
||||
"adminPhone": "13800138000",
|
||||
"numberBlackList": [],
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
"type": "dingtalk",
|
||||
"name": "钉钉通知",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"secret": "SECxxx"
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产模式
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### 使用PM2(推荐)
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 查询状态
|
||||
```bash
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
### 发送短信
|
||||
```bash
|
||||
POST /api/sms/send
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"message": "测试短信"
|
||||
}
|
||||
```
|
||||
|
||||
### 查询信号强度
|
||||
```bash
|
||||
GET /api/modem/signal
|
||||
```
|
||||
|
||||
### 查询模组信息
|
||||
```bash
|
||||
GET /api/modem/info
|
||||
```
|
||||
|
||||
### 发送AT命令
|
||||
```bash
|
||||
POST /api/modem/at
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "AT+CSQ"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取日志
|
||||
```bash
|
||||
GET /api/logs
|
||||
```
|
||||
|
||||
所有API均需要HTTP Basic认证。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── index.js # 入口文件
|
||||
│ ├── modem.js # 4G模块通信
|
||||
│ ├── sms.js # 短信处理
|
||||
│ ├── push.js # 推送通道
|
||||
│ ├── concat.js # 长短信合并
|
||||
│ ├── api.js # REST API
|
||||
│ └── logger.js # 日志系统
|
||||
├── config.json # 配置文件
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 推送通道类型
|
||||
|
||||
| 类型 | type值 | 配置项 |
|
||||
|------|--------|--------|
|
||||
| 钉钉机器人 | `dingtalk` | url, secret(可选) |
|
||||
| 飞书机器人 | `feishu` | url, secret(可选) |
|
||||
| Telegram Bot | `telegram` | url(bot token), key1(chat_id) |
|
||||
| PushPlus | `pushplus` | key1(token) |
|
||||
| Server酱 | `serverchan` | key1(sendkey) |
|
||||
| Bark | `bark` | url(bark服务器) |
|
||||
| POST JSON | `post_json` | url |
|
||||
| GET请求 | `get` | url |
|
||||
| 自定义模板 | `custom` | url, customBody |
|
||||
|
||||
## 管理员命令
|
||||
|
||||
通过短信发送命令(仅管理员号码):
|
||||
|
||||
### 远程发送短信
|
||||
```
|
||||
SMS:目标号码:短信内容
|
||||
```
|
||||
示例:`SMS:13800138000:测试消息`
|
||||
|
||||
### 重启系统
|
||||
```
|
||||
RESET
|
||||
```
|
||||
|
||||
## 长短信处理
|
||||
|
||||
- 自动检测长短信(分段数 > 1)
|
||||
- 使用参考号和发送者号码标识同一条长短信
|
||||
- 收齐所有分段后自动合并
|
||||
- 30秒超时保护(未收齐也转发)
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 串口打不开
|
||||
```bash
|
||||
# Linux检查串口设备
|
||||
ls -l /dev/ttyUSB*
|
||||
# 添加用户到dialout组
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
|
||||
### 模块不响应
|
||||
- 检查串口路径和波特率
|
||||
- 检查USB连接
|
||||
- 检查SIM卡是否插好
|
||||
|
||||
### 收不到短信
|
||||
- 检查AT+CEREG?网络注册状态
|
||||
- 检查AT+CNMI配置是否正确
|
||||
- 检查SIM卡余额和服务状态
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
@@ -70,6 +70,7 @@
|
||||
],
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token",
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Before starting:
|
||||
# cp config.example.json config.json
|
||||
# Keep serial.autoDetect enabled in config.json so the app can probe visible serial ports.
|
||||
services:
|
||||
sms-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: sms-forwarding:latest
|
||||
container_name: sms-forwarding
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "${SMS_GATEWAY_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
- /dev:/dev
|
||||
- /sys:/sys:ro
|
||||
device_cgroup_rules:
|
||||
- "c 188:* rwm"
|
||||
- "c 166:* rwm"
|
||||
Generated
+2048
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -21,7 +21,7 @@
|
||||
"dependencies": {
|
||||
"serialport": "^12.0.0",
|
||||
"@serialport/parser-readline": "^12.0.0",
|
||||
"node-pdu": "^0.2.1",
|
||||
"node-pdu": "^2.1.2",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Gateway 管理台</title>
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__inner">
|
||||
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
||||
<span class="brand__mark" aria-hidden="true">✣</span>
|
||||
<span>SMS Gateway</span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="管理导航">
|
||||
<a href="#status">状态</a>
|
||||
<a href="#inbox">收件箱</a>
|
||||
<a href="#send">短信</a>
|
||||
<a href="#console">AT 控制台</a>
|
||||
<a href="#logs">日志</a>
|
||||
</nav>
|
||||
<div class="top-nav__actions">
|
||||
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
||||
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero-band" id="status">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-copy">
|
||||
<span class="badge badge--coral" id="readyBadge">连接检测中</span>
|
||||
<h1>短信网关管理台</h1>
|
||||
<p>集中查看 ML307A 模组状态、发送短信、执行 AT 命令,并检查运行日志。</p>
|
||||
</div>
|
||||
|
||||
<div class="product-panel" aria-label="运行摘要">
|
||||
<div class="panel-header">
|
||||
<span>runtime</span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
</div>
|
||||
<dl class="runtime-list">
|
||||
<div>
|
||||
<dt>模组</dt>
|
||||
<dd id="modelName">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运营商</dt>
|
||||
<dd id="operator">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>信号</dt>
|
||||
<dd id="signalQuality">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行时长</dt>
|
||||
<dd id="uptime">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band section-band--soft">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>当前状态</h2>
|
||||
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">connection</span>
|
||||
<h3 id="connectionState">--</h3>
|
||||
<p id="connectionHint">等待状态刷新。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">signal</span>
|
||||
<h3 id="signalValue">--</h3>
|
||||
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">module</span>
|
||||
<h3 id="moduleModel">--</h3>
|
||||
<p id="moduleVersion">--</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">sim</span>
|
||||
<h3 id="iccid">--</h3>
|
||||
<p>ICCID 查询来自当前串口模组。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band">
|
||||
<div class="container" id="inbox">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="badge badge--soft">inbox</span>
|
||||
<h2>收到的短信</h2>
|
||||
</div>
|
||||
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||
</div>
|
||||
<div class="inbox-panel">
|
||||
<div class="inbox-meta">
|
||||
<span id="messageCount">最近 0 条</span>
|
||||
<span>新短信会自动出现在这里</span>
|
||||
</div>
|
||||
<div class="message-list" id="messageList" aria-live="polite">
|
||||
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band section-band--soft">
|
||||
<div class="container workbench-grid">
|
||||
<section class="form-card" id="send">
|
||||
<div class="section-heading section-heading--compact">
|
||||
<h2>发送短信</h2>
|
||||
<p>使用当前 PDU 模式发送短信,提交后等待模组返回发送结果。</p>
|
||||
</div>
|
||||
<form id="smsForm" class="stack-form">
|
||||
<label>
|
||||
<span>目标号码</span>
|
||||
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容" required></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="button button--primary" type="submit">发送短信</button>
|
||||
<p class="form-status" id="smsStatus" role="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="console-card" id="console">
|
||||
<div class="panel-header">
|
||||
<span>AT console</span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</div>
|
||||
<form id="atForm" class="at-form">
|
||||
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required>
|
||||
<button class="button button--secondary-on-dark" type="submit">执行</button>
|
||||
</form>
|
||||
<pre class="console-output" id="atOutput">等待命令...</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band section-band--dark" id="logs">
|
||||
<div class="container">
|
||||
<div class="dark-heading">
|
||||
<div>
|
||||
<span class="badge badge--dark">logs</span>
|
||||
<h2>运行日志</h2>
|
||||
</div>
|
||||
<button class="button button--secondary-on-dark" id="logsButton" type="button">刷新日志</button>
|
||||
</div>
|
||||
<div class="log-window" id="logWindow" aria-live="polite">
|
||||
<p class="empty-state">等待日志加载。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal" id="pushModal" aria-hidden="true">
|
||||
<div class="modal__backdrop" data-close-push-modal></div>
|
||||
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
||||
<div class="modal__header">
|
||||
<div>
|
||||
<span class="badge badge--soft">push</span>
|
||||
<h2 id="pushModalTitle">推送渠道设置</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
|
||||
</div>
|
||||
|
||||
<div class="push-settings">
|
||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||
<div class="panel-header panel-header--light">
|
||||
<span>channels</span>
|
||||
<div class="channel-add">
|
||||
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="telegram">Telegram Bot</option>
|
||||
<option value="pushplus">PushPlus</option>
|
||||
<option value="serverchan">Server酱</option>
|
||||
<option value="post_json">POST JSON</option>
|
||||
<option value="bark">Bark</option>
|
||||
<option value="get">GET</option>
|
||||
<option value="custom">自定义 POST</option>
|
||||
</select>
|
||||
<button class="button button--secondary button--compact" id="pushAddButton" type="button">新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-list" id="pushChannelList"></div>
|
||||
</aside>
|
||||
|
||||
<form class="channel-form" id="pushChannelForm">
|
||||
<fieldset class="form-fieldset" id="pushFormFields" disabled>
|
||||
<div class="channel-form__grid">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" name="enabled">
|
||||
<span>启用通道</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>通道名称</span>
|
||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>推送类型</span>
|
||||
<select class="text-input select-input" name="type">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="telegram">Telegram Bot</option>
|
||||
<option value="pushplus">PushPlus</option>
|
||||
<option value="serverchan">Server酱</option>
|
||||
<option value="post_json">POST JSON</option>
|
||||
<option value="bark">Bark</option>
|
||||
<option value="get">GET</option>
|
||||
<option value="custom">自定义 POST</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="url">
|
||||
<span id="pushUrlLabel">Webhook URL</span>
|
||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="secret">
|
||||
<span id="pushSecretLabel">加签密钥</span>
|
||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key1">
|
||||
<span id="pushKey1Label">Key1</span>
|
||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key2">
|
||||
<span id="pushKey2Label">Key2</span>
|
||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="customBody">
|
||||
<span>自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions channel-form__actions">
|
||||
<button class="button button--primary" id="pushSaveButton" type="submit">保存设置</button>
|
||||
<button class="button button--secondary" id="pushTestButton" type="button">发送测试</button>
|
||||
<button class="button button--danger" id="pushDeleteButton" type="button">删除通道</button>
|
||||
<p class="form-status" id="pushStatus" role="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
|
||||
<span>Basic Auth protected management surface</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,987 @@
|
||||
:root {
|
||||
--primary: #cc785c;
|
||||
--primary-active: #a9583e;
|
||||
--primary-disabled: #e6dfd8;
|
||||
--ink: #141413;
|
||||
--body: #3d3d3a;
|
||||
--body-strong: #252523;
|
||||
--muted: #6c6a64;
|
||||
--muted-soft: #8e8b82;
|
||||
--hairline: #e6dfd8;
|
||||
--hairline-soft: #ebe6df;
|
||||
--canvas: #faf9f5;
|
||||
--surface-soft: #f5f0e8;
|
||||
--surface-card: #efe9de;
|
||||
--surface-dark: #181715;
|
||||
--surface-dark-elevated: #252320;
|
||||
--surface-dark-soft: #1f1e1b;
|
||||
--on-primary: #ffffff;
|
||||
--on-dark: #faf9f5;
|
||||
--on-dark-soft: #a09d96;
|
||||
--accent-teal: #5db8a6;
|
||||
--accent-amber: #e8a55a;
|
||||
--success: #5db872;
|
||||
--warning: #d4a017;
|
||||
--error: #c64545;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
color: var(--body);
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1200px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: 64px;
|
||||
background: rgba(250, 249, 245, 0.94);
|
||||
border-bottom: 1px solid var(--hairline-soft);
|
||||
}
|
||||
|
||||
.top-nav__inner {
|
||||
width: min(1200px, calc(100% - 48px));
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-left: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-links a:focus-visible,
|
||||
.button:focus-visible,
|
||||
.icon-button:focus-visible,
|
||||
.channel-list__item:focus-visible,
|
||||
.text-input:focus-visible,
|
||||
.select-input:focus-visible {
|
||||
outline: 3px solid rgba(204, 120, 92, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.top-nav__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.button--primary:active {
|
||||
background: var(--primary-active);
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.button--secondary-on-dark {
|
||||
background: var(--surface-dark-elevated);
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border: 1px solid rgba(198, 69, 69, 0.28);
|
||||
background: rgba(198, 69, 69, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.button--compact {
|
||||
min-height: 32px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 9999px;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
padding: 72px 0 96px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
|
||||
align-items: stretch;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
color: var(--ink);
|
||||
font-family: "Cormorant Garamond", "EB Garamond", Georgia, "Times New Roman", serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 700px;
|
||||
margin-top: 18px;
|
||||
font-size: 64px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
max-width: 620px;
|
||||
margin-top: 22px;
|
||||
color: var(--body-strong);
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
border-radius: 9999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge--coral {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.badge--dark {
|
||||
background: var(--surface-dark-elevated);
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.badge--soft {
|
||||
background: var(--surface-card);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.product-panel,
|
||||
.console-card {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-dark);
|
||||
color: var(--on-dark);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--on-dark-soft);
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runtime-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
margin: 32px 0 0;
|
||||
}
|
||||
|
||||
.runtime-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgba(250, 249, 245, 0.1);
|
||||
}
|
||||
|
||||
.runtime-list dt {
|
||||
color: var(--on-dark-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-list dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--on-dark);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section-band {
|
||||
padding: 96px 0;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.section-band--soft {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.section-band--dark {
|
||||
background: var(--surface-dark);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
max-width: 560px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-heading--compact {
|
||||
display: block;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-heading--compact p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-card,
|
||||
.form-card {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
color: var(--muted);
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(420px, 1.1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.inbox-panel {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.inbox-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
background: var(--canvas);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.message-item__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-item__header strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-item__header time {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
border-radius: 9999px;
|
||||
background: var(--surface-card);
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-item.is-forwarded .message-status {
|
||||
background: rgba(93, 184, 114, 0.16);
|
||||
color: #2f7c44;
|
||||
}
|
||||
|
||||
.message-item.is-admin .message-status {
|
||||
background: rgba(232, 165, 90, 0.2);
|
||||
color: #8a5b16;
|
||||
}
|
||||
|
||||
.message-item.is-ignored .message-status {
|
||||
background: rgba(198, 69, 69, 0.12);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
margin-top: 12px;
|
||||
color: var(--body);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stack-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--body-strong);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 8px;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: 3px solid rgba(204, 120, 92, 0.15);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
min-height: 144px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-area--code {
|
||||
min-height: 160px;
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-status {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-status.is-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.form-status.is-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.console-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(220px, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.at-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.text-input--dark {
|
||||
border-color: rgba(250, 249, 245, 0.12);
|
||||
background: var(--surface-dark-soft);
|
||||
color: var(--on-dark);
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.console-output,
|
||||
.log-window {
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-dark-soft);
|
||||
color: var(--on-dark);
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
min-height: 220px;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dark-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.dark-heading h2 {
|
||||
margin-top: 14px;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.log-window {
|
||||
height: 420px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid rgba(250, 249, 245, 0.08);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
color: var(--on-dark-soft);
|
||||
}
|
||||
|
||||
.log-line.is-error .log-level {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.log-line.is-warn .log-level {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.log-line.is-info .log-level {
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--on-dark-soft);
|
||||
}
|
||||
|
||||
.empty-state--light {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: var(--surface-dark);
|
||||
color: var(--on-dark-soft);
|
||||
border-top: 1px solid rgba(250, 249, 245, 0.08);
|
||||
padding: 32px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand--footer {
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 19, 0.48);
|
||||
}
|
||||
|
||||
.modal__dialog {
|
||||
position: relative;
|
||||
width: min(1040px, 100%);
|
||||
max-height: min(86vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: var(--canvas);
|
||||
box-shadow: 0 24px 80px rgba(20, 20, 19, 0.24);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal__header h2 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.push-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.channel-list-panel {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header--light {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.channel-list-panel .panel-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-add {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-add__select {
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.channel-list__item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 8px;
|
||||
background: var(--canvas);
|
||||
color: var(--body);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-list__item.is-active {
|
||||
border-color: var(--surface-dark);
|
||||
background: var(--surface-dark);
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.channel-list__name {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.channel-list__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-list__item.is-active .channel-list__meta {
|
||||
color: var(--on-dark-soft);
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-fieldset {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-fieldset:disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.channel-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.channel-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--body-strong);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.switch-row input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.field-row--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.channel-form__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-grid,
|
||||
.workbench-grid,
|
||||
.push-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.container,
|
||||
.top-nav__inner {
|
||||
width: min(100% - 32px, 1200px);
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.top-nav__inner {
|
||||
min-height: 64px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.top-nav__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
padding: 48px 0 64px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-band {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.dark-heading,
|
||||
.footer__inner {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-panel,
|
||||
.console-card,
|
||||
.feature-card,
|
||||
.form-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.at-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-item__header {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.message-item__header time,
|
||||
.message-status {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.log-window {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal__dialog {
|
||||
max-height: calc(100vh - 24px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
gap: 16px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.channel-form,
|
||||
.channel-list-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.channel-form__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.switch-row,
|
||||
.field-row--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
const state = {
|
||||
busy: false,
|
||||
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'),
|
||||
uptime: document.querySelector('#uptime'),
|
||||
connectionState: document.querySelector('#connectionState'),
|
||||
connectionHint: document.querySelector('#connectionHint'),
|
||||
signalValue: document.querySelector('#signalValue'),
|
||||
signalHint: document.querySelector('#signalHint'),
|
||||
moduleModel: document.querySelector('#moduleModel'),
|
||||
moduleVersion: document.querySelector('#moduleVersion'),
|
||||
iccid: document.querySelector('#iccid'),
|
||||
smsForm: document.querySelector('#smsForm'),
|
||||
smsStatus: document.querySelector('#smsStatus'),
|
||||
messagesButton: document.querySelector('#messagesButton'),
|
||||
messageCount: document.querySelector('#messageCount'),
|
||||
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) {
|
||||
node.textContent = value || '--';
|
||||
}
|
||||
|
||||
function setStatusMessage(node, message, type = '') {
|
||||
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 renderStatus(status, info) {
|
||||
const data = status.data || {};
|
||||
const infoData = info.data || {};
|
||||
const model = data.model || infoData.model || {};
|
||||
const signal = data.signal || {};
|
||||
const ready = Boolean(data.ready);
|
||||
|
||||
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));
|
||||
|
||||
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.iccid, infoData.iccid || '--');
|
||||
}
|
||||
|
||||
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) {
|
||||
elements.messageList.replaceChildren();
|
||||
setText(elements.messageCount, `最近 ${messages.length} 条`);
|
||||
|
||||
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 messages = await requestJSON('/api/sms/received?limit=50');
|
||||
renderMessages(messages.data || []);
|
||||
}
|
||||
|
||||
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');
|
||||
renderStatus(status, info);
|
||||
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;
|
||||
setStatusMessage(elements.smsStatus, '发送中...');
|
||||
|
||||
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.logsButton.addEventListener('click', refreshLogs);
|
||||
elements.messagesButton.addEventListener('click', refreshMessages);
|
||||
elements.smsForm.addEventListener('submit', sendSMS);
|
||||
elements.atForm.addEventListener('submit', runATCommand);
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
|
||||
closePushModal();
|
||||
}
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
setInterval(() => {
|
||||
refreshMessages().catch(() => {});
|
||||
}, 5000);
|
||||
+433
-11
@@ -1,13 +1,46 @@
|
||||
import fs from 'fs';
|
||||
import express from 'express';
|
||||
import basicAuth from 'express-basic-auth';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import logger from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PUSH_CHANNEL_TYPES = new Set([
|
||||
'post_json',
|
||||
'bark',
|
||||
'get',
|
||||
'dingtalk',
|
||||
'pushplus',
|
||||
'serverchan',
|
||||
'custom',
|
||||
'feishu',
|
||||
'telegram'
|
||||
]);
|
||||
|
||||
const PUSH_CHANNEL_TYPE_LABELS = {
|
||||
post_json: 'POST JSON',
|
||||
bark: 'Bark',
|
||||
get: 'GET',
|
||||
dingtalk: '钉钉',
|
||||
pushplus: 'PushPlus',
|
||||
serverchan: 'Server酱',
|
||||
custom: '自定义',
|
||||
feishu: '飞书',
|
||||
telegram: 'Telegram'
|
||||
};
|
||||
|
||||
class APIServer {
|
||||
constructor(config, modem, smsProcessor) {
|
||||
constructor(config, modem, smsProcessor, pushManager) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.smsProcessor = smsProcessor;
|
||||
this.pushManager = pushManager;
|
||||
this.app = express();
|
||||
this.publicDir = path.join(__dirname, '../public');
|
||||
this.configPath = path.join(__dirname, '../config.json');
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
@@ -17,24 +50,315 @@ class APIServer {
|
||||
// JSON解析
|
||||
this.app.use(express.json());
|
||||
|
||||
// HTTP Basic认证
|
||||
const users = {};
|
||||
users[this.config.api.auth.username] = this.config.api.auth.password;
|
||||
|
||||
this.app.use(basicAuth({
|
||||
users,
|
||||
challenge: true,
|
||||
realm: 'SMS Gateway'
|
||||
}));
|
||||
// Web Token认证,API保留Basic Auth兼容
|
||||
this.app.use((req, res, next) => {
|
||||
this.authenticateRequest(req, res, next);
|
||||
});
|
||||
|
||||
// 请求日志
|
||||
this.app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use('/assets', express.static(path.join(this.publicDir, 'assets')));
|
||||
}
|
||||
|
||||
authenticateRequest(req, res, next) {
|
||||
if (this.hasValidWebToken(req)) {
|
||||
this.persistWebToken(req, res);
|
||||
|
||||
if (this.shouldCleanTokenFromUrl(req)) {
|
||||
return res.redirect(302, this.getCleanUrl(req));
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.isWebRoute(req) && this.getConfiguredWebToken()) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
|
||||
if (this.hasValidBasicAuth(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth'
|
||||
});
|
||||
}
|
||||
|
||||
return this.sendBasicAuthChallenge(res);
|
||||
}
|
||||
|
||||
getConfiguredWebToken() {
|
||||
return String(this.config.api.webToken || '').trim();
|
||||
}
|
||||
|
||||
hasValidWebToken(req) {
|
||||
const expected = this.getConfiguredWebToken();
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = this.getRequestToken(req);
|
||||
return this.safeEqual(token, expected);
|
||||
}
|
||||
|
||||
getRequestToken(req) {
|
||||
const bearer = req.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
return req.query.token || req.get('x-web-token') || bearer || this.getCookie(req, 'sms_gateway_token') || '';
|
||||
}
|
||||
|
||||
getCookie(req, name) {
|
||||
const cookieHeader = req.get('cookie') || '';
|
||||
const cookies = cookieHeader.split(';').map(item => item.trim());
|
||||
const prefix = `${name}=`;
|
||||
const cookie = cookies.find(item => item.startsWith(prefix));
|
||||
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : '';
|
||||
}
|
||||
|
||||
persistWebToken(req, res) {
|
||||
if (!req.query.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.cookie('sms_gateway_token', req.query.token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
shouldCleanTokenFromUrl(req) {
|
||||
return this.isWebRoute(req) && Boolean(req.query.token);
|
||||
}
|
||||
|
||||
getCleanUrl(req) {
|
||||
const url = new URL(req.originalUrl, 'http://localhost');
|
||||
url.searchParams.delete('token');
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
hasValidBasicAuth(req) {
|
||||
const auth = req.get('authorization') || '';
|
||||
if (!auth.startsWith('Basic ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
||||
const separatorIndex = decoded.indexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const username = decoded.slice(0, separatorIndex);
|
||||
const password = decoded.slice(separatorIndex + 1);
|
||||
return this.safeEqual(username, this.config.api.auth.username) &&
|
||||
this.safeEqual(password, this.config.api.auth.password);
|
||||
}
|
||||
|
||||
safeEqual(actual, expected) {
|
||||
const actualBuffer = Buffer.from(String(actual));
|
||||
const expectedBuffer = Buffer.from(String(expected));
|
||||
|
||||
if (actualBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
isWebRoute(req) {
|
||||
return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/');
|
||||
}
|
||||
|
||||
sendBasicAuthChallenge(res) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="SMS Gateway"');
|
||||
return res.status(401).send('Authentication required');
|
||||
}
|
||||
|
||||
sendTokenGate(res) {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(401).type('html').send(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Gateway Token</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --canvas:#faf9f5; --ink:#141413; --body:#3d3d3a; --muted:#6c6a64; --card:#efe9de; --primary:#cc785c; --primary-active:#a9583e; --hairline:#e6dfd8; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--canvas); color: var(--body); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; }
|
||||
main { width: min(480px, calc(100% - 32px)); border-radius: 12px; background: var(--card); padding: 32px; }
|
||||
.mark { color: var(--ink); font-size: 20px; }
|
||||
h1 { margin: 18px 0 10px; color: var(--ink); font-family: "Cormorant Garamond", "EB Garamond", Georgia, serif; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: 0; }
|
||||
p { margin: 0 0 24px; color: var(--muted); }
|
||||
label { display: grid; gap: 8px; color: var(--ink); font-size: 14px; font-weight: 500; }
|
||||
input { width: 100%; min-height: 40px; border: 1px solid var(--hairline); border-radius: 8px; background: var(--canvas); color: var(--ink); padding: 10px 14px; font: inherit; }
|
||||
input:focus { border-color: var(--primary); outline: 3px solid rgba(204, 120, 92, .15); }
|
||||
button { margin-top: 18px; width: 100%; min-height: 40px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; padding: 12px 20px; font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
button:active { background: var(--primary-active); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span class="mark">✣</span>
|
||||
<h1>输入访问 token</h1>
|
||||
<p>管理台需要有效 token 才能继续访问。</p>
|
||||
<form method="get" action="/admin">
|
||||
<label>
|
||||
Token
|
||||
<input name="token" type="password" autocomplete="current-password" autofocus required>
|
||||
</label>
|
||||
<button type="submit">进入管理台</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
}
|
||||
|
||||
async readConfigFile() {
|
||||
const configContent = await fs.promises.readFile(this.configPath, 'utf8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
|
||||
async savePushChannels(channels) {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
diskConfig.pushChannels = channels;
|
||||
|
||||
await fs.promises.writeFile(
|
||||
this.configPath,
|
||||
`${JSON.stringify(diskConfig, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
this.config.pushChannels = channels;
|
||||
if (this.pushManager?.config) {
|
||||
this.pushManager.config.pushChannels = channels;
|
||||
}
|
||||
}
|
||||
|
||||
getPushChannelsFromConfig(config = this.config) {
|
||||
return Array.isArray(config.pushChannels) ? config.pushChannels : [];
|
||||
}
|
||||
|
||||
normalizePushChannels(channels) {
|
||||
if (!Array.isArray(channels)) {
|
||||
throw new Error('pushChannels 必须是数组');
|
||||
}
|
||||
|
||||
if (channels.length > 30) {
|
||||
throw new Error('推送通道数量不能超过 30 个');
|
||||
}
|
||||
|
||||
return channels.map((channel, index) => this.normalizePushChannel(channel, index));
|
||||
}
|
||||
|
||||
normalizePushChannel(channel, index = 0) {
|
||||
if (!channel || typeof channel !== 'object' || Array.isArray(channel)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道格式不正确`);
|
||||
}
|
||||
|
||||
const type = this.normalizeString(channel.type, '推送类型', 32);
|
||||
if (!PUSH_CHANNEL_TYPES.has(type)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道类型不支持`);
|
||||
}
|
||||
|
||||
const typeLabel = PUSH_CHANNEL_TYPE_LABELS[type] || type;
|
||||
const fallbackName = `${typeLabel}通知`;
|
||||
const name = this.normalizeString(channel.name || fallbackName, '通道名称', 80);
|
||||
|
||||
return {
|
||||
enabled: Boolean(channel.enabled),
|
||||
type,
|
||||
name: name || fallbackName,
|
||||
url: this.normalizeString(channel.url, 'URL', 2048),
|
||||
secret: this.normalizeString(channel.secret, '密钥', 2048),
|
||||
key1: this.normalizeString(channel.key1, 'Key1', 2048),
|
||||
key2: this.normalizeString(channel.key2, 'Key2', 2048),
|
||||
customBody: this.normalizeString(channel.customBody, '自定义请求体', 10000)
|
||||
};
|
||||
}
|
||||
|
||||
normalizeString(value, fieldName, maxLength) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maxLength) {
|
||||
throw new Error(`${fieldName}长度不能超过 ${maxLength} 个字符`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
validatePushChannels(channels) {
|
||||
channels.forEach((channel, index) => {
|
||||
this.validatePushChannel(channel, { index });
|
||||
});
|
||||
}
|
||||
|
||||
validatePushChannel(channel, options = {}) {
|
||||
const index = options.index ?? 0;
|
||||
const requireDestination = Boolean(options.requireDestination || channel.enabled);
|
||||
const prefix = `第 ${index + 1} 个推送通道`;
|
||||
const urlTypes = new Set(['post_json', 'bark', 'get', 'dingtalk', 'custom', 'feishu']);
|
||||
|
||||
if (urlTypes.has(channel.type)) {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 URL`);
|
||||
}
|
||||
|
||||
if (channel.url && !this.isHttpUrl(channel.url)) {
|
||||
throw new Error(`${prefix}的 URL 格式不正确`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'telegram') {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 Bot Token`);
|
||||
}
|
||||
|
||||
if (requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Chat ID`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'pushplus' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 PushPlus Token`);
|
||||
}
|
||||
|
||||
if (channel.type === 'serverchan' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Server酱 SendKey`);
|
||||
}
|
||||
|
||||
if (channel.type === 'custom' && channel.customBody) {
|
||||
try {
|
||||
JSON.parse(channel.customBody);
|
||||
} catch (err) {
|
||||
throw new Error(`${prefix}的自定义请求体不是有效 JSON`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/admin');
|
||||
});
|
||||
|
||||
this.app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(this.publicDir, 'admin.html'));
|
||||
});
|
||||
|
||||
// 状态查询
|
||||
this.app.get('/api/status', async (req, res) => {
|
||||
try {
|
||||
@@ -87,6 +411,23 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询收到的短信
|
||||
this.app.get('/api/sms/received', (req, res) => {
|
||||
try {
|
||||
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
|
||||
res.json({
|
||||
success: true,
|
||||
data: messages
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询收到短信失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询信号强度
|
||||
this.app.get('/api/modem/signal', async (req, res) => {
|
||||
try {
|
||||
@@ -172,6 +513,87 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询推送通道配置
|
||||
this.app.get('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
const channels = this.normalizePushChannels(this.getPushChannelsFromConfig(diskConfig));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('读取推送通道配置失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存推送通道配置
|
||||
this.app.put('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const rawChannels = Array.isArray(req.body) ? req.body : req.body?.channels;
|
||||
const channels = this.normalizePushChannels(rawChannels);
|
||||
this.validatePushChannels(channels);
|
||||
await this.savePushChannels(channels);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '推送通道配置已保存',
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('保存推送通道配置失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 测试推送通道
|
||||
this.app.post('/api/push/test', async (req, res) => {
|
||||
try {
|
||||
if (!this.pushManager) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '推送管理器未初始化'
|
||||
});
|
||||
}
|
||||
|
||||
const channel = this.normalizePushChannel(req.body?.channel || req.body);
|
||||
this.validatePushChannel(channel, { requireDestination: true });
|
||||
|
||||
const success = await this.pushManager.pushToChannel(
|
||||
{ ...channel, enabled: true },
|
||||
'测试号码',
|
||||
'这是一条短信网关推送测试消息',
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error: '测试推送发送失败,请查看运行日志'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '测试推送已发送'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('测试推送失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ async function main() {
|
||||
concatManager.startTimeoutChecker();
|
||||
|
||||
// 启动API服务器
|
||||
const apiServer = new APIServer(config, modem, smsProcessor);
|
||||
const apiServer = new APIServer(config, modem, smsProcessor, pushManager);
|
||||
apiServer.start();
|
||||
|
||||
logger.info('========================================');
|
||||
|
||||
+43
-3
@@ -72,14 +72,54 @@ class LogBuffer {
|
||||
// 创建全局日志缓冲区
|
||||
const logBuffer = new LogBuffer(120);
|
||||
|
||||
// 拦截日志输出,同时写入缓冲区
|
||||
function formatLogPart(part) {
|
||||
if (part instanceof Error) {
|
||||
return part.stack || part.message;
|
||||
}
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch (err) {
|
||||
return String(part);
|
||||
}
|
||||
}
|
||||
return String(part);
|
||||
}
|
||||
|
||||
function formatLocalTimestamp(date = new Date()) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate())
|
||||
].join('-') + ' ' + [
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds())
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function addToBuffer(level, message, args = []) {
|
||||
const timestamp = formatLocalTimestamp();
|
||||
const details = [message, ...args].map(formatLogPart).join(' ');
|
||||
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${details}`);
|
||||
}
|
||||
|
||||
// 拦截日志输出,同时写入缓冲区。winston的info/warn/error便捷方法不会走自定义log方法。
|
||||
const originalLog = logger.log.bind(logger);
|
||||
logger.log = function(level, message, ...args) {
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${message}`);
|
||||
addToBuffer(level, message, args);
|
||||
return originalLog(level, message, ...args);
|
||||
};
|
||||
|
||||
['error', 'warn', 'info', 'debug'].forEach((level) => {
|
||||
const original = logger[level].bind(logger);
|
||||
logger[level] = function(message, ...args) {
|
||||
addToBuffer(level, message, args);
|
||||
return original(message, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
// 导出日志缓冲区
|
||||
logger.getBuffer = () => logBuffer;
|
||||
|
||||
|
||||
+263
-80
@@ -1,5 +1,6 @@
|
||||
import { SerialPort } from 'serialport';
|
||||
import { ReadlineParser } from '@serialport/parser-readline';
|
||||
import { Submit } from 'node-pdu';
|
||||
import EventEmitter from 'events';
|
||||
import logger from './logger.js';
|
||||
|
||||
@@ -154,6 +155,53 @@ class ModemManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送AT命令,失败时打印模组原始响应,便于排查型号差异
|
||||
*/
|
||||
async sendATWithRetry(cmd, options = {}) {
|
||||
const {
|
||||
timeout = 2000,
|
||||
retries = 3,
|
||||
retryDelay = 1000,
|
||||
label = cmd,
|
||||
required = true
|
||||
} = options;
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const resp = await this.sendATCommand(cmd, timeout);
|
||||
if (resp.includes('OK')) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
logger.warn(`${label}失败(${attempt}/${retries}): ${this.formatATResponse(resp)}`);
|
||||
} catch (err) {
|
||||
logger.warn(`${label}失败(${attempt}/${retries}): ${err.message}`);
|
||||
}
|
||||
|
||||
if (attempt < retries && retryDelay > 0) {
|
||||
await this.sleep(retryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (required) {
|
||||
throw new Error(`${label}失败`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩AT响应,避免日志跨太多行
|
||||
*/
|
||||
formatATResponse(resp) {
|
||||
return resp
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模组
|
||||
*/
|
||||
@@ -186,45 +234,11 @@ class ModemManager extends EventEmitter {
|
||||
logger.warn('查询模组信息失败');
|
||||
}
|
||||
|
||||
// 3. 禁用数据连接(ML307Y跳过)
|
||||
if (this.modelInfo.model !== 'ML307Y') {
|
||||
retries = 0;
|
||||
while (!(await this.sendATandWaitOK('AT+CGACT=0,1', 5000)) && retries < 3) {
|
||||
logger.warn('设置CGACT失败,重试...');
|
||||
retries++;
|
||||
}
|
||||
if (retries < 3) {
|
||||
logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
|
||||
} else {
|
||||
logger.warn('设置CGACT失败,可能会消耗流量');
|
||||
}
|
||||
} else {
|
||||
logger.info('ML307Y型号跳过CGACT配置');
|
||||
}
|
||||
// 3. 按ML307A文档先确认SIM卡和协议栈状态
|
||||
await this.waitSIMReady();
|
||||
await this.ensureFullFunctionality();
|
||||
|
||||
// 4. 配置短信URC上报
|
||||
retries = 0;
|
||||
while (!(await this.sendATandWaitOK('AT+CNMI=2,2,0,0,0', 1000)) && retries < 3) {
|
||||
logger.warn('设置CNMI失败,重试...');
|
||||
retries++;
|
||||
}
|
||||
if (retries >= 3) {
|
||||
throw new Error('CNMI配置失败');
|
||||
}
|
||||
logger.info('✓ CNMI参数设置完成');
|
||||
|
||||
// 5. 设置PDU模式
|
||||
retries = 0;
|
||||
while (!(await this.sendATandWaitOK('AT+CMGF=0', 1000)) && retries < 3) {
|
||||
logger.warn('设置PDU模式失败,重试...');
|
||||
retries++;
|
||||
}
|
||||
if (retries >= 3) {
|
||||
throw new Error('PDU模式设置失败');
|
||||
}
|
||||
logger.info('✓ PDU模式设置完成');
|
||||
|
||||
// 6. 等待网络注册
|
||||
// 4. 等待网络注册
|
||||
retries = 0;
|
||||
while (!(await this.waitCEREG()) && retries < 30) {
|
||||
logger.info('等待网络注册...');
|
||||
@@ -235,14 +249,146 @@ class ModemManager extends EventEmitter {
|
||||
logger.info('✓ 网络已注册');
|
||||
this.ready = true;
|
||||
} else {
|
||||
logger.error('⚠️ 网络注册超时(无SIM卡或信号差)');
|
||||
logger.error('网络注册超时(无SIM卡或信号差)');
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
// 5. 数据连接处理。ML307文档不建议用CGACT做PDP激活/去激活。
|
||||
await this.disableDataConnection();
|
||||
|
||||
// 6. 按文档配置短信功能,并启用本项目需要的PDU模式
|
||||
await this.configureSMS();
|
||||
|
||||
logger.info('模组初始化完成');
|
||||
this.emit('ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待SIM卡完成初始化
|
||||
*/
|
||||
async waitSIMReady() {
|
||||
for (let attempt = 1; attempt <= 10; attempt++) {
|
||||
try {
|
||||
const resp = await this.sendATCommand('AT+CPIN?', 2000);
|
||||
if (resp.includes('+CPIN: READY')) {
|
||||
logger.info('✓ SIM卡已就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`);
|
||||
} catch (err) {
|
||||
logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`);
|
||||
}
|
||||
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
throw new Error('SIM卡未就绪');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保驻网前协议栈功能模式为CFUN=1
|
||||
*/
|
||||
async ensureFullFunctionality() {
|
||||
const resp = await this.sendATCommand('AT+CFUN?', 2000);
|
||||
const match = resp.match(/\+CFUN:\s*(\d+)/);
|
||||
|
||||
if (match && match[1] === '1') {
|
||||
logger.info('✓ 协议栈功能模式正常(CFUN=1)');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`当前CFUN状态不是1: ${this.formatATResponse(resp)}`);
|
||||
await this.sendATWithRetry('AT+CFUN=1', {
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
label: '设置CFUN=1'
|
||||
});
|
||||
|
||||
// 文档要求CFUN切换后等待模组完成协议栈恢复。
|
||||
await this.sleep(2000);
|
||||
logger.info('✓ 已设置协议栈功能模式(CFUN=1)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模组型号处理数据连接,避免ML307系列使用文档不推荐的CGACT。
|
||||
*/
|
||||
async disableDataConnection() {
|
||||
if (this.isML307Family()) {
|
||||
logger.info('ML307系列跳过AT+CGACT,按文档尝试断开应用层拨号(AT+MIPCALL=0,1)');
|
||||
const resp = await this.sendATWithRetry('AT+MIPCALL=0,1', {
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
label: '断开应用层拨号',
|
||||
required: false
|
||||
});
|
||||
|
||||
if (resp) {
|
||||
logger.info('✓ 已断开应用层拨号连接');
|
||||
} else {
|
||||
logger.warn('应用层拨号未断开或当前未激活,继续启动');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await this.sendATWithRetry('AT+CGACT=0,1', {
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
label: '禁用数据连接',
|
||||
required: false
|
||||
});
|
||||
|
||||
if (resp) {
|
||||
logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
|
||||
} else {
|
||||
logger.warn('设置CGACT失败,可能会消耗流量');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按ML307A文档配置短信功能;项目接收逻辑依赖PDU模式下的+CMT上报。
|
||||
*/
|
||||
async configureSMS() {
|
||||
if (this.modelInfo.version.includes('ML307A-DL')) {
|
||||
throw new Error('当前ML307A-DL型号不支持短信功能');
|
||||
}
|
||||
|
||||
await this.sendATWithRetry('AT+CMGF=0', {
|
||||
timeout: 2000,
|
||||
retries: 3,
|
||||
label: '设置PDU模式'
|
||||
});
|
||||
logger.info('✓ PDU模式设置完成');
|
||||
|
||||
await this.sendATWithRetry('AT+CNMI=2,2,0,2,0', {
|
||||
timeout: 2000,
|
||||
retries: 3,
|
||||
label: '设置CNMI短信上报'
|
||||
});
|
||||
logger.info('✓ CNMI参数设置完成');
|
||||
|
||||
await this.sendATWithRetry('AT+CSCS="IRA"', {
|
||||
timeout: 2000,
|
||||
retries: 3,
|
||||
label: '设置短信字符集'
|
||||
});
|
||||
logger.info('✓ 短信字符集设置完成');
|
||||
|
||||
await this.sendATWithRetry('AT+CSMP=33,167,0,0', {
|
||||
timeout: 2000,
|
||||
retries: 3,
|
||||
label: '设置短信发送参数'
|
||||
});
|
||||
logger.info('✓ 短信发送参数设置完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为ML307系列模组
|
||||
*/
|
||||
isML307Family() {
|
||||
return this.modelInfo.model.startsWith('ML307') || this.modelInfo.version.startsWith('ML307');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测网络注册状态
|
||||
*/
|
||||
@@ -269,56 +415,105 @@ class ModemManager extends EventEmitter {
|
||||
logger.info(`短信内容: ${message}`);
|
||||
|
||||
try {
|
||||
// 注意: node-pdu的API与C++版本不同,这里需要根据实际库调整
|
||||
const PDU = (await import('node-pdu')).default;
|
||||
|
||||
// 编码PDU
|
||||
const submit = PDU.Submit(phoneNumber, message);
|
||||
const parts = submit.getParts();
|
||||
const submit = new Submit(phoneNumber, message);
|
||||
const parts = submit.getPartStrings();
|
||||
|
||||
if (parts.length === 0) {
|
||||
throw new Error('PDU编码失败');
|
||||
}
|
||||
|
||||
// 发送第一段(简化处理,实际可能需要处理多段)
|
||||
const pduData = parts[0];
|
||||
const pduLength = Math.floor((pduData.length - parts[0].smscLength * 2) / 2);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const pduData = parts[i];
|
||||
const pduLength = this.getSubmitPduLength(pduData);
|
||||
|
||||
logger.debug(`PDU分段: ${i + 1}/${parts.length}`);
|
||||
logger.debug(`PDU数据: ${pduData}`);
|
||||
logger.debug(`PDU长度: ${pduLength}`);
|
||||
|
||||
// 发送AT+CMGS命令
|
||||
const success = await this.sendPduPart(pduData, pduLength);
|
||||
if (!success) {
|
||||
logger.error(`✗ 短信分段发送失败: ${i + 1}/${parts.length}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('✓ 短信发送成功');
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('发送短信异常:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算AT+CMGS需要的TPDU长度,不包含SMSC长度字段和SMSC内容。
|
||||
*/
|
||||
getSubmitPduLength(pduData) {
|
||||
const smscLength = parseInt(pduData.slice(0, 2), 16);
|
||||
const totalLength = pduData.length / 2;
|
||||
const submitLength = totalLength - smscLength - 1;
|
||||
|
||||
if (!Number.isFinite(submitLength) || submitLength <= 0) {
|
||||
throw new Error(`PDU长度无效: ${pduData}`);
|
||||
}
|
||||
|
||||
return submitLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单个PDU分段。
|
||||
*/
|
||||
async sendPduPart(pduData, pduLength) {
|
||||
const cmgsCmd = `AT+CMGS=${pduLength}`;
|
||||
this.port.write(cmgsCmd + '\r\n');
|
||||
|
||||
// 等待 > 提示符
|
||||
const gotPrompt = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 5000);
|
||||
const handler = (line) => {
|
||||
if (line.includes('>')) {
|
||||
clearTimeout(timer);
|
||||
this.parser.removeListener('data', handler);
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
this.parser.on('data', handler);
|
||||
});
|
||||
|
||||
const gotPrompt = await this.waitForPrompt();
|
||||
if (!gotPrompt) {
|
||||
throw new Error('未收到>提示符');
|
||||
}
|
||||
|
||||
logger.debug('收到>提示符,发送PDU数据...');
|
||||
|
||||
// 发送PDU数据 + Ctrl+Z
|
||||
this.port.write(pduData + String.fromCharCode(0x1A));
|
||||
|
||||
// 等待响应
|
||||
const success = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 30000);
|
||||
return await this.waitForCMGSResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待CMGS输入提示符。提示符通常没有行结束符,所以直接监听原始串口数据。
|
||||
*/
|
||||
waitForPrompt(timeout = 5000) {
|
||||
return new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
this.port.removeListener('data', handler);
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
const handler = (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
if (buffer.includes('>')) {
|
||||
clearTimeout(timer);
|
||||
this.port.removeListener('data', handler);
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
this.port.on('data', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待短信发送结果。
|
||||
*/
|
||||
waitForCMGSResult(timeout = 30000) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.parser.removeListener('data', handler);
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
const handler = (line) => {
|
||||
buffer += line;
|
||||
if (line.includes('OK')) {
|
||||
clearTimeout(timer);
|
||||
this.parser.removeListener('data', handler);
|
||||
@@ -329,21 +524,9 @@ class ModemManager extends EventEmitter {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.parser.on('data', handler);
|
||||
});
|
||||
|
||||
if (success) {
|
||||
logger.info('✓ 短信发送成功');
|
||||
return true;
|
||||
} else {
|
||||
logger.error('✗ 短信发送失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('发送短信异常:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+189
-13
@@ -1,12 +1,23 @@
|
||||
import PDU from 'node-pdu';
|
||||
import { parse } from 'node-pdu';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import logger from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class SMSProcessor {
|
||||
constructor(config, modem, concatManager, pushManager) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.concatManager = concatManager;
|
||||
this.pushManager = pushManager;
|
||||
this.receivedMessages = [];
|
||||
this.receivedMessageSeq = 0;
|
||||
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
||||
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
|
||||
this.loadReceivedMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,39 +28,45 @@ class SMSProcessor {
|
||||
logger.info('开始解析PDU数据...');
|
||||
|
||||
// 解析PDU
|
||||
const parsed = PDU.parse(pduHex);
|
||||
const parsed = parse(pduHex);
|
||||
|
||||
if (!parsed) {
|
||||
logger.error('PDU解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = parsed.address?.phone || '未知号码';
|
||||
const timestamp = parsed.serviceCenterTimeStamp?.getIsoString?.() || new Date().toISOString();
|
||||
const text = parsed.data?.getText?.() || '';
|
||||
const part = parsed.data?.parts?.[0];
|
||||
const header = part?.header;
|
||||
|
||||
logger.info('✓ PDU解析成功');
|
||||
logger.info(`发送者: ${parsed.sender}`);
|
||||
logger.info(`时间戳: ${parsed.time}`);
|
||||
logger.info(`内容: ${parsed.text}`);
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
|
||||
// 检查是否为长短信
|
||||
if (parsed.udh && parsed.udh.iei === 0x00) {
|
||||
if (header && header.getType() !== undefined) {
|
||||
// 长短信头部
|
||||
const refNumber = parsed.udh.data.refNumber;
|
||||
const totalParts = parsed.udh.data.parts;
|
||||
const partNumber = parsed.udh.data.current;
|
||||
const refNumber = header.getPointer();
|
||||
const totalParts = header.getSegments();
|
||||
const partNumber = header.getCurrent();
|
||||
|
||||
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
|
||||
|
||||
// 添加到长短信缓存
|
||||
this.concatManager.addPart(
|
||||
refNumber,
|
||||
parsed.sender,
|
||||
sender,
|
||||
partNumber,
|
||||
totalParts,
|
||||
parsed.text,
|
||||
parsed.time
|
||||
text,
|
||||
timestamp
|
||||
);
|
||||
} else {
|
||||
// 普通短信,直接处理
|
||||
await this.processSmsContent(parsed.sender, parsed.text, parsed.time);
|
||||
await this.processSmsContent(sender, text, timestamp);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('处理PDU失败:', err);
|
||||
@@ -66,8 +83,14 @@ class SMSProcessor {
|
||||
logger.info(`内容: ${text}`);
|
||||
logger.info('====================');
|
||||
|
||||
const messageRecord = this.addReceivedMessage(sender, text, timestamp);
|
||||
|
||||
// 检查是否在黑名单中
|
||||
if (this.isInBlacklist(sender)) {
|
||||
this.updateReceivedMessage(messageRecord.id, {
|
||||
status: 'ignored',
|
||||
statusText: '黑名单忽略'
|
||||
});
|
||||
logger.info('发送者在黑名单中,忽略该短信');
|
||||
return;
|
||||
}
|
||||
@@ -78,6 +101,10 @@ class SMSProcessor {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') {
|
||||
this.updateReceivedMessage(messageRecord.id, {
|
||||
status: 'admin',
|
||||
statusText: '管理员命令'
|
||||
});
|
||||
await this.processAdminCommand(sender, trimmedText);
|
||||
return; // 命令已处理,不再发送普通通知
|
||||
}
|
||||
@@ -90,6 +117,155 @@ class SMSProcessor {
|
||||
const subject = `短信${sender},${text.substring(0, 20)}`;
|
||||
const body = `来自:${sender},时间:${timestamp},内容:${text}`;
|
||||
await this.pushManager.sendEmail(subject, body);
|
||||
|
||||
this.updateReceivedMessage(messageRecord.id, {
|
||||
status: 'forwarded',
|
||||
statusText: '已转发'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录收到的短信,供Web管理端展示。
|
||||
*/
|
||||
addReceivedMessage(sender, text, timestamp) {
|
||||
const message = {
|
||||
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
||||
sender,
|
||||
text,
|
||||
timestamp,
|
||||
receivedAt: new Date().toISOString(),
|
||||
status: 'received',
|
||||
statusText: '已接收'
|
||||
};
|
||||
|
||||
this.receivedMessages.unshift(message);
|
||||
if (this.receivedMessages.length > this.maxReceivedMessages) {
|
||||
this.receivedMessages.length = this.maxReceivedMessages;
|
||||
}
|
||||
|
||||
this.persistReceivedMessages();
|
||||
logger.info(`短信已进入Web收件箱: ${sender}`);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收件箱短信状态。
|
||||
*/
|
||||
updateReceivedMessage(id, patch) {
|
||||
const message = this.receivedMessages.find(item => item.id === id);
|
||||
if (message) {
|
||||
Object.assign(message, patch);
|
||||
this.persistReceivedMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近收到的短信。
|
||||
*/
|
||||
getReceivedMessages(limit = 50) {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||
return this.receivedMessages.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析收件箱保留条数。
|
||||
*/
|
||||
resolveMaxReceivedMessages() {
|
||||
const configured = Number(this.config.inbox?.maxReceivedMessages);
|
||||
if (!Number.isFinite(configured) || configured <= 0) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
return Math.min(Math.floor(configured), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析收件箱持久化文件路径。
|
||||
*/
|
||||
resolveReceivedMessagesFile() {
|
||||
const configuredPath = this.config.inbox?.receivedMessagesFile || 'data/received-messages.json';
|
||||
if (path.isAbsolute(configuredPath)) {
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
return path.join(__dirname, '..', configuredPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时加载持久化收件箱。
|
||||
*/
|
||||
loadReceivedMessages() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
||||
|
||||
if (!fs.existsSync(this.receivedMessagesFile)) {
|
||||
logger.info(`短信收件箱持久化文件不存在,将在收到短信后创建: ${this.receivedMessagesFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.receivedMessagesFile, 'utf8').trim();
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger.warn('短信收件箱持久化文件格式不是数组,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
this.receivedMessages = parsed
|
||||
.map(item => this.normalizeStoredMessage(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, this.maxReceivedMessages);
|
||||
|
||||
logger.info(`已加载持久化短信收件箱: ${this.receivedMessages.length} 条`);
|
||||
} catch (err) {
|
||||
logger.error('加载短信收件箱持久化文件失败:', err);
|
||||
this.receivedMessages = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前收件箱到磁盘。
|
||||
*/
|
||||
persistReceivedMessages() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
||||
const tmpFile = `${this.receivedMessagesFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
`${JSON.stringify(this.receivedMessages, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
fs.renameSync(tmpFile, this.receivedMessagesFile);
|
||||
} catch (err) {
|
||||
logger.error('保存短信收件箱持久化文件失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化历史短信记录,避免坏数据影响Web展示。
|
||||
*/
|
||||
normalizeStoredMessage(item) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = String(item.id || '').trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
sender: String(item.sender || '未知号码'),
|
||||
text: String(item.text || ''),
|
||||
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
||||
receivedAt: String(item.receivedAt || new Date().toISOString()),
|
||||
status: String(item.status || 'received'),
|
||||
statusText: String(item.statusText || '已接收')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user