多渠道 Webhook 消息网关
在日常运维与开发中,服务器监控告警、自动化脚本(Shell/Python)、定时任务以及各类服务(如 Uptime Kuma、爱快软路由)通常需要将通知实时推送到手机端。
为了满足不同网络环境(公网 Serverless vs 局域网私有化)的需求,本文提供 两种完整且经过生产检验的自建方案:
- 方案一:Cloudflare Serverless 极速部署(推荐):基于 Cloudflare Worker + D1 数据库 / KV,0 物理服务器、0 本地资源消耗、全球边缘毫秒级并发分发,自带高安全 Token 鉴权与实时调用审计流水。
- 方案二:VPS / 本机物理独立架设:采用独立域名 + Nginx/OpenResty 反向代理 + 单文件 Python 消息网关服务 + 本地 SQLite 审计日志,适合企业内网、无公网访问限制或不依赖第三方云平台的私有化环境。
架构拓扑
+-------------------+ +------------------------------------+ +-------------------+
| 各种监控/脚本 | ----> | 消息转发网关服务端 | ----> | 企业微信 / Bark |
| (Shell/Python/路由) | | [方案一: CF Worker] 或 [方案二: Py] | | Telegram/钉钉/飞书|
+-------------------+ +------------------------------------+ +-------------------+
|
v
+-------------------+
| D1 或 本地 SQLite |
| (安全审计日志记录)|
+-------------------+
接口参数规范
| 参数名 | 类型 | 必填 | 传递位置 | 说明 |
|---|---|---|---|---|
token | String | 是 | Query / Header | 授权认证密钥(支持 ?token= 或 X-Token / X-Gotify-Key) |
title | String | 否 | Body / Query | 消息标题(可选,默认「通知消息」) |
message / content | String | 是 | Body / Query | 消息正文(支持多行换行、特殊字符与长文本) |
markdown | Boolean | 否 | Body / Query | 传入 true 可开启企业微信/Telegram富文本排版 |
📊 D1 免费额度与消息容量测算
在消息网关场景中,每触发一次 Webhook 推送,系统会自动写入 1 条调用审计流水。Cloudflare D1 免费计划提供 每日 100,000 次写入:
| 使用场景 | 每日预估消息量 | D1 每日写入额度消耗 | 额度剩余百分比 | 运行状态 |
|---|---|---|---|---|
| 个人日常运维 (登录提醒、定时备份通知) | 10 ~ 50 条 / 天 | 10 ~ 50 次 | > 99.9% | 极其轻载,近乎零消耗 |
| 团队业务监控 (CI/CD 触发、异常监控报警) | 100 ~ 500 条 / 天 | 100 ~ 500 次 | > 99.5% | 轻松承载,余量极充裕 |
| 高频交易/自动化事件推送 | 1,000 ~ 5,000 条 / 天 | 1,000 ~ 5,000 次 | 95.0% ~ 99.0% | 稳定运行无压力 |
| 全天极限并发承载能力 | 100,000 条 / 天 | 100,000 次 (写满) | 0% (达上限) | 支持全天平均 1.15 条/秒持续推送 |
🔒 推送链路安全隔离机制(零阻断保证)
- 核心转发独立运行:消息网关将外部 API(企业微信、Bark、Telegram 等)的转发逻辑与日志存储完全解耦;
- 容错隔离保护:即使在极端高频调用下 D1 数据库或 KV 写入达到配额上限,系统亦有
try...catch兜底保护,所有消息推送依然会 100% 毫秒级正常送达,绝不会因日志记录超额阻断告警通知。
💡 为什么私有化 Python 消息网关能支持高频并发?
如果选择方案二在 VPS 本机物理架设,单文件 Python 服务端采用多线程 HTTP 服务配合 SQLite WAL 模式:
- 毫秒级响应:消息接收后立即通过线程池异步转发并入库,单次请求响应耗时在 20ms 以内;
- 轻量长效:常驻内存占用保持在 < 15MB,完全不占服务器资源;
- 安全审计日志:自动保存最近 50 条流水到本地 SQLite,支持 Web 控制台可视化检索与调用排查。
方案一:Cloudflare Serverless 部署(推荐)
该方案将消息网关完全托管在 Cloudflare 全球边缘网络,秒级响应,免除一切服务器维护成本。
第一步:创建 D1 数据库(可选,用于审计日志)
- 登录 Cloudflare Dashboard;
- 进入 Workers & Pages -> D1 SQL Database;
- 点击 Create Database,名称填写
sms_db并创建。
(注:代码内置双引擎适配器,未绑定 D1 时亦可自动兼容 KV 命名空间 AUTH_HUB_KV 或纯无状态转发)
第二步:创建 Worker 并配置密钥
- 进入 Workers & Pages -> Overview,点击 Create Worker,命名为
sms-forwarder并部署; - 进入 Settings -> Bindings(可选):
- 点击 Add -> D1 Database Binding,变量名填写
DB,关联sms_db;
- 点击 Add -> D1 Database Binding,变量名填写
- 进入 Settings -> Variables and Secrets,添加以下环境变量(敏感密钥点击 Encrypt 加密):
ADMIN_KEY:管理员密码(用于登录 Web 控制台查看实时流水与额度);DEFAULT_TOKEN:默认推送 Token(客户端推送时的鉴权凭据);WECOM_CORPID:企业微信 CorpID;WECOM_SECRET:企业微信应用 Secret;WECOM_AGENTID:企业微信应用 AgentID;BARK_KEY:(可选)Bark 设备 Key;TG_BOT_TOKEN:(可选)Telegram Bot Token;TG_CHAT_ID:(可选)Telegram Chat ID。
第三步:粘贴开源代码并部署
点击 Edit Code,将本文文末提供的 完整 Cloudflare Worker 开源代码 复制覆盖 worker.js 并点击 Deploy。随后在 Domains & Routes 绑定你的域名(如 sms.example.com)即可上线!
方案二:VPS / 本机物理独立架设
如果你希望在私有 VPS 或局域网物理机上搭建,使用 Nginx + 单文件 Python 脚本 + SQLite 即可实现同款功能。
第一步:部署 Python 服务端程序
在服务器上创建目录 /opt/sms-gateway/ 并编写服务端启动脚本 server.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SMS & Webhook Message Gateway - Standalone Python Push Server (私有化独立消息网关)
- 零第三方依赖 (纯 Python 3 标准库 http.server + sqlite3 + urllib.request)
- 内置 SQLite 审计日志与企业微信/Bark/Telegram 转发引擎
- 内存占用 < 15MB
"""
import os, sys, json, time, sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import urllib.request, urllib.parse
ADMIN_KEY = "YOUR_ADMIN_KEY_HERE"
DEFAULT_TOKEN = "YOUR_SMS_TOKEN_HERE"
WECOM_CORPID = "YOUR_WECOM_CORPID_HERE"
WECOM_SECRET = "YOUR_WECOM_SECRET_HERE"
WECOM_AGENTID = 1000002
DB_PATH = "/opt/sms-gateway/sms.db"
PORT = 28082
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS push_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
ts INTEGER NOT NULL,
ip TEXT NOT NULL,
token_id TEXT NOT NULL,
token_name TEXT NOT NULL,
title TEXT NOT NULL,
preview TEXT NOT NULL,
status TEXT NOT NULL
)
''')
conn.commit()
conn.close()
def send_wecom(title, message, is_markdown=False):
if not WECOM_CORPID or not WECOM_SECRET:
return False
try:
# 1. Get Access Token
token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={WECOM_CORPID}&corpsecret={WECOM_SECRET}"
req = urllib.request.Request(token_url)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
access_token = data.get('access_token')
if not access_token:
return False
# 2. Send Message
send_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
if is_markdown:
payload = {
"touser": "@all",
"msgtype": "markdown",
"agentid": WECOM_AGENTID,
"markdown": {"content": f"### {title}\n{message}"}
}
else:
payload = {
"touser": "@all",
"msgtype": "text",
"agentid": WECOM_AGENTID,
"text": {"content": f"【{title}】\n{message}"}
}
body = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(send_url, data=body, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=5) as resp:
res = json.loads(resp.read().decode('utf-8'))
return res.get('errcode') == 0
except Exception as e:
print("WeCom send error:", e)
return False
class GatewayHandler(BaseHTTPRequestHandler):
def send_json(self, data, status=200):
body = json.dumps(data).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
url = urlparse(self.path)
if url.path == '/health':
self.send_json({'status': 'healthy', 'time': int(time.time())})
elif url.path == '/api/logs':
auth = self.headers.get('Authorization', '')
key = self.headers.get('X-Admin-Key', '')
if ADMIN_KEY not in auth and key != ADMIN_KEY:
return self.send_json({'status': 'fail', 'error': 'Unauthorized'}, 401)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT time, ts, ip, token_id, token_name, title, preview, status FROM push_logs ORDER BY ts DESC LIMIT 50')
rows = c.fetchall()
conn.close()
logs = []
for r in rows:
logs.append({
'time': r[0], 'ts': r[1], 'ip': r[2], 'token_id': r[3],
'token_name': r[4], 'title': r[5], 'preview': r[6], 'status': r[7]
})
self.send_json({'status': 'success', 'logs': logs})
elif url.path == '/message':
query = parse_qs(url.query)
token = query.get('token', [''])[0] or self.headers.get('x-token', '')
if token != DEFAULT_TOKEN and token != ADMIN_KEY:
return self.send_json({'status': 'fail', 'error': 'Invalid Token'}, 403)
title = query.get('title', ['通知消息'])[0]
message = query.get('message', query.get('content', query.get('text', [''])))[0]
if not message:
return self.send_json({'status': 'success', 'msg': '无消息内容,已忽略'})
is_markdown = query.get('markdown', ['false'])[0].lower() in ['true', '1']
send_wecom(title, message, is_markdown)
self.record_log(title, message)
self.send_json({'status': 'success', 'msg': '推送成功'})
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
url = urlparse(self.path)
if url.path == '/message':
query = parse_qs(url.query)
token = query.get('token', [''])[0] or self.headers.get('x-token', '')
if token != DEFAULT_TOKEN and token != ADMIN_KEY:
return self.send_json({'status': 'fail', 'error': 'Invalid Token'}, 403)
length = int(self.headers.get('Content-Length', 0))
raw_body = self.rfile.read(length).decode('utf-8')
content_type = self.headers.get('Content-Type', '')
title = '通知消息'
message = ''
is_markdown = False
if 'application/json' in content_type:
try:
data = json.loads(raw_body)
title = data.get('title', '通知消息')
message = data.get('message', data.get('content', data.get('text', '')))
is_markdown = data.get('markdown', False) or data.get('type') == 'markdown'
except Exception:
pass
else:
form_data = parse_qs(raw_body)
title = form_data.get('title', ['通知消息'])[0]
message = form_data.get('message', form_data.get('content', form_data.get('text', [''])))[0]
is_markdown = form_data.get('markdown', ['false'])[0].lower() in ['true', '1']
if not message:
return self.send_json({'status': 'success', 'msg': '无消息内容,已忽略'})
send_wecom(title, message, is_markdown)
self.record_log(title, message)
return self.send_json({'status': 'success', 'msg': '推送成功'})
else:
self.send_response(404)
self.end_headers()
def record_log(self, title, message):
try:
now = int(time.time())
time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(now))
client_ip = self.headers.get('X-Real-IP', self.client_address[0])
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO push_logs (time, ts, ip, token_id, token_name, title, preview, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (time_str, now, client_ip, 'default', '默认通道', title, message[:100], 'success'))
conn.commit()
conn.close()
except Exception as e:
print("Log record error:", e)
if __name__ == '__main__':
init_db()
server = HTTPServer(('127.0.0.1', PORT), GatewayHandler)
print(f"SMS Gateway standalone server started on http://127.0.0.1:{PORT}")
server.serve_forever()
第二步:配置 Systemd 守护进程
创建 /etc/systemd/system/sms-gateway.service:
[Unit]
Description=SMS & Webhook Message Gateway Standalone Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/sms-gateway
ExecStart=/usr/bin/python3 /opt/sms-gateway/server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
启动并设置开机自启:
systemctl daemon-reload
systemctl enable --now sms-gateway.service
第三步:配置 Nginx / OpenResty 反向代理
在 Nginx 中配置站点(如 /etc/nginx/conf.d/sms.conf):
server {
listen 80;
server_name sms.example.com;
location / {
proxy_pass http://127.0.0.1:28082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
💻 常用调用示例(全方案通用)
无论使用方案一还是方案二,客户端调用的 HTTP 接口参数完全一致:
1. cURL GET 方式极速推送
curl "https://sms.example.com/message?token=YOUR_SMS_TOKEN_HERE&title=服务器告警&message=CPU使用率超过90%"
2. cURL POST JSON 方式推送(支持 Markdown)
curl -X POST "https://sms.example.com/message?token=YOUR_SMS_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"title": "备份完成", "message": "数据库已成功同步至冷备节点。", "markdown": true}'
3. Python 脚本极速推送
import urllib.request, json
payload = {
"title": "CI/CD 构建成功",
"message": "生产镜像 v1.0.8 已成功发布并上线。"
}
req = urllib.request.Request(
"https://sms.example.com/message?token=YOUR_SMS_TOKEN_HERE",
data=json.dumps(payload).encode('utf-8'),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp:
print(resp.read().decode('utf-8'))
📜 完整服务端开源代码 (Cloudflare Worker)
以下为方案一对应的全部 Worker 源码(集成 Cloudflare D1 SQL 关系型数据库存储、湖青 5 色现代护眼控制台 UI、实时调用流水动态轮询与配额统计、多渠道 Webhook 消息转发分发):
/**
* Cloudflare Worker: Serverless Webhook 消息推送网关 (sms.example.com)
* 纯 Cloudflare D1 关系型 SQL 数据库驱动 (每日 100,000 次免费写入 · 专为高频推送设计)
*
* 核心特性:
* - 纯 D1 数据库引擎 (无 KV 依赖,持久化日志与多 Token)
* - 兼容历史通道:预置默认 Token (YOUR_SMS_TOKEN) 与 历史旧版 Token (jJQOMPfL)
* - 三级视图分段切换:【📊 概览卡片】 / 【🟩 30天推送点阵】 / 【📋 流水明细】
* - 30 天消息推送热力图点阵 (GitHub 风格,真实统计每日消息发送量与健康度)
* - Multi-Token 多通道密钥管理 (在线创建/随机生成/专属分发/独立吊销)
* - 悬浮毛玻璃「返回顶部」平滑滚动按钮
* - IP 级防爆破惩罚引擎 (失败延时 + 阶梯封禁 + Webhook 告警)
* - 微信/企业微信 (WeCom) 企业应用通道直达 (@all 或指定接收人)
*/
// Built-in Legacy Tokens from Japanese Server (arm2 /config.json)
const LEGACY_TOKENS = [
{ id: "legacy_jJQOMPfL", name: "📱 小米11Pro 专属推送", token: "jJQOMPfL", remark: "小米 11Pro 手机自动化打卡与考勤通知", created_at: 1786800000 },
{ id: "legacy_3fgh4y", name: "📢 呆呆面板 / 通用签到", token: "3fgh4yfgh473212", remark: "呆呆面板 (D2550:5701) 7大打卡自动化 (联通/电信/天翼/B站/阿里/夸克/京东)", created_at: 1786800007 },
{ id: "legacy_fdngkee5yt", name: "🐙 代码仓库监控消息", token: "fdngkee5yt", remark: "GitHub Actions CI / SkerrySSH / Fuwari 博客构建通知", created_at: 1786800001 },
{ id: "legacy_AYGPO", name: "💻 运维脚本推送", token: "AYGPO-mCIMX21l7", remark: "本地运维 Python / Shell 脚本运行结果推送", created_at: 1786800002 },
{ id: "legacy_26ktxwAT", name: "🚨 业务紧急告警", token: "26ktxwAT", remark: "核心服务器宕机 / 关键服务异常熔断紧急告警", created_at: 1786800003 },
{ id: "legacy_BohTLS5z", name: "📊 AWS Lightsail 监控", token: "BohTLS5zTxwUA5", remark: "AWS 节点流量监控与关机探针生命周期提醒", created_at: 1786800004 },
{ id: "legacy_M2dA0Op", name: "🖥️ PPanel 面板告警", token: "M2dA0OpMIb1chk", remark: "PPanel 机场面板工单 / 支付回调 / 订单核账告警", created_at: 1786800005 },
{ id: "legacy_jk4iuty", name: "🛡️ 系统安全加固", token: "jk4iutyjkdfngkekjt", remark: "防暴力破解 / SSH 登录异常与 IP 封锁通知", created_at: 1786800006 },
{ id: "legacy_tdfgert", name: "📝 个人博客动态提醒", token: "tdfgert4FFWAF", remark: "example.com 博客文章发布与评论动态提醒", created_at: 1786800008 },
{ id: "token_node_alerts", name: "🌐 代理节点升级与告警", token: "node_alert_k82md91jx", remark: "13 节点探针 node_probe.py 状态轮询与内核升级通知", created_at: 1787367796 }
];
// ==================== Storage Adapter (Pure Cloudflare D1 SQL) ====================
const Storage = {
getDb(env) {
const db = env.DB || env.SMS_DB || env.PROBE_DB || null;
if (!db) {
throw new Error("未检测到 Cloudflare D1 数据库绑定 (变量名: DB)!消息网关已全面采用 D1 关系型数据库。");
}
return db;
},
async initDb(db) {
if (!this._dbInitialized) {
await db.prepare(`
CREATE TABLE IF NOT EXISTS sms_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
ts INTEGER NOT NULL,
ip TEXT NOT NULL,
token_id TEXT NOT NULL,
token_name TEXT NOT NULL,
title TEXT NOT NULL,
preview TEXT NOT NULL,
status TEXT NOT NULL
)
`).run();
await db.prepare(`
CREATE TABLE IF NOT EXISTS sms_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL,
remark TEXT
)
`).run();
try { await db.prepare("ALTER TABLE sms_tokens ADD COLUMN remark TEXT;").run(); } catch(e) {}
await db.prepare(`
CREATE TABLE IF NOT EXISTS sms_auth_locks (
ip TEXT PRIMARY KEY,
fail_count INTEGER NOT NULL,
first_fail INTEGER NOT NULL,
last_fail INTEGER NOT NULL,
blocked_until INTEGER NOT NULL
)
`).run();
await db.prepare(`
CREATE TABLE IF NOT EXISTS sms_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`).run();
await db.prepare(`
CREATE TABLE IF NOT EXISTS sms_daily_stats (
date TEXT PRIMARY KEY,
count INTEGER NOT NULL
)
`).run();
this._dbInitialized = true;
}
},
async getSettings(env) {
const defaultSettings = {
auth_lock_alert: env.AUTH_LOCK_ALERT || "0"
};
const db = this.getDb(env);
try {
await this.initDb(db);
const { results } = await db.prepare("SELECT key, value FROM sms_settings").all();
if (results && results.length > 0) {
const s = { ...defaultSettings };
for (const row of results) {
if (row.key === 'auth_lock_alert') s.auth_lock_alert = row.value;
}
return s;
}
} catch (e) {
console.error("D1 getSettings error:", e);
}
return defaultSettings;
},
async saveSettings(env, settings) {
const now = Math.floor(Date.now() / 1000);
const db = this.getDb(env);
await this.initDb(db);
if (settings.auth_lock_alert !== undefined) {
await db.prepare(`
INSERT INTO sms_settings (key, value, updated_at) VALUES ('auth_lock_alert', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).bind(String(settings.auth_lock_alert), now).run();
}
},
async getDefaultToken(env) {
const db = this.getDb(env);
try {
await this.initDb(db);
const row = await db.prepare("SELECT value FROM sms_settings WHERE key = 'default_token'").first();
if (row && row.value && row.value.trim() !== '') {
return row.value.trim();
}
} catch (e) {}
return env.DEFAULT_TOKEN || DEFAULT_SMS_TOKEN;
},
async setDefaultToken(env, newTokenStr) {
const cleanToken = (newTokenStr || '').trim();
if (!cleanToken) return false;
const now = Math.floor(Date.now() / 1000);
const db = this.getDb(env);
await this.initDb(db);
await db.prepare(`
INSERT INTO sms_settings (key, value, updated_at)
VALUES ('default_token', ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`).bind(cleanToken, now).run();
return true;
},
// ---------- Anti-Brute-Force Locks ----------
async getAuthLock(env, ip) {
const strIp = String(ip || '127.0.0.1').trim();
const db = this.getDb(env);
try {
await this.initDb(db);
const row = await db.prepare("SELECT fail_count, first_fail, last_fail, blocked_until FROM sms_auth_locks WHERE ip = ?").bind(strIp).first();
if (row) return row;
} catch (e) {
console.error("D1 getAuthLock error:", e);
}
return null;
},
async recordAuthFail(env, ip) {
const strIp = String(ip || '127.0.0.1').trim();
const now = Math.floor(Date.now() / 1000);
const existing = await this.getAuthLock(env, strIp);
let failCount = 1;
let firstFail = now;
let blockedUntil = 0;
if (existing) {
if (now - existing.last_fail < 900) {
failCount = (existing.fail_count || 0) + 1;
firstFail = existing.first_fail || now;
}
}
if (failCount >= 10) {
blockedUntil = now + 86400;
} else if (failCount >= 5) {
blockedUntil = now + 900;
}
const lockData = { ip: strIp, fail_count: failCount, first_fail: firstFail, last_fail: now, blocked_until: blockedUntil };
const db = this.getDb(env);
await this.initDb(db);
await db.prepare(`
INSERT INTO sms_auth_locks (ip, fail_count, first_fail, last_fail, blocked_until)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
fail_count = excluded.fail_count,
first_fail = excluded.first_fail,
last_fail = excluded.last_fail,
blocked_until = excluded.blocked_until
`).bind(strIp, failCount, firstFail, now, blockedUntil).run();
const settings = await this.getSettings(env);
if (settings.auth_lock_alert === '1' || settings.auth_lock_alert === true) {
if (failCount === 5 || failCount === 10) {
const lockMin = Math.ceil((blockedUntil - now) / 60);
try {
await sendWeCom(
env,
'@all',
'⚠️ 消息网关管理后台遭受密码暴力破解',
`检测到来源 IP [${strIp}] 连续输错管理员密码达 ${failCount} 次,触发系统防爆破拦截策略,该 IP 已被封锁 ${lockMin} 分钟。`,
false
);
} catch (e) {}
}
}
return lockData;
},
async clearAuthLock(env, ip) {
const strIp = String(ip || '127.0.0.1').trim();
const db = this.getDb(env);
try {
await this.initDb(db);
await db.prepare("DELETE FROM sms_auth_locks WHERE ip = ?").bind(strIp).run();
} catch (e) {}
},
// ---------- Push Logs & Statistics ----------
async clearLogs(env) {
const db = this.getDb(env);
try {
await this.initDb(db);
// ONLY delete raw message details, KEEP sms_daily_stats intact!
await db.prepare("DELETE FROM sms_logs").run();
return true;
} catch (e) {
console.error("D1 clearLogs error:", e);
return false;
}
},
async recordLog(env, logItem) {
const db = this.getDb(env);
try {
await this.initDb(db);
await db.prepare(`
INSERT INTO sms_logs (time, ts, ip, token_id, token_name, title, preview, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
logItem.time,
logItem.ts,
logItem.ip,
logItem.token_id,
logItem.token_name,
logItem.title,
logItem.preview,
logItem.status
).run();
// Accumulate daily stats (decoupled from raw logs)
const dayKey = formatBeijingDate(logItem.ts || Date.now());
await db.prepare(`
INSERT INTO sms_daily_stats (date, count) VALUES (?, 1)
ON CONFLICT(date) DO UPDATE SET count = count + 1
`).bind(dayKey).run();
} catch (e) {
console.error("D1 recordLog error:", e);
}
},
async getLogsAndQuota(env) {
const db = this.getDb(env);
let logs = [];
let todayCount = 0;
const now = Date.now();
const todayKey = formatBeijingDate(now);
const thirtyDaysAgoTs = now - (30 * 86400 * 1000);
const thirtyDaysAgoKey = formatBeijingDate(thirtyDaysAgoTs);
let dailyCounts = {};
try {
await this.initDb(db);
const { results } = await db.prepare(
"SELECT time, ts, ip, token_id, token_name, title, preview, status FROM sms_logs ORDER BY ts DESC LIMIT 50"
).all();
logs = results || [];
// Query daily stats table first
const todayStatRow = await db.prepare(
"SELECT count FROM sms_daily_stats WHERE date = ?"
).bind(todayKey).first();
if (todayStatRow && todayStatRow.count !== undefined) {
todayCount = todayStatRow.count;
} else {
// Fallback: calculate from logs if daily_stats hasn't recorded yet
const startOfDayBeijing = now - ((now + 8 * 3600 * 1000) % (24 * 3600 * 1000));
const countRow = await db.prepare(
"SELECT COUNT(*) as cnt FROM sms_logs WHERE ts >= ?"
).bind(startOfDayBeijing).first();
todayCount = countRow ? (countRow.cnt || 0) : 0;
if (todayCount > 0) {
try {
await db.prepare("INSERT OR REPLACE INTO sms_daily_stats (date, count) VALUES (?, ?)").bind(todayKey, todayCount).run();
} catch(e){}
}
}
// Aggregate 30-day daily counts from sms_daily_stats
const statsRows = await db.prepare(
"SELECT date, count FROM sms_daily_stats WHERE date >= ? ORDER BY date ASC"
).bind(thirtyDaysAgoKey).all();
if (statsRows && statsRows.results && statsRows.results.length > 0) {
for (const r of statsRows.results) {
dailyCounts[r.date] = r.count;
}
} else {
// Fallback from logs if daily_stats empty
const historyRows = await db.prepare(
"SELECT ts FROM sms_logs WHERE ts >= ? ORDER BY ts ASC"
).bind(thirtyDaysAgoTs).all();
if (historyRows && historyRows.results) {
for (const r of historyRows.results) {
const dk = formatBeijingDate(r.ts);
dailyCounts[dk] = (dailyCounts[dk] || 0) + 1;
}
}
}
} catch (e) {
console.error("D1 getLogs error:", e);
}
const dailyLimit = 100000;
const usedPct = ((todayCount / dailyLimit) * 100).toFixed(2);
const remain = Math.max(0, dailyLimit - todayCount);
const remainPct = (100 - parseFloat(usedPct)).toFixed(2);
return {
logs,
daily_counts: dailyCounts,
quota: {
daily_limit: dailyLimit,
today_count: todayCount,
used_pct: usedPct,
remain_count: remain,
remain_pct: remainPct,
storage_engine: "Cloudflare D1 SQL 数据库"
}
};
},
// ---------- Multi-Token Management (Full CRUD in D1 SQL) ----------
async getAllTokens(env) {
const db = this.getDb(env);
await this.initDb(db);
const defaultToken = await this.getDefaultToken(env);
const list = [
{
id: "default",
name: "🚀 网关默认主通道",
token: defaultToken,
remark: "网关全局默认消息通道 / Webhook 通用转发",
created_at: 1786900000,
status: "active",
is_default: true
}
];
try {
// Auto seed legacy tokens if sms_tokens table has no rows
const countRow = await db.prepare("SELECT COUNT(*) as cnt FROM sms_tokens").first();
if (!countRow || countRow.cnt === 0) {
for (const lt of LEGACY_TOKENS) {
try {
await db.prepare(`
INSERT INTO sms_tokens (id, name, token, remark, created_at, status)
VALUES (?, ?, ?, ?, ?, active)
`).bind(lt.id, lt.name, lt.token, lt.remark || "", lt.created_at).run();
} catch (e) {}
}
}
const { results } = await db.prepare("SELECT id, name, token, remark, created_at, status FROM sms_tokens ORDER BY created_at ASC").all();
if (results && results.length > 0) {
for (const r of results) {
let remark = r.remark || "";
if (!remark) {
const matched = LEGACY_TOKENS.find(lt => lt.id === r.id || lt.token === r.token);
if (matched && matched.remark) {
remark = matched.remark;
try {
await db.prepare("UPDATE sms_tokens SET remark = ? WHERE id = ?").bind(remark, r.id).run();
} catch(e) {}
}
}
list.push({
id: r.id,
name: r.name,
token: r.token,
remark: remark || "通用推送服务调用",
created_at: r.created_at,
status: r.status || "active",
is_default: false
});
}
}
} catch (e) {
console.error("D1 getAllTokens error:", e);
}
return list;
},
async createToken(env, name, tokenStr, remarkStr) {
const id = "tok_" + Math.random().toString(36).substring(2, 10);
const now = Math.floor(Date.now() / 1000);
const cleanName = (name || "未命名推送通道").trim();
const cleanRemark = (remarkStr || "").trim();
let cleanToken = (tokenStr || "").trim();
if (!cleanToken) {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
cleanToken = "sms_";
for (let i = 0; i < 18; i++) cleanToken += chars.charAt(Math.floor(Math.random() * chars.length));
}
const db = this.getDb(env);
await this.initDb(db);
await db.prepare(`
INSERT INTO sms_tokens (id, name, token, remark, created_at, status)
VALUES (?, ?, ?, ?, ?, active)
`).bind(id, cleanName, cleanToken, cleanRemark, now).run();
return { id, name: cleanName, token: cleanToken, remark: cleanRemark, created_at: now, status: "active", is_default: false };
},
async updateToken(env, id, name, tokenStr, remarkStr) {
const cleanToken = (tokenStr || "").trim();
if (!cleanToken) return false;
if (id === "default") {
return await this.setDefaultToken(env, cleanToken);
}
const cleanName = (name || "未命名推送通道").trim();
const cleanRemark = (remarkStr !== undefined ? remarkStr : "").trim();
const db = this.getDb(env);
await this.initDb(db);
await db.prepare(`
UPDATE sms_tokens SET name = ?, token = ?, remark = ? WHERE id = ?
`).bind(cleanName, cleanToken, cleanRemark, id).run();
return true;
},
async toggleTokenStatus(env, id) {
if (id === 'default') return null;
const db = this.getDb(env);
await this.initDb(db);
const row = await db.prepare("SELECT status FROM sms_tokens WHERE id = ?").bind(id).first();
if (!row) return null;
const nextStatus = (row.status === 'active' || !row.status) ? 'disabled' : 'active';
await db.prepare("UPDATE sms_tokens SET status = ? WHERE id = ?").bind(nextStatus, id).run();
return nextStatus;
},
async deleteToken(env, id) {
if (id === 'default') return false;
const db = this.getDb(env);
await this.initDb(db);
await db.prepare("DELETE FROM sms_tokens WHERE id = ?").bind(id).run();
return true;
},
async findToken(env, token) {
if (!token) return null;
const defaultToken = await this.getDefaultToken(env);
const adminKey = env.ADMIN_KEY || DEFAULT_ADMIN_KEY;
if (token === defaultToken || token === adminKey) {
return { id: 'default', name: '🚀 网关默认主通道', apiToken: token };
}
const db = this.getDb(env);
try {
await this.initDb(db);
const row = await db.prepare("SELECT id, name, token FROM sms_tokens WHERE token = ? AND status = 'active' LIMIT 1").bind(token).first();
if (row) {
return { id: row.id, name: row.name, apiToken: row.token };
}
} catch (e) {
console.error("D1 findToken error:", e);
}
return null;
}
};
const DEFAULT_ADMIN_KEY = "YOUR_ADMIN_PASSWORD";
const DEFAULT_SMS_TOKEN = "YOUR_SMS_TOKEN";
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00aeb9" /><stop offset="100%" stop-color="#00838c" /></linearGradient>
</defs>
<rect width="100" height="100" rx="22" fill="url(#g)" />
<path d="M25 35 H75 V65 H45 L32 75 V65 H25 Z" fill="none" stroke="#ffffff" stroke-width="7" stroke-linejoin="round" stroke-linecap="round" />
<circle cx="40" cy="50" r="4" fill="#ffffff" />
<circle cx="50" cy="50" r="4" fill="#ffffff" />
<circle cx="60" cy="50" r="4" fill="#ffffff" />
</svg>`;
function formatBeijingDate(ts) {
const d = new Date(ts + 8 * 3600 * 1000);
const pad = (n) => String(n).padStart(2, '0');
const y = d.getUTCFullYear();
const m = pad(d.getUTCMonth() + 1);
const day = pad(d.getUTCDate());
return `${y}-${m}-${day}`;
}
function formatBeijingTime(tsMs) {
if (!tsMs) return '未知';
const d = new Date(tsMs + 8 * 3600 * 1000);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
function maskIp(ip) {
if (!ip) return '-';
if (ip.includes(':')) {
const parts = ip.split(':');
if (parts.length > 2) return parts.slice(0, 2).join(':') + '::***';
return ip;
}
const parts = ip.split('.');
if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}.***`;
return ip;
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Key, X-Token, X-Gotify-Key'
}
});
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
async function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (!a || !b) return false;
const enc = new TextEncoder();
const aBuf = await crypto.subtle.digest('SHA-256', enc.encode(a.trim()));
const bBuf = await crypto.subtle.digest('SHA-256', enc.encode(b.trim()));
const aArr = new Uint8Array(aBuf);
const bArr = new Uint8Array(bBuf);
let diff = 0;
for (let i = 0; i < aArr.length; i++) {
diff |= aArr[i] ^ bArr[i];
}
return diff === 0;
}
async function verifyAdmin(request, env) {
const adminKey = (env.ADMIN_KEY || DEFAULT_ADMIN_KEY || '').trim();
if (!adminKey) return false;
const authHeader = request.headers.get("Authorization") || "";
if (authHeader.startsWith("Bearer ")) {
const tokenPart = authHeader.slice(7).trim();
if (await timingSafeEqual(tokenPart, adminKey)) return true;
}
const customHeader = request.headers.get("X-Admin-Key") || "";
if (customHeader && await timingSafeEqual(customHeader.trim(), adminKey)) return true;
const cookieHeader = request.headers.get("Cookie") || "";
if (cookieHeader) {
const cookies = Object.fromEntries(cookieHeader.split(';').map(c => {
const [k, ...v] = c.trim().split('=');
return [k, (v || []).join('=')];
}));
const sessionVal = cookies['sms_admin_key'];
if (sessionVal) {
let decoded = sessionVal;
try { decoded = decodeURIComponent(sessionVal); } catch(e) {}
if (await timingSafeEqual(decoded.trim(), adminKey)) {
return true;
}
}
}
return false;
}
async function recordTokenUsage(env, tokenMeta, clientIP, title, message) {
const now = Date.now();
const logItem = {
time: formatBeijingTime(now),
ts: now,
ip: clientIP,
token_id: tokenMeta.id || 'default',
token_name: tokenMeta.name || '默认通道',
title: title || '无标题消息',
preview: message || '',
status: 'success'
};
await Storage.recordLog(env, logItem);
}
async function extractMessage(request, url) {
let title = '';
let message = '';
let formatType = 'unknown';
let isMarkdown = false;
const urlTitle = url.searchParams.get('title') || '';
const urlContent = url.searchParams.get('content') || url.searchParams.get('msg') || url.searchParams.get('text') || url.searchParams.get('message') || url.searchParams.get('desp') || url.searchParams.get('body') || '';
const urlMarkdown = url.searchParams.get('markdown') === 'true' || url.searchParams.get('is_markdown') === '1';
if (request.method === 'POST' || request.method === 'PUT') {
const contentType = (request.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
try {
const body = await request.json();
title = body.title || body.head || body.subject || urlTitle;
message = body.content || body.message || body.text || body.msg || body.body || body.desp || body.description || body.markdown || urlContent;
if (body.markdown !== undefined || body.is_markdown === true || body.is_markdown === 1 || urlMarkdown) {
isMarkdown = true;
}
if (body.extras && typeof body.extras === 'object') {
if (body.extras['client::display']?.contentType === 'text/markdown') isMarkdown = true;
}
formatType = 'JSON Payload';
} catch (e) {
title = urlTitle;
message = urlContent;
formatType = 'JSON Parse Failed (Fallback Query)';
}
} else if (contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data')) {
try {
const formData = await request.formData();
title = formData.get('title') || urlTitle;
message = formData.get('content') || formData.get('message') || formData.get('text') || formData.get('msg') || formData.get('desp') || formData.get('body') || urlContent;
isMarkdown = formData.get('markdown') === 'true' || formData.get('is_markdown') === '1' || urlMarkdown;
formatType = 'Form Data';
} catch (e) {
title = urlTitle;
message = urlContent;
formatType = 'Form Parse Failed (Fallback Query)';
}
} else {
try {
const rawText = await request.text();
title = urlTitle;
message = rawText || urlContent;
isMarkdown = contentType.includes('text/markdown') || urlMarkdown;
formatType = 'Raw Text';
} catch (e) {
title = urlTitle;
message = urlContent;
formatType = 'Raw Text Failed (Fallback Query)';
}
}
} else {
// GET request
title = urlTitle;
message = urlContent;
isMarkdown = urlMarkdown;
formatType = 'GET Query Parameters';
}
return { title: title || '', message: message || '', formatType, isMarkdown };
}
async function getWeComAccessToken(env) {
const corpid = env.WECOM_CORPID;
const secret = env.WECOM_SECRET;
if (!corpid || !secret) return null;
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(corpid)}&corpsecret=${encodeURIComponent(secret)}`;
const resp = await fetch(url);
const data = await resp.json();
if (data.errcode === 0 && data.access_token) {
return data.access_token;
}
return null;
}
async function sendWeCom(env, receiver, title, message, isMarkdown) {
const token = await getWeComAccessToken(env);
if (!token) return { success: false, error: '未配置企业微信凭据' };
const agentid = env.WECOM_AGENTID ? parseInt(env.WECOM_AGENTID) : 1000002;
const fullText = title ? `【${title}】\n${message}` : message;
const enc = new TextEncoder();
const bytes = enc.encode(fullText);
let payload;
if (isMarkdown) {
let mdContent = fullText;
if (bytes.length > 4000) {
mdContent = fullText.slice(0, 1200) + '\n\n*(消息超长,完整内容请在 sms.example.com 后台日志中查看)*';
}
payload = {
touser: receiver || '@all',
msgtype: 'markdown',
agentid: agentid,
markdown: { content: mdContent }
};
} else {
let textContent = fullText;
if (bytes.length > 2000) {
textContent = fullText.slice(0, 600) + '\n\n...(消息超长,完整内容请在 sms.example.com 后台日志中查看)';
}
payload = {
touser: receiver || '@all',
msgtype: 'text',
agentid: agentid,
text: { content: textContent }
};
}
const resp = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const res = await resp.json();
return { success: res.errcode === 0, error: res.errmsg, raw: res };
}
async function handleMessage(request, url, env, ctx) {
const clientIP = request.headers.get('cf-connecting-ip') || request.headers.get('x-real-ip') || 'unknown';
const token = url.searchParams.get('token') || request.headers.get('x-token') || request.headers.get('x-gotify-key');
if (!token) {
return jsonResponse({ status: 'fail', error: '未提供 Token' }, 403);
}
const matched = await Storage.findToken(env, token);
if (!matched) {
return jsonResponse({ status: 'fail', error: '无效的推送 Token' }, 403);
}
const { title, message, formatType, isMarkdown } = await extractMessage(request, url);
if (!message || message.trim() === '') {
return jsonResponse({ status: 'success', msg: '无有效消息内容,已忽略' });
}
const finalTitle = title || '通知消息';
const wecomRes = await sendWeCom(env, '@all', finalTitle, message, isMarkdown);
if (ctx && ctx.waitUntil) {
ctx.waitUntil(recordTokenUsage(env, matched, clientIP, finalTitle, message));
} else {
await recordTokenUsage(env, matched, clientIP, finalTitle, message);
}
return jsonResponse({
status: 'success',
msg: '消息推送成功',
title: finalTitle,
channel: matched.name,
target: '企业微信'
});
}
// 30-Day Activity Heatmap Renderer for SMS Gateway
// ==============================================================================
// PPanel Design System: 30-Day Activity Heatmap (3 Rows x 10 Days)
// ==============================================================================
function renderSmsHeatmap(dailyCounts) {
const nowTs = Math.floor(Date.now() / 1000);
let total30d = 0;
const rowsHtml = [];
for (let rowIdx = 0; rowIdx < 3; rowIdx++) {
const startOffset = 29 - rowIdx * 10;
const endOffset = 20 - rowIdx * 10;
const startTs = nowTs - (startOffset * 86400);
const endTs = nowTs - (endOffset * 86400);
const startDateStr = formatBeijingDate(startTs * 1000);
const endDateStr = formatBeijingDate(endTs * 1000);
const isLatestRow = (rowIdx === 2);
const rowTag = isLatestRow ? '近 10 天' : `${startOffset + 1}~${endOffset + 1} 天前`;
const rowRange = isLatestRow
? `${startDateStr.slice(5)} ~ ${endDateStr.slice(5)} (今天 · 实时)`
: `${startDateStr.slice(5)} ~ ${endDateStr.slice(5)}`;
const cells = [];
for (let d = startOffset; d >= endOffset; d--) {
const blockTs = nowTs - (d * 86400);
const dateStr = formatBeijingDate(blockTs * 1000);
const dayNum = dateStr.slice(8);
const isToday = (d === 0);
const labelDate = isToday ? `${dateStr} (今天 · 实时)` : `${dateStr} (${d}天前)`;
const count = dailyCounts[dateStr] || 0;
total30d += count;
let cls = 'hm-gray';
let statusText = '暂无推送 (0 条)';
if (count > 50) {
cls = 'hm-red';
statusText = `超高频推送 (${count} 条)`;
} else if (count > 15) {
cls = 'hm-orange';
statusText = `高频推送 (${count} 条)`;
} else if (count > 5) {
cls = 'hm-darkgreen';
statusText = `活跃推送 (${count} 条)`;
} else if (count > 0) {
cls = 'hm-lightgreen';
statusText = `正常推送 (${count} 条)`;
}
cells.push(`
<div class="hm-cell ${cls}"
data-date="${labelDate}"
data-status="${statusText}"
data-count="${count} 条"
onmouseenter="showHeatmapTooltip(this, event)"
onmouseleave="hideHeatmapTooltip()"
onclick="showHeatmapTooltip(this, event)"
title="${labelDate} 推送: ${count} 条">
<span class="hm-day-num">${dayNum}</span>
</div>
`);
}
rowsHtml.push(`
<div class="heatmap-row-wrap ${isLatestRow ? 'row-today' : ''}">
<div class="heatmap-row-header">
<span class="hm-row-tag ${isLatestRow ? 'today' : ''}">${isLatestRow ? '⚡ ' : '📅 '}${rowTag}</span>
<span class="hm-row-range ${isLatestRow ? 'today' : ''}">${rowRange}</span>
</div>
<div class="heatmap-row-cells">
${cells.join('')}
</div>
</div>
`);
}
return `
<div class="card ppanel-card">
<div class="card-header-row">
<div class="header-left">
<div class="title-pill-group">
<span class="badge badge-primary">📊 30 天日历</span>
<span class="badge badge-secondary">网关活跃度</span>
<h3 class="card-title">推送统计热力图</h3>
</div>
<div class="meta-subtext-row">
<span>⚡ 30天总量: <strong style="color:var(--primary);">${total30d} 条</strong></span>
<span>📅 跨度: <strong>30 个自然日 (三旬分行)</strong></span>
</div>
</div>
<div class="header-right">
<span class="status-chip chip-online"><span class="status-dot"></span>服务就绪</span>
</div>
</div>
<div class="heatmap-3rows-container">
${rowsHtml.join('')}
</div>
<div class="heatmap-legend-row">
<span class="legend-label">无推送</span>
<div class="legend-cells">
<span class="hm-cell hm-gray" title="无推送记录 (0 条)"></span>
<span class="hm-cell hm-lightgreen" title="正常推送 (1~5 条)"></span>
<span class="hm-cell hm-darkgreen" title="活跃推送 (6~15 条)"></span>
<span class="hm-cell hm-orange" title="高频推送 (16~50 条)"></span>
<span class="hm-cell hm-red" title="超高频推送 (>50 条)"></span>
</div>
<span class="legend-label">高频</span>
</div>
</div>
`;
}
// ==============================================================================
// PPanel Web Dashboard Main Handler (handleIndex)
// ==============================================================================
async function handleIndex(request, env) {
const isAdmin = await verifyAdmin(request, env);
const host = request.headers.get('host') || 'sms.example.com';
const tokens = isAdmin ? await Storage.getAllTokens(env) : [];
const settings = await Storage.getSettings(env);
const displayToken = isAdmin ? (tokens.length > 0 ? tokens[0].token : (env.DEFAULT_TOKEN || DEFAULT_SMS_TOKEN)) : 'YOUR_TOKEN';
const data = await Storage.getLogsAndQuota(env);
const quota = data.quota || {};
const logs = data.logs || [];
const dailyCounts = data.daily_counts || {};
const html = `<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<script>(function(){try{var t=localStorage.getItem("sms_theme")||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");document.documentElement.setAttribute("data-theme",t);var c=localStorage.getItem("example_theme_color")||"aurora";document.documentElement.setAttribute("data-theme-color",c);}catch(e){}})();</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<title>Webhook 消息推送网关 · OnePve 控制台</title>
<style>
/* 1. 极光曜石青 (Aurora · 默认) */
:root, :root[data-theme-color="aurora"] {
--bg: #f3f2f3; --card: #ffffff; --card-sub: #f8fafc; --border: #d7d7d6; --border-sub: #e2e8f0;
--muted: #f3f2f3; --muted-fg: #64748b; --text: #0f172a; --text-muted: #64748b;
--primary: #00aeb9; --primary-hover: #00949e; --primary-fg: #ffffff; --primary-light: #cbedee; --primary-light-fg: #004d53;
--secondary: #cbedee; --secondary-fg: #004d53; --success: #00aeb9; --success-bg: #cbedee; --success-fg: #004d53;
--warning: #f59e0b; --warning-bg: #fef3c7; --warning-fg: #92400e; --danger: #ef4444; --danger-bg: #ffe7e5; --danger-fg: #991b1b;
--code-bg: #eae9e9; --code-fg: #0f172a;
}
.dark, [data-theme="dark"], .dark[data-theme-color="aurora"], [data-theme="dark"][data-theme-color="aurora"] {
--bg: #0b1120; --card: #0f172a; --card-sub: #141f36; --border: #1e293b; --border-sub: #273549;
--muted: #141c2e; --muted-fg: #94a3b8; --text: #f8fafc; --text-muted: #94a3b8;
--primary: #00aeb9; --primary-hover: #22d3ee; --primary-fg: #090d16; --primary-light: rgba(0, 174, 185, 0.18); --primary-light-fg: #22d3ee;
--secondary: #163640; --secondary-fg: #cbedee; --success: #00aeb9; --success-bg: rgba(0, 174, 185, 0.18); --success-fg: #22d3ee;
--warning: #fbbf24; --warning-bg: rgba(251, 191, 36, 0.15); --warning-fg: #fde68a; --danger: #f87171; --danger-bg: rgba(248, 113, 113, 0.15); --danger-fg: #fecaca;
--code-bg: #070c18; --code-fg: #f8fafc;
}
/* 2. 赛博钛金蓝 (Titanium Blue) */
:root[data-theme-color="titanium"] {
--bg: #f1f5f9; --card: #ffffff; --card-sub: #f8fafc; --border: #cbd5e1; --border-sub: #e2e8f0;
--muted: #f1f5f9; --muted-fg: #64748b; --text: #0f172a; --text-muted: #64748b;
--primary: #0284c7; --primary-hover: #0369a1; --primary-fg: #ffffff; --primary-light: #e0f2fe; --primary-light-fg: #0369a1;
--secondary: #e0f2fe; --secondary-fg: #0369a1; --success: #0284c7; --success-bg: #e0f2fe; --success-fg: #0369a1;
--code-bg: #e2e8f0; --code-fg: #0f172a;
}
.dark[data-theme-color="titanium"], [data-theme="dark"][data-theme-color="titanium"] {
--bg: #0d1527; --card: #111827; --card-sub: #172033; --border: #1e293b; --border-sub: #283548;
--muted: #172033; --muted-fg: #94a3b8; --text: #f1f5f9; --text-muted: #94a3b8;
--primary: #38bdf8; --primary-hover: #7dd3fc; --primary-fg: #0b0f19; --primary-light: rgba(56, 189, 248, 0.18); --primary-light-fg: #38bdf8;
--secondary: #1e293b; --secondary-fg: #bae6fd; --success: #38bdf8; --success-bg: rgba(56, 189, 248, 0.18); --success-fg: #38bdf8;
--code-bg: #090f1e; --code-fg: #f1f5f9;
}
/* 3. 暮霭紫晶电光 (Violet) */
:root[data-theme-color="violet"] {
--bg: #f7f5fa; --card: #ffffff; --card-sub: #faf8fd; --border: #ddd8e8; --border-sub: #eae5f5;
--muted: #f7f5fa; --muted-fg: #6b7280; --text: #1e1b4b; --text-muted: #6b7280;
--primary: #7c3aed; --primary-hover: #6d28d9; --primary-fg: #ffffff; --primary-light: #ede9fe; --primary-light-fg: #5b21b6;
--secondary: #ede9fe; --secondary-fg: #5b21b6; --success: #7c3aed; --success-bg: #ede9fe; --success-fg: #5b21b6;
--code-bg: #ede8f5; --code-fg: #1e1b4b;
}
.dark[data-theme-color="violet"], [data-theme="dark"][data-theme-color="violet"] {
--bg: #100d1e; --card: #141028; --card-sub: #1b1636; --border: #231c42; --border-sub: #2e2654;
--muted: #1c1736; --muted-fg: #9ca3af; --text: #f5f3ff; --text-muted: #9ca3af;
--primary: #a78bfa; --primary-hover: #c4b5fd; --primary-fg: #0d0b18; --primary-light: rgba(167, 139, 250, 0.18); --primary-light-fg: #a78bfa;
--secondary: #261f48; --secondary-fg: #ddd6fe; --success: #a78bfa; --success-bg: rgba(167, 139, 250, 0.18); --success-fg: #a78bfa;
--code-bg: #0a0814; --code-fg: #f5f3ff;
}
/* 4. 翡翠森林极客绿 (Emerald) */
:root[data-theme-color="emerald"] {
--bg: #eff4f1; --card: #ffffff; --card-sub: #f6faf7; --border: #d3dfd7; --border-sub: #e2ece5;
--muted: #eff4f1; --muted-fg: #4b5563; --text: #064e3b; --text-muted: #4b5563;
--primary: #059669; --primary-hover: #047857; --primary-fg: #ffffff; --primary-light: #d1fae5; --primary-light-fg: #065f46;
--secondary: #d1fae5; --secondary-fg: #065f46; --success: #059669; --success-bg: #d1fae5; --success-fg: #065f46;
--code-bg: #e1eae4; --code-fg: #064e3b;
}
.dark[data-theme-color="emerald"], [data-theme="dark"][data-theme-color="emerald"] {
--bg: #071211; --card: #0d201e; --card-sub: #122c29; --border: #1a3d39; --border-sub: #234e49;
--muted: #132d2a; --muted-fg: #9ca3af; --text: #ecfdf5; --text-muted: #9ca3af;
--primary: #34d399; --primary-hover: #6ee7b7; --primary-fg: #071211; --primary-light: rgba(52, 211, 153, 0.18); --primary-light-fg: #34d399;
--secondary: #173834; --secondary-fg: #a7f3d0; --success: #34d399; --success-bg: rgba(52, 211, 153, 0.18); --success-fg: #34d399;
--code-bg: #050d0c; --code-fg: #ecfdf5;
}
/* 5. 黑金暗曜琥珀 (Amber) */
:root[data-theme-color="amber"] {
--bg: #fdfaf3; --card: #ffffff; --card-sub: #fefdf9; --border: #edd5a6; --border-sub: #f7e6c4;
--muted: #fdfaf3; --muted-fg: #78350f; --text: #451a03; --text-muted: #78350f;
--primary: #d97706; --primary-hover: #b45309; --primary-fg: #ffffff; --primary-light: #fef3c7; --primary-light-fg: #78350f;
--secondary: #fef3c7; --secondary-fg: #78350f; --success: #d97706; --success-bg: #fef3c7; --success-fg: #78350f;
--code-bg: #f5eedb; --code-fg: #451a03;
}
.dark[data-theme-color="amber"], [data-theme="dark"][data-theme-color="amber"] {
--bg: #140d04; --card: #1f1406; --card-sub: #2b1d0a; --border: #3d2910; --border-sub: #4e3516;
--muted: #241707; --muted-fg: #d97706; --text: #fef3c7; --text-muted: #d97706;
--primary: #fbbf24; --primary-hover: #fcd34d; --primary-fg: #140d04; --primary-light: rgba(251, 191, 36, 0.18); --primary-light-fg: #fbbf24;
--secondary: #30200b; --secondary-fg: #fde68a; --success: #fbbf24; --success-bg: rgba(251, 191, 36, 0.18); --success-fg: #fbbf24;
--code-bg: #0c0802; --code-fg: #fef3c7;
}
/* 6. 落樱嫩粉 (Crimson) */
:root[data-theme-color="crimson"] {
--bg: #fff1f2; --card: #ffffff; --card-sub: #ffe4e6; --border: #fecdd3; --border-sub: #ffe4e6;
--muted: #fff1f2; --muted-fg: #9f1239; --text: #0f172a; --text-muted: #881337;
--primary: #e11d48; --primary-hover: #be123c; --primary-fg: #ffffff; --primary-light: #ffe4e6; --primary-light-fg: #be123c;
--secondary: #ffe4e6; --secondary-fg: #9f1239; --success: #059669; --success-bg: #d1fae5; --success-fg: #047857;
--code-bg: #ffe4e6; --code-fg: #9f1239;
}
.dark[data-theme-color="crimson"], [data-theme="dark"][data-theme-color="crimson"] {
--bg: #0f0507; --card: #190a0e; --card-sub: #260f15; --border: #4c111d; --border-sub: #380d15;
--muted: #260f15; --muted-fg: #f43f5e; --text: #fff1f2; --text-muted: #fda4af;
--primary: #fb7185; --primary-hover: #f43f5e; --primary-fg: #0f0507; --primary-light: rgba(244, 63, 94, 0.18); --primary-light-fg: #fda4af;
--secondary: #260f15; --secondary-fg: #fda4af; --success: #34d399; --success-bg: rgba(52, 211, 153, 0.16); --success-fg: #34d399;
--code-bg: #140508; --code-fg: #fecdd3;
}
/* Palette Popover Picker */
.palette-dropdown {
position: relative;
display: inline-block;
}
.palette-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.45rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 600;
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
transition: all 0.2s;
}
.palette-btn:hover { border-color: var(--primary); }
.color-dot-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 6px var(--primary);
}
.palette-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 8px;
box-shadow: var(--shadow-lg);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
width: 240px;
z-index: 10005;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(12px);
}
.palette-menu.open { opacity: 1; visibility: visible; transform: translateY(0); }
.color-opt-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border-radius: var(--radius-md);
border: 1px solid var(--border-sub);
background: var(--card-sub);
color: var(--text);
font-size: 0.72rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.color-opt-btn:hover { border-color: var(--primary); transform: translateY(-1px); }
.color-opt-btn.active { border-color: var(--primary); background: var(--primary-light); color: var(--primary-light-fg); }
.color-swatch {
width: 16px;
height: 16px;
border-radius: 50%;
}
/* ==========================================================================
PPanel (OnePve) Modern Design System · Aurora Cyan 5-Color Eye-Care
========================================================================== */
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
/* Light mode (Aurora 5-Color) */
--bg: #f3f2f3;
--card: #ffffff;
--card-sub: #f8fafc;
--border: #d7d7d6;
--border-sub: #e2e8f0;
--muted: #f3f2f3;
--muted-fg: #64748b;
--text: #0f172a;
--text-muted: #64748b;
--primary: #00aeb9;
--primary-hover: #00949e;
--primary-fg: #ffffff;
--primary-light: #cbedee;
--primary-light-fg: #004d53;
--secondary: #cbedee;
--secondary-fg: #004d53;
--success: #00aeb9;
--success-bg: #cbedee;
--success-fg: #004d53;
--warning: #f59e0b;
--warning-bg: #fef3c7;
--warning-fg: #92400e;
--danger: #ef4444;
--danger-bg: #ffe7e5;
--danger-fg: #991b1b;
--code-bg: #eae9e9;
--code-fg: #0f172a;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 24px;
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.08), 0 1px 2px -1px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] {
--bg: #0b1120;
--card: #0f172a;
--card-sub: #141f36;
--border: #1e293b;
--border-sub: #273549;
--muted: #141c2e;
--muted-fg: #94a3b8;
--text: #f8fafc;
--text-muted: #94a3b8;
--primary: #00aeb9;
--primary-hover: #22d3ee;
--primary-fg: #090d16;
--primary-light: rgba(0, 174, 185, 0.15);
--primary-light-fg: #22d3ee;
--secondary: #163640;
--secondary-fg: #cbedee;
--success: #00aeb9;
--success-bg: rgba(0, 174, 185, 0.15);
--success-fg: #22d3ee;
--warning: #fbbf24;
--warning-bg: rgba(251, 191, 36, 0.12);
--warning-fg: #fde68a;
--danger: #f87171;
--danger-bg: rgba(248, 113, 113, 0.12);
--danger-fg: #fecaca;
--code-bg: #070c18;
--code-fg: #f8fafc;
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 1.5rem 1rem;
min-height: 100vh;
display: flex;
justify-content: center;
transition: background 0.25s ease, color 0.25s ease;
-webkit-font-smoothing: antialiased;
}
.app-container {
max-width: 1360px;
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* PPanel Glassmorphism Navbar */
.site-navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(12px);
}
.nav-brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-logo-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #00aeb9 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 1.25rem;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 174, 185, 0.3);
}
.brand-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.brand-badge-pill {
font-size: 0.68rem;
font-weight: 800;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.6px;
line-height: 1;
margin-bottom: 2px;
}
.brand-title {
font-size: 1.15rem;
font-weight: 800;
color: var(--text);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desktop-nav {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mobile-nav-trigger {
display: none;
background: var(--muted);
border: 1px solid var(--border);
color: var(--text);
padding: 0.45rem 0.75rem;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
align-items: center;
justify-content: center;
}
/* PPanel Metrics Cards Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.metric-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: var(--shadow-xs);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.metric-card:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.metric-label {
font-size: 0.76rem;
font-weight: 600;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
}
.metric-val {
font-size: 1.35rem;
font-weight: 800;
color: var(--text);
font-family: monospace;
line-height: 1.2;
}
.metric-val.highlight {
color: var(--primary);
}
.metric-sub {
font-size: 0.72rem;
color: var(--text-muted);
}
/* PPanel Shadcn-like Tabs & Segmented Bar */
.tabs-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.tabs-list {
display: inline-flex;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 4px;
gap: 4px;
box-shadow: var(--shadow-xs);
overflow-x: auto;
max-width: 100%;
}
.tab-item {
padding: 6px 14px;
border-radius: var(--radius-md);
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tab-item:hover {
color: var(--text);
}
.tab-item.active {
background: var(--primary);
color: #ffffff;
box-shadow: 0 2px 6px rgba(0, 174, 185, 0.3);
}
/* PPanel Cards */
.ppanel-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
padding: 1.25rem 1.5rem;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 1rem;
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
flex-wrap: wrap;
}
.title-pill-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.card-title {
font-size: 1.05rem;
font-weight: 800;
color: var(--text);
}
.meta-subtext-row {
font-size: 0.76rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
/* PPanel Badges & Chips */
.badge {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1.2;
}
.badge-primary {
background: var(--primary-light);
color: var(--primary-light-fg);
border: 1px solid var(--border-sub);
}
.badge-secondary {
background: var(--muted);
color: var(--text-muted);
border: 1px solid var(--border);
}
.status-chip {
font-size: 0.75rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip-online {
background: var(--success-bg);
color: var(--success-fg);
border: 1px solid rgba(0, 174, 185, 0.3);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 6px var(--primary);
}
/* PPanel Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid transparent;
user-select: none;
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--primary);
color: #ffffff;
box-shadow: 0 2px 6px rgba(0, 174, 185, 0.25);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-outline {
background: var(--card);
border-color: var(--border);
color: var(--text);
}
.btn-outline:hover {
background: var(--muted);
border-color: var(--primary);
}
.btn-ghost {
background: transparent;
color: var(--text);
}
.btn-ghost:hover { background: var(--muted); }
.btn-danger {
background: var(--danger-bg);
border-color: rgba(239, 68, 68, 0.3);
color: var(--danger);
}
.btn-danger:hover { background: var(--danger); color: #ffffff; }
.btn-sm { padding: 3px 8px; font-size: 0.74rem; border-radius: var(--radius-sm); }
/* Forms */
.input-box {
width: 100%;
padding: 0.55rem 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text);
font-size: 0.85rem;
outline: none;
transition: all 0.2s;
}
.input-box:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
background: var(--card);
}
/* 3-Row Heatmap Decade Matrix */
.heatmap-3rows-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.heatmap-row-wrap {
background: var(--card-sub);
border: 1px solid var(--border-sub);
border-radius: var(--radius-lg);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s;
}
.heatmap-row-wrap:hover {
border-color: var(--primary);
background: var(--card);
}
.heatmap-row-wrap.row-today {
border-color: rgba(0, 174, 185, 0.45);
background: var(--primary-light);
}
.heatmap-row-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.78rem;
}
.hm-row-tag {
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 4px;
}
.hm-row-tag.today { color: var(--primary-light-fg); font-weight: 800; }
.hm-row-range {
font-family: monospace;
font-size: 0.74rem;
color: var(--text-muted);
}
.hm-row-range.today { color: var(--primary-light-fg); font-weight: 700; }
.heatmap-row-cells {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 6px;
width: 100%;
}
.hm-cell {
height: 32px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
position: relative;
border: 1px solid rgba(0,0,0,0.06);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
user-select: none;
}
.hm-cell:hover, .hm-cell:active {
transform: scale(1.1);
outline: 2px solid var(--primary);
outline-offset: 1px;
z-index: 10;
filter: brightness(1.15);
}
.hm-day-num {
font-size: 0.72rem;
font-weight: 700;
font-family: monospace;
line-height: 1;
opacity: 0.9;
}
.hm-gray { background: #cbd5e1; }
.hm-gray .hm-day-num { color: #475569; }
[data-theme="dark"] .hm-gray { background: #334155; }
[data-theme="dark"] .hm-gray .hm-day-num { color: #94a3b8; }
.hm-lightgreen { background: #84cc16; }
.hm-lightgreen .hm-day-num { color: #1e3a0f; }
.hm-darkgreen { background: #00aeb9; }
.hm-darkgreen .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
.hm-yellow { background: #facc15; }
.hm-yellow .hm-day-num { color: #713f12; }
.hm-orange { background: #f97316; }
.hm-orange .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
.hm-red { background: #ef4444; }
.hm-red .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
.heatmap-legend-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.74rem;
color: var(--text-muted);
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.legend-cells { display: inline-flex; align-items: center; gap: 4px; }
.legend-cells .hm-cell { width: 12px; height: 14px; min-width: 12px; max-width: 12px; border-radius: 2px; cursor: default; }
.legend-cells .hm-cell:hover, .legend-cells .hm-cell:active { outline: none; filter: none; transform: none; }
.legend-label { font-weight: 600; color: var(--text-muted); }
/* ProList Token Cards Grid */
.tokens-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.token-card {
background: var(--card-sub);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 1.15rem;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: var(--shadow-xs);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.token-card:hover {
border-color: var(--primary);
background: var(--card);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.token-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.token-card-title {
font-weight: 700;
font-size: 0.95rem;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.token-card-val-box {
background: var(--code-bg);
border: 1px solid var(--border-sub);
border-radius: var(--radius-md);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.token-card-val {
font-family: monospace;
font-size: 0.84rem;
color: var(--code-fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.token-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 0.78rem;
}
/* ProTable */
.table-container {
width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
text-align: left;
}
th {
background: var(--muted);
color: var(--text-muted);
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-sub);
font-size: 0.85rem;
white-space: nowrap;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--card-sub); }
/* Code Snippet Box with Full Multi-line & Hover Tooltip */
.cmd-box {
background: var(--code-bg);
border: 1px solid var(--border-sub);
border-radius: var(--radius-md);
padding: 0.65rem 0.85rem;
margin-top: 4px;
min-width: 0;
transition: all 0.2s;
position: relative;
}
.cmd-box:hover {
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(0, 174, 185, 0.2);
}
.cmd-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.8rem;
color: var(--code-fg);
white-space: pre-wrap;
word-break: break-all;
line-height: 1.55;
user-select: all;
cursor: text;
}
/* Sonner Toast (Top-Center) */
.toast {
position: fixed;
top: 24px;
left: 50%;
transform: translate(-50%, -20px);
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
font-weight: 600;
font-size: 0.88rem;
padding: 0.65rem 1.4rem;
border-radius: 9999px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(12px);
z-index: 10001;
opacity: 0;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* Floating Heatmap Tooltip */
.heatmap-floating-tooltip {
position: fixed;
z-index: 10002;
background: rgba(15, 23, 42, 0.95);
color: #f8fafc;
padding: 8px 12px;
border-radius: var(--radius-md);
font-size: 0.78rem;
line-height: 1.45;
box-shadow: var(--shadow-lg);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.heatmap-floating-tooltip.show { opacity: 1; visibility: visible; }
/* Modals & Dialogs */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
padding: 1rem;
}
.modal-overlay.open { opacity: 1; visibility: visible; }
.modal-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
padding: 1.5rem;
max-width: 480px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-overlay.open .modal-card { transform: scale(1); }
.modal-title { font-size: 1.15rem; font-weight: 800; color: var(--text); margin-bottom: 0.5rem; }
.modal-desc { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.2rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Drawer */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9990;
opacity: 0;
visibility: hidden;
transition: all 0.25s ease;
}
.drawer-overlay.open { opacity: 1; visibility: visible; }
.drawer-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
background: var(--card);
border-left: 1px solid var(--border);
padding: 1.5rem;
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--shadow-lg);
}
.drawer-overlay.open .drawer-panel { transform: translateX(0); }
/* Mobile Responsive Optimizations */
@media (max-width: 768px) {
body { padding: 0.85rem 0.65rem; }
.site-navbar { padding: 0.75rem 1rem; }
.desktop-nav { display: none; }
.mobile-nav-trigger { display: inline-flex; }
.ppanel-card { padding: 1rem 1rem; border-radius: var(--radius-xl); }
.metrics-grid { grid-template-columns: 1fr 1fr; }
.tabs-bar { flex-direction: column; align-items: stretch; }
.tabs-list { width: 100%; }
.tab-item { flex: 1; justify-content: center; padding: 6px 8px; font-size: 0.75rem; }
.heatmap-row-header { flex-wrap: wrap; gap: 4px; }
.heatmap-row-cells { gap: 4px; }
.hm-cell { height: 28px; }
.tokens-card-grid { grid-template-columns: 1fr; }
.cmd-text { white-space: normal; word-break: break-all; }
}
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<div class="app-container">
<!-- Navbar -->
<header class="site-navbar">
<div class="nav-brand">
<div class="brand-logo-icon">
<span>📡</span>
</div>
<div class="brand-info">
<div class="brand-badge-pill">ONEPVE CLOUD PORTAL</div>
<h1 class="brand-title">消息推送网关</h1>
</div>
</div>
<div class="desktop-nav">
${isAdmin ? `
<span class="badge badge-primary">👑 管理员已登录</span>
<button class="btn btn-outline btn-sm" style="color:var(--danger);" onclick="logoutAdmin()">退出</button>
` : `
<button class="btn btn-primary btn-sm" onclick="openAdminModal()"><span>🔐</span> 管理员登录</button>
`}
<div class="palette-dropdown" id="paletteDropdown">
<button class="palette-btn" onclick="togglePaletteMenu(event)" title="选择主题配色">
<span class="color-dot-indicator" id="curColorDot"></span>
<span id="curColorName">极光青</span>
</button>
<div class="palette-menu" id="paletteMenu">
<button class="color-opt-btn" data-color="aurora" onclick="setThemeColor('aurora')">
<span class="color-swatch" style="background:#00aeb9;"></span>
<span>极光青</span>
</button>
<button class="color-opt-btn" data-color="titanium" onclick="setThemeColor('titanium')">
<span class="color-swatch" style="background:#0284c7;"></span>
<span>钛金蓝</span>
</button>
<button class="color-opt-btn" data-color="violet" onclick="setThemeColor('violet')">
<span class="color-swatch" style="background:#7c3aed;"></span>
<span>紫晶光</span>
</button>
<button class="color-opt-btn" data-color="emerald" onclick="setThemeColor('emerald')">
<span class="color-swatch" style="background:#059669;"></span>
<span>森林绿</span>
</button>
<button class="color-opt-btn" data-color="amber" onclick="setThemeColor('amber')">
<span class="color-swatch" style="background:#d97706;"></span>
<span>琥珀金</span>
</button>
<button class="color-opt-btn" data-color="crimson" onclick="setThemeColor('crimson')">
<span class="color-swatch" style="background:#e11d48;"></span>
<span>落樱嫩粉</span>
</button>
</div>
</div>
<button class="btn btn-outline btn-sm" id="desktopThemeBtn" onclick="toggleTheme()" title="切换深浅主题">🌙</button>
</div>
<button class="mobile-nav-trigger" onclick="openNavDrawer()" title="打开菜单">
<span>☰</span>
</button>
</header>
<!-- Metrics Strip -->
<div class="metrics-grid">
<div class="metric-card">
<span class="metric-label">📊 今日发信总量</span>
<span class="metric-val highlight" id="smsDailyVal">${(quota.today_count || 0).toLocaleString()} <small style="font-size:0.75rem; color:var(--text-muted);">条</small></span>
<span class="metric-sub">今日额度: ${(quota.daily_limit || 100000).toLocaleString()} 条/天</span>
</div>
<div class="metric-card">
<span class="metric-label">⚡ 30天累计发信</span>
<span class="metric-val">${Object.values(dailyCounts).reduce((a, b) => a + b, 0).toLocaleString()} <small style="font-size:0.75rem; color:var(--text-muted);">条</small></span>
<span class="metric-sub">自然月历史沉淀统计</span>
</div>
<div class="metric-card">
<span class="metric-label">🔑 活跃通道数</span>
<span class="metric-val highlight">${isAdmin ? tokens.length : '11'} <small style="font-size:0.75rem; color:var(--text-muted);">个</small></span>
<span class="metric-sub">Multi-Token 独立密钥分发</span>
</div>
<div class="metric-card">
<span class="metric-label">🛡️ 网关运行状态</span>
<span class="metric-val" style="color:var(--success);">100% <small style="font-size:0.75rem; color:var(--text-muted);">SLA</small></span>
<span class="metric-sub">Cloudflare D1 无服务器架构</span>
</div>
</div>
<!-- Segmented Tabs -->
<div class="tabs-bar">
<div class="tabs-list">
<button class="tab-item" id="viewDocsBtn" onclick="switchSmsView('docs')">📖 快速接入</button>
<button class="tab-item active" id="viewHeatmapBtn" onclick="switchSmsView('heatmap')">🟩 30天日历</button>
${isAdmin ? `<button class="tab-item" id="viewTokensBtn" onclick="switchSmsView('tokens')">🔑 推送通道</button>` : ''}
${isAdmin ? `<button class="tab-item" id="viewLogsBtn" onclick="switchSmsView('logs')">📋 实时流水</button>` : ''}
</div>
</div>
<!-- 1. Docs & Online Testing View -->
<div id="sectionDocs" style="display:none;">
${isAdmin ? `
<div class="ppanel-card" style="margin-bottom: 1rem;">
<div class="card-header-row">
<div class="title-pill-group">
<span class="badge badge-primary">🚀 在线推送测试</span>
<h3 class="card-title">即时触发 Webhook 消息</h3>
</div>
<div style="display:inline-flex; align-items:center; gap:6px; font-size:0.8rem;">
<span style="color:var(--text-muted);">选用 Token:</span>
<select id="testTokenSelect" class="input-box" style="width:auto; padding:3px 8px; font-weight:700;">
${tokens.map(t => `<option value="${t.token}">${t.name} (${t.token.substring(0, 8)}...)</option>`).join('')}
</select>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:10px;">
<input type="text" id="testMsgTitle" class="input-box" placeholder="消息标题 (如: 探针告警 / 部署成功)" value="系统测试通知" />
<input type="text" id="testMsgContent" class="input-box" placeholder="消息正文内容..." value="这是一条来自 sms.example.com 网关的在线测试推送!" />
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn btn-primary" onclick="submitOnlineTest()">📤 立即推送测试</button>
</div>
</div>
` : ''}
<div class="ppanel-card">
<div class="card-header-row">
<div class="title-pill-group">
<span class="badge badge-secondary">📡 接口规范</span>
<h3 class="card-title">通用 Webhook 调用规范 (6 大场景范例)</h3>
</div>
${isAdmin ? `
<div style="display:inline-flex; align-items:center; gap:6px; font-size:0.8rem;">
<span style="color:var(--text-muted);">文档示例 Token:</span>
<select id="docTokenSelect" onchange="onDocTokenChange(this.value)" class="input-box" style="width:auto; padding:3px 8px; font-weight:700;">
${tokens.map(t => `<option value="${t.token}">${t.name} (${t.token.substring(0, 10)}...)</option>`).join('')}
</select>
</div>
` : `
<span style="font-size:0.75rem; color:var(--text-muted); background:var(--muted); padding:3px 8px; border-radius:6px;">未登录只读模式 · 使用时请替换 <code>YOUR_TOKEN</code> 为你的专属密钥</span>
`}
</div>
<p style="font-size:0.84rem; color:var(--text-muted);">网关原生兼容标准 JSON、Form 表单、URL Query 以及 Gotify / Bark 格式:</p>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:12px;">
<div class="metric-card" style="padding:1rem; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:700; font-size:0.85rem; color:var(--text); display:flex; align-items:center; gap:6px;">
<span class="badge badge-primary" style="font-size:0.7rem; padding:1px 6px;">GET</span>
<span>极速直发 (浏览器/简单脚本)</span>
</div>
<button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('cmdGet', this)" title="一键复制完整命令">📋 复制</button>
</div>
<div class="cmd-box">
<div class="cmd-text" id="cmdGet" title="https://${host}/message?token=${displayToken}&title=测试标题&text=测试内容">https://${host}/message?token=${displayToken}&title=测试标题&text=测试内容</div>
</div>
</div>
<div class="metric-card" style="padding:1rem; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:700; font-size:0.85rem; color:var(--text); display:flex; align-items:center; gap:6px;">
<span class="badge badge-primary" style="font-size:0.7rem; padding:1px 6px;">POST</span>
<span>标准 JSON (API / CI/CD)</span>
</div>
<button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('cmdJson', this)" title="一键复制完整命令">📋 复制</button>
</div>
<div class="cmd-box">
<div class="cmd-text" id="cmdJson" title="curl -X POST "https://${host}/message?token=${displayToken}" -H "Content-Type: application/json" -d '{"title":"测试标题","text":"测试内容"}'">curl -X POST "https://${host}/message?token=${displayToken}" -H "Content-Type: application/json" -d '{"title":"测试标题","text":"测试内容"}'</div>
</div>
</div>
<div class="metric-card" style="padding:1rem; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:700; font-size:0.85rem; color:var(--text); display:flex; align-items:center; gap:6px;">
<span class="badge badge-secondary" style="font-size:0.7rem; padding:1px 6px;">Gotify</span>
<span>协议兼容 (智能家居/监控)</span>
</div>
<button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('cmdGotify', this)" title="一键复制完整命令">📋 复制</button>
</div>
<div class="cmd-box">
<div class="cmd-text" id="cmdGotify" title="curl -X POST "https://${host}/message" -H "X-Gotify-Key: ${displayToken}" -d "title=告警&message=服务器离线"">curl -X POST "https://${host}/message" -H "X-Gotify-Key: ${displayToken}" -d "title=告警&message=服务器离线"</div>
</div>
</div>
<div class="metric-card" style="padding:1rem; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:700; font-size:0.85rem; color:var(--text); display:flex; align-items:center; gap:6px;">
<span class="badge badge-secondary" style="font-size:0.7rem; padding:1px 6px;">Bark</span>
<span>协议兼容 (iOS 推送工具)</span>
</div>
<button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('cmdBark', this)" title="一键复制完整命令">📋 复制</button>
</div>
<div class="cmd-box">
<div class="cmd-text" id="cmdBark" title="curl "https://${host}/${displayToken}/这是标题/这是内容"">curl "https://${host}/${displayToken}/这是标题/这是内容"</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. 30-Day Heatmap View -->
<div id="sectionHeatmap">
${renderSmsHeatmap(dailyCounts)}
</div>
<!-- 3. Tokens View (Admin Only) -->
${isAdmin ? `
<div id="sectionTokens" style="display:none;">
<div class="ppanel-card">
<div class="card-header-row">
<div class="title-pill-group">
<span class="badge badge-primary">🔑 密钥中枢</span>
<span class="badge badge-secondary">共 ${tokens.length} 个通道</span>
<h3 class="card-title">推送通道密钥管理 (Multi-Token 备注与合并)</h3>
</div>
<div class="tabs-list" style="padding:2px;">
<button class="tab-item active" id="tokViewCardsBtn" onclick="switchTokenView('cards')" style="padding:4px 10px; font-size:0.75rem;">🗂️ 卡片流</button>
<button class="tab-item" id="tokViewTableBtn" onclick="switchTokenView('table')" style="padding:4px 10px; font-size:0.75rem;">📊 紧凑表格</button>
</div>
</div>
<!-- Add Token Form -->
<div class="metric-card" style="padding:1rem;">
<div style="font-size:0.84rem; font-weight:700; color:var(--text); margin-bottom:8px;">+ 创建 / 登记新的推送通道 Token</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:8px;">
<div>
<label style="font-size:0.75rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">通道名称</label>
<input type="text" id="newTokenName" class="input-box" placeholder="如: 📱 小米11打卡 / 🚨 生产告警" />
</div>
<div>
<label style="font-size:0.75rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">用途备注 (注明供哪些脚本/项目调用)</label>
<input type="text" id="newTokenRemark" class="input-box" placeholder="如: 呆呆面板 7大签到 / GitHub Actions CI" />
</div>
<div>
<label style="font-size:0.75rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">Token 密钥 (留空自动生成)</label>
<input type="text" id="newTokenVal" class="input-box" placeholder="留空自动生成 sms_xxx" style="font-family:monospace;" />
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:4px;">
<button class="btn btn-outline btn-sm" onclick="generateRandomToken()">🎲 随机生成</button>
<button class="btn btn-primary btn-sm" onclick="submitCreateToken()">+ 添加密钥通道</button>
</div>
</div>
</div>
<!-- Cards View -->
<div class="tokens-card-grid" id="tokCardsView">
${tokens.map(t => `
<div class="token-card" id="card-tok-${t.id}">
<div class="token-card-head">
<div style="min-width:0; flex:1;">
<div class="token-card-title">
<span id="tok-card-name-${t.id}">${t.name}</span>
${t.is_default ? '<span class="badge badge-primary">主通道</span>' : ''}
</div>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:2px;">
创建: ${formatBeijingTime(t.created_at * 1000)}
</div>
</div>
<div>
${t.status === 'disabled'
? '<span class="badge badge-secondary">已停用</span>'
: '<span class="badge badge-primary" style="color:var(--success-fg);">活跃</span>'}
</div>
</div>
<!-- Purpose Remark Badge -->
<div style="font-size:0.76rem; color:var(--text-muted); background:var(--code-bg); padding:4px 8px; border-radius:6px; border:1px solid var(--border-sub); display:flex; align-items:flex-start; gap:4px; margin:4px 0 6px 0;">
<span style="font-weight:700; color:var(--primary); font-size:0.72rem; shrink-0;">💡 用途:</span>
<span id="tok-card-remark-${t.id}" style="word-break:break-all;">${t.remark || '通用推送服务调用'}</span>
</div>
<div class="token-card-val-box">
<code class="token-card-val" id="tok-card-val-${t.id}" style="${t.status === 'disabled' ? 'text-decoration:line-through; opacity:0.6;' : ''}">${t.token}</code>
<button class="btn btn-outline btn-sm" onclick="copyText(this, '${t.token}')">📋 复制</button>
</div>
<div class="token-card-footer">
<span style="color:var(--text-muted);">状态: ${t.status === 'disabled' ? '已休眠' : '正常监听'}</span>
<div style="display:inline-flex; gap:6px; align-items:center;">
${t.is_default ? `
<button class="btn btn-outline btn-sm" style="color:var(--primary); font-weight:700;" onclick="openEditTokenModal('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}', '${t.token}', true, '${(t.remark||'').replace(/'/g, "\\'")}')">🔄 重置/修改</button>
` : `
<button class="btn btn-outline btn-sm" style="${t.status === 'disabled' ? 'color:var(--success); font-weight:700;' : 'color:var(--warning);'}" onclick="toggleTokenStatus('${t.id}', '${t.status}')">
${t.status === 'disabled' ? '▶️ 启用' : '⏸️ 停用'}
</button>
<button class="btn btn-outline btn-sm" onclick="openEditTokenModal('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}', '${t.token}', false, '${(t.remark||'').replace(/'/g, "\\'")}')">✏️ 编辑备注</button>
<button class="btn btn-danger btn-sm" onclick="deleteToken('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}')">🗑️ 删除</button>
`}
</div>
</div>
</div>
`).join('')}
</div>
<!-- Table View -->
<div class="table-container" id="tokTableView" style="display:none;">
<table>
<thead>
<tr>
<th>通道名称</th>
<th>用途备注 / 适用系统</th>
<th>推送 Token 凭据</th>
<th>创建时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${tokens.map(t => `
<tr id="row-tok-${t.id}">
<td>
<strong id="tok-name-${t.id}">${t.name}</strong>
${t.is_default ? '<span class="badge badge-primary" style="margin-left:4px; font-size:0.7rem;">主通道</span>' : ''}
</td>
<td>
<span id="tok-remark-${t.id}" style="font-size:0.78rem; color:var(--text); background:var(--code-bg); padding:3px 8px; border-radius:6px; border:1px solid var(--border-sub); display:inline-block; max-width:320px;">${t.remark || '通用推送调用'}</span>
</td>
<td>
<div style="display:inline-flex; align-items:center; gap:6px;">
<code id="tok-val-${t.id}" style="background:var(--code-bg); padding:2px 6px; border-radius:4px; font-size:0.82rem; ${t.status === 'disabled' ? 'text-decoration:line-through; opacity:0.6;' : ''}">${t.token}</code>
<button class="btn btn-outline btn-sm" onclick="copyText(this, '${t.token}')">📋 复制</button>
</div>
</td>
<td><small style="color:var(--text-muted); font-family:monospace;">${formatBeijingTime(t.created_at * 1000)}</small></td>
<td>
${t.status === 'disabled'
? '<span class="badge badge-secondary">已停用</span>'
: '<span class="badge badge-primary" style="color:var(--success-fg);">活跃</span>'}
</td>
<td>
${t.is_default ? `
<button class="btn btn-outline btn-sm" style="color:var(--primary); font-weight:700;" onclick="openEditTokenModal('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}', '${t.token}', true, '${(t.remark||'').replace(/'/g, "\\'")}')">🔄 重置</button>
` : `
<div style="display:inline-flex; gap:4px; align-items:center;">
<button class="btn btn-outline btn-sm" onclick="toggleTokenStatus('${t.id}', '${t.status}')" title="${t.status === 'disabled' ? '启用通道' : '停用通道'}">
${t.status === 'disabled' ? '▶️' : '⏸️'}
</button>
<button class="btn btn-outline btn-sm" onclick="openEditTokenModal('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}', '${t.token}', false, '${(t.remark||'').replace(/'/g, "\\'")}')" title="编辑名称与用途备注">✏️</button>
<button class="btn btn-danger btn-sm" onclick="deleteToken('${t.id}', '${(t.name||'').replace(/'/g, "\\'")}')" title="彻底吊销删除">🗑️</button>
</div>
`}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
` : ''}
<!-- 4. Push Logs View (Admin Only) -->
${isAdmin ? `
<div id="sectionLogs" style="display:none;">
<div class="ppanel-card">
<div class="card-header-row">
<div class="title-pill-group">
<span class="badge badge-primary">📋 实时流水</span>
<h3 class="card-title">消息推送记录流水明细 (最近 50 条)</h3>
</div>
<div style="display:inline-flex; gap:8px; align-items:center;">
<button class="btn btn-outline btn-sm" onclick="refreshLogs()">🔄 刷新流水</button>
<button class="btn btn-danger btn-sm" onclick="clearAllLogs()">🗑️ 一键清空流水</button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>时间</th>
<th>来源 IP</th>
<th>通道名称</th>
<th>消息标题</th>
<th>消息摘要</th>
<th>推送状态</th>
</tr>
</thead>
<tbody id="logsTableBody">
${logs.length > 0 ? logs.map((l, idx) => `
<tr style="cursor:pointer;" data-idx="${idx}" onclick="showLogDetailFromIdx(this.dataset.idx)" title="点击查看完整推送消息详情">
<td><small style="font-family:monospace;">${escapeHtml(l.time)}</small></td>
<td><small style="font-family:monospace; color:var(--text-muted);">${maskIp(l.ip)}</small></td>
<td><span class="badge badge-secondary">${escapeHtml(l.token_name || '默认通道')}</span></td>
<td><strong>${escapeHtml(l.title || '-')}</strong></td>
<td>
<div style="display:flex; align-items:center; justify-content:space-between; gap:6px;">
<span style="color:var(--text-muted); font-size:0.82rem; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(l.preview || '-')}</span>
<button class="btn btn-outline btn-sm" data-idx="${idx}" onclick="event.stopPropagation(); showLogDetailFromIdx(this.dataset.idx)">🔍 详情</button>
</div>
</td>
<td><span style="color:var(--success); font-weight:700;">✓ 成功</span></td>
</tr>
`).join('') : `<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:2rem;">暂无推送流水记录</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
` : ''}
<footer style="text-align:center; font-size:0.76rem; color:var(--text-muted); padding:1rem 0;">
Powered by OnePve Cloud Portal & Cloudflare Workers D1 Database · 零服务器常驻
</footer>
</div>
<!-- Heatmap Tooltip -->
<div class="heatmap-floating-tooltip" id="heatmapFloatingTooltip"></div>
<!-- Mobile Drawer -->
<div class="drawer-overlay" id="siteNavDrawer" onclick="if(event.target===this) closeNavDrawer()">
<div class="drawer-panel">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<div style="font-weight:800; font-size:1rem;">📡 网关菜单</div>
<button class="btn btn-ghost btn-sm" onclick="closeNavDrawer()">✕</button>
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:0.78rem; font-weight:700; color:var(--text-muted); margin-top:4px;">🎨 主题配色方案</div>
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:6px;">
<button class="color-opt-btn" data-color="aurora" onclick="setThemeColor('aurora')">
<span class="color-swatch" style="background:#00aeb9;"></span>
<span>极光青</span>
</button>
<button class="color-opt-btn" data-color="titanium" onclick="setThemeColor('titanium')">
<span class="color-swatch" style="background:#0284c7;"></span>
<span>钛金蓝</span>
</button>
<button class="color-opt-btn" data-color="violet" onclick="setThemeColor('violet')">
<span class="color-swatch" style="background:#7c3aed;"></span>
<span>紫晶光</span>
</button>
<button class="color-opt-btn" data-color="emerald" onclick="setThemeColor('emerald')">
<span class="color-swatch" style="background:#059669;"></span>
<span>森林绿</span>
</button>
<button class="color-opt-btn" data-color="amber" onclick="setThemeColor('amber')">
<span class="color-swatch" style="background:#d97706;"></span>
<span>琥珀金</span>
</button>
<button class="color-opt-btn" data-color="crimson" onclick="setThemeColor('crimson')">
<span class="color-swatch" style="background:#e11d48;"></span>
<span>落樱嫩粉</span>
</button>
</div>
<button class="btn btn-outline" onclick="toggleTheme(); closeNavDrawer();" style="justify-content:space-between;">
<span id="drawerThemeText">深色模式 (护眼)</span>
<span id="drawerThemeIcon">🌙</span>
</button>
${isAdmin ? `
<button class="btn btn-danger" onclick="logoutAdmin(); closeNavDrawer();">🚪 退出管理员</button>
` : `
<button class="btn btn-primary" onclick="openAdminModal(); closeNavDrawer();">🔐 管理员登录</button>
`}
</div>
</div>
</div>
<!-- Admin Login Modal -->
<div class="modal-overlay" id="adminLoginModal" onclick="if(event.target===this) closeAdminModal()">
<div class="modal-card">
<div class="modal-title">🔐 管理员身份认证</div>
<div class="modal-desc">请输入网关 ADMIN_KEY 以解锁全部通道凭据管理与流水监控:</div>
<input type="password" id="adminKeyInput" class="input-box" placeholder="输入管理员密码..." style="margin-bottom:1.2rem;" onkeydown="if(event.key==='Enter') submitAdminLogin()" />
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeAdminModal()">取消</button>
<button class="btn btn-primary" id="adminLoginBtn" onclick="submitAdminLogin()">登录验证</button>
</div>
</div>
</div>
<!-- Edit Token Modal -->
<div class="modal-overlay" id="editTokenModal" onclick="if(event.target===this) closeEditTokenModal()">
<div class="modal-card">
<div class="modal-title" id="editTokenModalTitle">✏️ 编辑推送通道与用途备注</div>
<div class="modal-desc">修改通道名称、用途备注说明或重新生成专属校验 Token:</div>
<input type="hidden" id="editTokenId" />
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:1.2rem;">
<div>
<label style="font-size:0.76rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">通道名称</label>
<input type="text" id="editTokenName" class="input-box" placeholder="如: 📱 小米11Pro 专属推送" />
</div>
<div>
<label style="font-size:0.76rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">用途备注说明 (注明大概给哪些地方/服务调用)</label>
<input type="text" id="editTokenRemark" class="input-box" placeholder="如: 呆呆面板 7大签到 / 业务告警 / GitHub CI" />
</div>
<div>
<label style="font-size:0.76rem; font-weight:700; color:var(--text-muted); margin-bottom:3px; display:block;">推送 Token 凭据</label>
<div style="display:flex; gap:6px;">
<input type="text" id="editTokenVal" class="input-box" placeholder="Token 字符串..." style="font-family:monospace;" />
<button class="btn btn-outline btn-sm" onclick="generateEditRandomToken()">🎲</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeEditTokenModal()">取消</button>
<button class="btn btn-primary" id="saveEditTokenBtn" onclick="submitSaveEditToken()">保存修改</button>
</div>
</div>
</div>
<!-- Confirm Action Modal -->
<div class="modal-overlay" id="confirmActionModal" onclick="if(event.target===this) closeConfirmActionModal()">
<div class="modal-card">
<div class="modal-title" id="confirmModalTitle">⚠️ 确认操作</div>
<div class="modal-desc" id="confirmModalDesc">请确认是否执行此操作?</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeConfirmActionModal()">取消</button>
<button class="btn btn-danger" id="confirmActionBtn" onclick="executePendingConfirmAction()">确认执行</button>
</div>
</div>
</div>
<!-- Log Detail Modal -->
<div class="modal-overlay" id="logDetailModal" onclick="if(event.target===this) closeModal('logDetailModal')">
<div class="modal-card" style="max-width: 580px;">
<div class="modal-title">📋 消息推送流水详情</div>
<div style="display:flex; flex-direction:column; gap:8px; font-size:0.82rem; margin-bottom:1.2rem;">
<div style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px solid var(--border-sub);">
<span style="color:var(--text-muted);">推送时间:</span>
<strong id="logDetailTime" style="font-family:monospace;">-</strong>
</div>
<div style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px solid var(--border-sub);">
<span style="color:var(--text-muted);">来源 IP:</span>
<strong id="logDetailIp" style="font-family:monospace;">-</strong>
</div>
<div style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px solid var(--border-sub);">
<span style="color:var(--text-muted);">匹配通道:</span>
<span class="badge badge-secondary" id="logDetailChannel">-</span>
</div>
<div style="padding:4px 0;">
<span style="color:var(--text-muted); display:block; margin-bottom:4px;">消息标题:</span>
<strong id="logDetailTitle" style="font-size:0.95rem;">-</strong>
</div>
<div style="padding:4px 0;">
<span style="color:var(--text-muted); display:block; margin-bottom:4px;">推送正文内容 / Payload:</span>
<pre id="logDetailContent" style="background:var(--code-bg); color:var(--code-fg); padding:10px; border-radius:var(--radius-md); font-family:monospace; font-size:0.8rem; white-space:pre-wrap; word-break:break-all; max-height:220px; overflow-y:auto; border:1px solid var(--border-sub);"></pre>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('logDetailModal')">关闭</button>
</div>
</div>
</div>
<script>
let currentLogsData = ${JSON.stringify(logs || [])};
function clientEscape(s) {
if (!s) return '';
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function clientMaskIp(ip) {
if (!ip) return '-';
if (ip.includes(':')) {
const parts = ip.split(':');
if (parts.length > 2) return parts.slice(0, 2).join(':') + '::***';
return ip;
}
const parts = ip.split('.');
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.***.***';
return ip;
}
function showLogDetailFromIdx(idx) {
const item = currentLogsData[parseInt(idx, 10)];
if (!item) return;
showLogDetail(
item.time,
clientMaskIp(item.ip),
item.token_name || '默认通道',
item.title || '无标题消息',
item.content || item.preview || '(内容为空)'
);
}
async function refreshLogs() {
try {
showToast('正在拉取最新流水...');
const res = await fetch('/api/logs');
if (res.status === 401) { location.reload(); return; }
const data = await res.json();
if (data.status === 'success') {
showToast('✓ 流水日志已刷新');
renderClientLogs(data.logs || []);
if (data.quota && document.getElementById('smsDailyVal')) {
document.getElementById('smsDailyVal').innerHTML = (data.quota.today_count || 0).toLocaleString() + ' <small style="font-size:0.75rem; color:var(--text-muted);">条</small>';
}
}
} catch (e) {
location.reload();
}
}
function renderClientLogs(logs) {
currentLogsData = logs || [];
const tbody = document.getElementById('logsTableBody');
if (!tbody) return;
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:2rem;">暂无推送流水记录</td></tr>';
return;
}
tbody.innerHTML = logs.map((l, idx) => {
const time = clientEscape(l.time || '');
const ip = clientEscape(clientMaskIp(l.ip || ''));
const tokenName = clientEscape(l.token_name || '默认通道');
const title = clientEscape(l.title || '-');
const preview = clientEscape(l.preview || '-');
return '<tr style="cursor:pointer;" data-idx="' + idx + '" onclick="showLogDetailFromIdx(this.dataset.idx)" title="点击查看完整推送消息详情">' +
'<td><small style="font-family:monospace;">' + time + '</small></td>' +
'<td><small style="font-family:monospace; color:var(--text-muted);">' + ip + '</small></td>' +
'<td><span class="badge badge-secondary">' + tokenName + '</span></td>' +
'<td><strong>' + title + '</strong></td>' +
'<td><div style="display:flex; align-items:center; justify-content:space-between; gap:6px;">' +
'<span style="color:var(--text-muted); font-size:0.82rem; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + preview + '</span>' +
'<button class="btn btn-outline btn-sm" data-idx="' + idx + '" onclick="event.stopPropagation(); showLogDetailFromIdx(this.dataset.idx)">🔍 详情</button>' +
'</div></td>' +
'<td><span style="color:var(--success); font-weight:700;">✓ 成功</span></td>' +
'</tr>';
}).join('');
}
function clearAllLogs() {
openConfirmModal(
'🗑️ 确认彻底清空所有推送流水?',
'确定要彻底清空并删除所有历史消息推送流水日志吗?<br><br><span style="color:var(--danger); font-size:0.82rem;">⚠️ 警告:清空后历史推送记录将无法恢复,今日写入配额统计保持正常计算!</span>',
'确认清空',
'btn-danger',
async () => {
try {
showToast('正在清空消息流水...');
const res = await fetch('/api/logs/clear', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast('✓ 已彻底清空所有流水日志!');
currentLogsData = [];
const tbody = document.getElementById('logsTableBody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:2rem;">暂无推送流水记录</td></tr>';
}
} else {
showToast('❌ 清空失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
}
);
}
let pendingConfirmCallback = null;
function openConfirmModal(title, desc, confirmBtnText, btnClass, callback) {
document.getElementById('confirmModalTitle').innerText = title;
document.getElementById('confirmModalDesc').innerHTML = desc;
const btn = document.getElementById('confirmActionBtn');
btn.innerText = confirmBtnText || '确认';
btn.className = 'btn ' + (btnClass || 'btn-danger');
pendingConfirmCallback = callback;
openModal('confirmActionModal');
}
function closeConfirmActionModal() {
closeModal('confirmActionModal');
pendingConfirmCallback = null;
}
async function executePendingConfirmAction() {
if (typeof pendingConfirmCallback === 'function') {
const cb = pendingConfirmCallback;
closeConfirmActionModal();
await cb();
}
}
function openEditTokenModal(id, name, token, isDefault, remark) {
document.getElementById('editTokenId').value = id;
document.getElementById('editTokenName').value = name || '';
document.getElementById('editTokenRemark').value = remark || '';
document.getElementById('editTokenVal').value = token || '';
document.getElementById('editTokenModalTitle').innerText = isDefault ? '🔄 重置默认主密钥' : '✏️ 编辑通道名称与用途备注';
openModal('editTokenModal');
}
function closeEditTokenModal() { closeModal('editTokenModal'); }
function generateEditRandomToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let r = 'sms_';
for (let i = 0; i < 18; i++) r += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById('editTokenVal').value = r;
}
async function doSaveToken(id, name, token, remark) {
const btn = document.getElementById('saveEditTokenBtn');
if (btn) { btn.disabled = true; btn.innerText = '保存中...'; }
try {
const res = await fetch('/api/tokens', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, token, remark })
});
const data = await res.json();
if (data.status === 'success') {
showToast(id === 'default' ? '✓ 默认主 Token 已成功重置!' : '✓ 通道凭据与用途备注修改成功!');
setTimeout(() => location.reload(), 400);
} else {
showToast('❌ 修改失败: ' + (data.error || '未知错误'));
if (btn) { btn.disabled = false; btn.innerText = '保存修改'; }
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
if (btn) { btn.disabled = false; btn.innerText = '保存修改'; }
}
}
async function submitSaveEditToken() {
const id = document.getElementById('editTokenId')?.value;
const name = (document.getElementById('editTokenName')?.value || '').trim();
const remark = (document.getElementById('editTokenRemark')?.value || '').trim();
const token = (document.getElementById('editTokenVal')?.value || '').trim();
if (!token) { showToast('❌ 请填写完整的 Token 字符串'); return; }
if (id === 'default') {
openConfirmModal(
'⚠️ 确认重置默认主密钥?',
'你正在重置<b>网关默认主推送 Token</b>。<br><br><span style="color:var(--danger); font-size:0.82rem;">⚠️ 警告:重置后使用旧默认 Token 的探针与脚本将无法发送消息,需同步更新配置!</span>',
'确认重置',
'btn-danger',
() => doSaveToken(id, name, token, remark)
);
return;
}
doSaveToken(id, name, token, remark);
}
function showHeatmapTooltip(elem, event) {
let tt = document.getElementById('heatmapFloatingTooltip');
if (!tt) return;
const date = elem.getAttribute('data-date') || '状态记录';
const status = elem.getAttribute('data-status') || '正常';
const count = elem.getAttribute('data-count') || '0 条';
let statusColor = '#00aeb9';
if (status.includes('超高频')) statusColor = '#ef4444';
else if (status.includes('高频')) statusColor = '#f97316';
else if (status.includes('暂无')) statusColor = '#94a3b8';
tt.innerHTML = '<div style="font-weight:700; margin-bottom:2px;">📅 ' + date + '</div>' +
'<div style="color:' + statusColor + '; font-weight:700;">● ' + status + '</div>' +
'<div style="color:#94a3b8; font-size:0.72rem; margin-top:2px;">推送量: ' + count + ' · 通道状态: 就绪</div>';
const rect = elem.getBoundingClientRect();
let left = rect.left + rect.width / 2;
let top = rect.top - 8;
if (rect.top < 90) {
top = rect.bottom + 8;
tt.style.transform = 'translate(-50%, 0)';
} else {
tt.style.transform = 'translate(-50%, -100%)';
}
left = Math.max(120, Math.min(window.innerWidth - 120, left));
tt.style.left = left + 'px';
tt.style.top = top + 'px';
tt.classList.add('show');
clearTimeout(window.__ttTimer);
window.__ttTimer = setTimeout(() => hideHeatmapTooltip(), 3500);
}
function hideHeatmapTooltip() {
const tt = document.getElementById('heatmapFloatingTooltip');
if (tt) tt.classList.remove('show');
}
function switchSmsView(mode) {
const btns = ['viewDocsBtn', 'viewHeatmapBtn', 'viewTokensBtn', 'viewLogsBtn'];
btns.forEach(id => document.getElementById(id)?.classList.remove('active'));
const secDocs = document.getElementById('sectionDocs');
const secHeatmap = document.getElementById('sectionHeatmap');
const secTokens = document.getElementById('sectionTokens');
const secLogs = document.getElementById('sectionLogs');
if (secDocs) secDocs.style.display = 'none';
if (secHeatmap) secHeatmap.style.display = 'none';
if (secTokens) secTokens.style.display = 'none';
if (secLogs) secLogs.style.display = 'none';
if (mode === 'docs') {
document.getElementById('viewDocsBtn')?.classList.add('active');
if (secDocs) secDocs.style.display = 'block';
} else if (mode === 'heatmap') {
document.getElementById('viewHeatmapBtn')?.classList.add('active');
if (secHeatmap) secHeatmap.style.display = 'block';
} else if (mode === 'tokens') {
document.getElementById('viewTokensBtn')?.classList.add('active');
if (secTokens) secTokens.style.display = 'block';
} else if (mode === 'logs') {
document.getElementById('viewLogsBtn')?.classList.add('active');
if (secLogs) secLogs.style.display = 'block';
}
}
function switchTokenView(mode) {
const cardsView = document.getElementById('tokCardsView');
const tableView = document.getElementById('tokTableView');
const btnCards = document.getElementById('tokViewCardsBtn');
const btnTable = document.getElementById('tokViewTableBtn');
if (mode === 'table') {
if (cardsView) cardsView.style.display = 'none';
if (tableView) tableView.style.display = 'block';
btnCards?.classList.remove('active');
btnTable?.classList.add('active');
localStorage.setItem('sms_token_view_mode', 'table');
} else {
if (cardsView) cardsView.style.display = 'grid';
if (tableView) tableView.style.display = 'none';
btnCards?.classList.add('active');
btnTable?.classList.remove('active');
localStorage.setItem('sms_token_view_mode', 'cards');
}
}
function openAdminModal() {
openModal('adminLoginModal');
document.getElementById('adminKeyInput')?.focus();
}
function closeAdminModal() { closeModal('adminLoginModal'); }
async function submitAdminLogin() {
const key = (document.getElementById('adminKeyInput')?.value || '').trim();
if (!key) { showToast('请输入管理员密码'); return; }
const btn = document.getElementById('adminLoginBtn');
if (btn) { btn.disabled = true; btn.innerText = '认证中...'; }
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
const data = await res.json();
if (data.status === 'success') {
showToast('✓ 管理员认证成功!');
setTimeout(() => location.reload(), 400);
} else {
showToast('❌ ' + (data.error || '密码错误'));
if (btn) { btn.disabled = false; btn.innerText = '登录验证'; }
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
if (btn) { btn.disabled = false; btn.innerText = '登录验证'; }
}
}
async function logoutAdmin() {
try {
await fetch('/api/logout', { method: 'POST' });
showToast('✓ 已退出管理员模式');
setTimeout(() => location.reload(), 300);
} catch (e) {
location.reload();
}
}
async function submitOnlineTest() {
const token = document.getElementById('testTokenSelect')?.value;
const title = (document.getElementById('testMsgTitle')?.value || '').trim();
const text = (document.getElementById('testMsgContent')?.value || '').trim();
if (!token) { showToast('❌ 请选择有效的 Token'); return; }
if (!text) { showToast('❌ 请输入要测试推送的内容'); return; }
showToast('正在发送测试消息...');
try {
const res = await fetch('/message?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, text })
});
const data = await res.json();
if (data.status === 'success') {
showToast('✓ 消息推送成功!已投递至目标通道');
} else {
showToast('❌ 推送失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 推送请求失败: ' + e.message);
}
}
function generateRandomToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let r = 'sms_';
for (let i = 0; i < 18; i++) r += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById('newTokenVal').value = r;
}
async function submitCreateToken() {
const name = (document.getElementById('newTokenName')?.value || '').trim();
const remark = (document.getElementById('newTokenRemark')?.value || '').trim();
let token = (document.getElementById('newTokenVal')?.value || '').trim();
if (!token) { generateRandomToken(); token = document.getElementById('newTokenVal').value; }
try {
const res = await fetch('/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || '自定义推送通道', token, remark: remark || '通用推送服务调用' })
});
const data = await res.json();
if (data.status === 'success') {
showToast('✓ 密钥通道创建成功!');
setTimeout(() => location.reload(), 400);
} else {
showToast('❌ 创建失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
}
async function toggleTokenStatus(id, currentStatus) {
try {
const res = await fetch('/api/tokens?id=' + encodeURIComponent(id) + '&action=toggle', { method: 'PATCH' });
const data = await res.json();
if (data.status === 'success') {
showToast(data.new_status === 'active' ? '✓ 通道已成功启用!' : '✓ 通道已停用');
setTimeout(() => location.reload(), 400);
} else {
showToast('❌ 操作失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
}
async function deleteToken(id, name) {
openConfirmModal(
'🗑️ 确认吊销并删除通道?',
'确定要永久吊销并删除推送通道「<strong style="color:var(--text);">' + name + '</strong>」吗?<br><br><span style="color:var(--danger); font-size:0.82rem;">⚠️ 警告:删除后使用该 Token 发起的 Webhook 消息将被网关直接拦截拒绝,此操作不可撤销!</span>',
'确认删除',
'btn-danger',
async () => {
try {
const res = await fetch('/api/tokens?id=' + encodeURIComponent(id), { method: 'DELETE' });
const data = await res.json();
if (data.status === 'success') {
showToast('✓ Token 已成功吊销删除');
document.getElementById('row-tok-' + id)?.remove();
document.getElementById('card-tok-' + id)?.remove();
} else {
showToast('❌ 删除失败: ' + (data.error || '未知错误'));
}
} catch (e) {
showToast('❌ 请求失败: ' + e.message);
}
}
);
}
function onDocTokenChange(tok) {
const host = window.location.host || 'sms.example.com';
const cmdGetStr = 'https://' + host + '/message?token=' + tok + '&title=测试标题&text=测试内容';
const cmdJsonStr = 'curl -X POST "https://' + host + '/message?token=' + tok + '" -H "Content-Type: application/json" -d ' + JSON.stringify('{"title":"测试标题","text":"测试内容"}');
const cmdGotifyStr = 'curl -X POST "https://' + host + '/message" -H "X-Gotify-Key: ' + tok + '" -d "title=告警&message=服务器离线"';
const cmdBarkStr = 'curl "https://' + host + '/' + tok + '/这是标题/这是内容"';
const elGet = document.getElementById('cmdGet');
if (elGet) { elGet.innerText = cmdGetStr; elGet.title = cmdGetStr; }
const elJson = document.getElementById('cmdJson');
if (elJson) { elJson.innerText = cmdJsonStr; elJson.title = cmdJsonStr; }
const elGotify = document.getElementById('cmdGotify');
if (elGotify) { elGotify.innerText = cmdGotifyStr; elGotify.title = cmdGotifyStr; }
const elBark = document.getElementById('cmdBark');
if (elBark) { elBark.innerText = cmdBarkStr; elBark.title = cmdBarkStr; }
}
function copyDynamicCmd(elemId, btn) {
const text = document.getElementById(elemId)?.innerText || '';
copyText(btn, text);
}
function copyText(btn, text) {
navigator.clipboard.writeText(text).then(() => {
showToast('✓ 已复制到剪贴板');
const orig = btn.innerText;
btn.innerText = '✓ 已复制';
setTimeout(() => { btn.innerText = orig; }, 1500);
}).catch(() => showToast('复制失败,请手动复制'));
}
function showToast(msg) {
const t = document.getElementById('toast');
if (t) {
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
}
function showLogDetail(time, ip, channel, title, contentText) {
document.getElementById('logDetailTime').innerText = time || '-';
document.getElementById('logDetailIp').innerText = ip || '-';
document.getElementById('logDetailChannel').innerText = channel || '默认通道';
document.getElementById('logDetailTitle').innerText = title || '无标题消息';
document.getElementById('logDetailContent').innerText = contentText || '(内容为空)';
openModal('logDetailModal');
}
function openModal(id) { document.getElementById(id)?.classList.add('open'); }
function closeModal(id) { document.getElementById(id)?.classList.remove('open'); }
function openNavDrawer() { document.getElementById('siteNavDrawer')?.classList.add('open'); }
function closeNavDrawer() { document.getElementById('siteNavDrawer')?.classList.remove('open'); }
function toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
const next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('sms_theme', next);
updateThemeUI(next);
}
function updateThemeUI(t) {
const icon = t === 'dark' ? '☀️' : '🌙';
const text = t === 'dark' ? '明亮模式' : '深色模式 (护眼)';
const dBtn = document.getElementById('desktopThemeBtn');
const dIcon = document.getElementById('drawerThemeIcon');
const dText = document.getElementById('drawerThemeText');
if (dBtn) dBtn.innerText = icon;
if (dIcon) dIcon.innerText = icon;
if (dText) dText.innerText = text;
}
updateThemeUI(document.documentElement.getAttribute('data-theme') || 'light');
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('sms_token_view_mode');
if (saved) { switchTokenView(saved); }
else if (window.innerWidth < 1024) { switchTokenView('cards'); }
});
const COLOR_NAMES = {
aurora: '极光青',
titanium: '钛金蓝',
violet: '紫晶光',
emerald: '森林绿',
amber: '琥珀金',
crimson: '落樱嫩粉'
};
function setThemeColor(c) {
document.documentElement.setAttribute('data-theme-color', c);
localStorage.setItem('example_theme_color', c);
updateThemeColorUI(c);
closePaletteMenu();
}
function updateThemeColorUI(c) {
const name = COLOR_NAMES[c] || '极光青';
const curName = document.getElementById('curColorName');
if (curName) curName.innerText = name;
document.querySelectorAll('.color-opt-btn').forEach(btn => {
if (btn.getAttribute('data-color') === c) btn.classList.add('active');
else btn.classList.remove('active');
});
}
function togglePaletteMenu(e) {
e.stopPropagation();
document.getElementById('paletteMenu')?.classList.toggle('open');
}
function closePaletteMenu() {
document.getElementById('paletteMenu')?.classList.remove('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#paletteDropdown')) {
closePaletteMenu();
}
});
(function initColor() {
const saved = localStorage.getItem('example_theme_color') || 'aurora';
document.documentElement.setAttribute('data-theme-color', saved);
updateThemeColorUI(saved);
})();
</script>
</body>
</html>
`;
return new Response(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const clientIP = request.headers.get('cf-connecting-ip') || request.headers.get('x-real-ip') || 'unknown';
// CORS preflight
if (request.method === 'OPTIONS') {
return jsonResponse({ status: 'ok' }, 200);
}
// --- 0. Live Logs & Status API ---
if (path === '/api/logs' || path === '/api/status') {
const isAdmin = await verifyAdmin(request, env);
if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);
if (request.method === 'DELETE' || url.searchParams.get('action') === 'clear') {
const ok = await Storage.clearLogs(env);
if (ok) return jsonResponse({ status: 'success', message: '已彻底清空所有推送流水记录' });
return jsonResponse({ status: 'fail', error: '清空流水失败' }, 500);
}
const data = await Storage.getLogsAndQuota(env);
return jsonResponse({ status: 'success', ...data });
}
if (path === '/api/logs/clear') {
const isAdmin = await verifyAdmin(request, env);
if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);
const ok = await Storage.clearLogs(env);
if (ok) return jsonResponse({ status: 'success', message: '已彻底清空所有推送流水记录' });
return jsonResponse({ status: 'fail', error: '清空流水失败' }, 500);
}
// --- 1. Admin Login API ---
if (path === '/api/login') {
if (request.method !== 'POST') return jsonResponse({ status: 'fail', error: 'Method not allowed' }, 405);
const lock = await Storage.getAuthLock(env, clientIP);
const now = Math.floor(Date.now() / 1000);
if (lock && lock.blocked_until > now) {
const remainSec = lock.blocked_until - now;
return jsonResponse({
status: 'fail',
error: `密码输错次数过多,IP 已被安全锁定,请在 ${remainSec} 秒后再试`
}, 429);
}
const body = await request.json().catch(() => ({}));
const inputKey = (body.key || '').trim();
const adminKey = (env.ADMIN_KEY || DEFAULT_ADMIN_KEY || '').trim();
if (!inputKey) {
return jsonResponse({ status: 'fail', error: '请输入管理员密码' }, 400);
}
if (await timingSafeEqual(inputKey, adminKey)) {
await Storage.clearAuthLock(env, clientIP);
const headers = new Headers({
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `sms_admin_key=${encodeURIComponent(inputKey)}; Path=/; Max-Age=2592000; Secure; SameSite=Lax`,
'Access-Control-Allow-Origin': '*'
});
return new Response(JSON.stringify({ status: 'success', message: '管理员认证成功' }), { status: 200, headers });
} else {
await Storage.recordAuthFail(env, clientIP);
return jsonResponse({ status: 'fail', error: '管理员密码错误' }, 401);
}
}
// --- 2. Admin Logout API ---
if (path === '/api/logout') {
const headers = new Headers({
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `sms_admin_key=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Secure; SameSite=Lax`,
'Access-Control-Allow-Origin': '*'
});
return new Response(JSON.stringify({ status: 'success', message: '已退出管理员身份' }), { status: 200, headers });
}
// --- 3. Settings API ---
if (path === '/api/settings') {
const isAdmin = await verifyAdmin(request, env);
if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);
if (request.method === 'POST') {
const body = await request.json().catch(() => ({}));
await Storage.saveSettings(env, body);
return jsonResponse({ status: 'success', message: '配置已保存' });
}
const settings = await Storage.getSettings(env);
return jsonResponse({ status: 'success', settings });
}
// --- 4. Tokens Management API ---
if (path === '/api/tokens') {
const isAdmin = await verifyAdmin(request, env);
if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);
if (request.method === 'POST') {
const body = await request.json().catch(() => ({}));
const created = await Storage.createToken(env, body.name, body.token, body.remark);
return jsonResponse({ status: 'success', token: created });
} else if (request.method === 'PUT') {
const body = await request.json().catch(() => ({}));
if (!body.id || !body.token) {
return jsonResponse({ status: 'fail', error: '缺少必填字段 (id, token)' }, 400);
}
const ok = await Storage.updateToken(env, body.id, body.name, body.token, body.remark);
if (ok) return jsonResponse({ status: 'success', message: 'Token 与用途备注更新成功' });
return jsonResponse({ status: 'fail', error: '更新失败' }, 400);
} else if (request.method === 'PATCH') {
const id = url.searchParams.get('id');
if (!id) return jsonResponse({ status: 'fail', error: '缺少 token id' }, 400);
const newStatus = await Storage.toggleTokenStatus(env, id);
if (newStatus) return jsonResponse({ status: 'success', new_status: newStatus, message: `Token 状态已切换为 ${newStatus}` });
return jsonResponse({ status: 'fail', error: '操作失败或不可修改默认 Token' }, 400);
} else if (request.method === 'DELETE') {
const id = url.searchParams.get('id');
if (!id) return jsonResponse({ status: 'fail', error: '缺少 token id' }, 400);
const ok = await Storage.deleteToken(env, id);
if (ok) return jsonResponse({ status: 'success', message: 'Token 已成功吊销删除' });
return jsonResponse({ status: 'fail', error: '删除失败或不可删除默认 Token' }, 400);
} else {
const tokens = await Storage.getAllTokens(env);
return jsonResponse({ status: 'success', tokens });
}
}
// --- 5. Push Message API ---
if (path === '/message' || path === '/send' || path === '/webhook') {
return await handleMessage(request, url, env, ctx);
}
// --- Favicon SVG Handler ---
if (path === '/favicon.ico' || path === '/favicon.svg') {
return new Response(FAVICON_SVG, {
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'public, max-age=604800, immutable'
}
});
}
// --- 6. Health Check ---
if (path === '/health') {
return jsonResponse({ status: 'healthy', time: new Date().toISOString(), database: 'D1' });
}
// --- 7. Web Dashboard ---
if (path === '/' || path === '/index.html') {
return await handleIndex(request, env);
}
return Response.redirect(`${url.origin}/`, 302);
}
};
总结
- 方案一(Cloudflare Serverless + D1):零服务器、免维护、毫秒级并发分发,享有 D1 每日 10 万次写入,完全免除限流顾虑;
- 方案二(VPS 本机物理架设):纯 Python 标准库 + Nginx + SQLite,完全私有自主,适合企业专网与独立主机部署!