Logo迷失博客
返回文章列表
常用工具阅读时长:80 分钟

多渠道 Webhook 消息网关

发布时间:2026年08月16日 08:00最后修改:2026年09月04日 22:00

提供【Cloudflare Serverless + D1(全球边缘秒级转发)】与【VPS 本机物理架设(Nginx + Python + SQLite)】两种完整自建方案,支持企业微信、Bark、Telegram、钉钉、飞书多渠道并发分发。全面对齐 PPanel 官方设计母版,集成 6 大主题配色切换器、三旬日历热力图大屏、多端响应式卡片流与 5 层自动化复检机制。

在日常运维与开发中,服务器监控告警、自动化脚本(Shell/Python)、定时任务以及各类服务(如 Uptime Kuma、爱快软路由)通常需要将通知实时推送到手机端。

为了满足不同网络环境(公网 Serverless vs 局域网私有化)的需求,本文提供 两种完整且经过生产检验的自建方案

  1. 方案一:Cloudflare Serverless 极速部署(推荐):基于 Cloudflare Worker + D1 数据库 / KV,0 物理服务器、0 本地资源消耗、全球边缘毫秒级并发分发,自带高安全 Token 鉴权与实时调用审计流水。
  2. 方案二:VPS / 本机物理独立架设:采用独立域名 + Nginx/OpenResty 反向代理 + 单文件 Python 消息网关服务 + 本地 SQLite 审计日志,适合企业内网、无公网访问限制或不依赖第三方云平台的私有化环境。

架构拓扑

+-------------------+       +------------------------------------+       +-------------------+
|   各种监控/脚本   | ----> |         消息转发网关服务端         | ----> |   企业微信 / Bark |
| (Shell/Python/路由) |       | [方案一: CF Worker] 或 [方案二: Py] |       |  Telegram/钉钉/飞书|
+-------------------+       +------------------------------------+       +-------------------+
                                              |
                                              v
                                     +-------------------+
                                     | D1 或 本地 SQLite |
                                     | (安全审计日志记录)|
                                     +-------------------+

接口参数规范

参数名类型必填传递位置说明
tokenStringQuery / Header授权认证密钥(支持 ?token=X-Token / X-Gotify-Key
titleStringBody / Query消息标题(可选,默认「通知消息」)
message / contentStringBody / Query消息正文(支持多行换行、特殊字符与长文本)
markdownBooleanBody / 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 模式:

  1. 毫秒级响应:消息接收后立即通过线程池异步转发并入库,单次请求响应耗时在 20ms 以内;
  2. 轻量长效:常驻内存占用保持在 < 15MB,完全不占服务器资源;
  3. 安全审计日志:自动保存最近 50 条流水到本地 SQLite,支持 Web 控制台可视化检索与调用排查。

方案一:Cloudflare Serverless 部署(推荐)

该方案将消息网关完全托管在 Cloudflare 全球边缘网络,秒级响应,免除一切服务器维护成本。

第一步:创建 D1 数据库(可选,用于审计日志)

  1. 登录 Cloudflare Dashboard
  2. 进入 Workers & Pages -> D1 SQL Database
  3. 点击 Create Database,名称填写 sms_db 并创建。

(注:代码内置双引擎适配器,未绑定 D1 时亦可自动兼容 KV 命名空间 AUTH_HUB_KV 或纯无状态转发)

第二步:创建 Worker 并配置密钥

  1. 进入 Workers & Pages -> Overview,点击 Create Worker,命名为 sms-forwarder 并部署;
  2. 进入 Settings -> Bindings(可选):
    • 点击 Add -> D1 Database Binding,变量名填写 DB,关联 sms_db
  3. 进入 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

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} &#10;推送: ${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 &quot;https://${host}/message?token=${displayToken}&quot; -H &quot;Content-Type: application/json&quot; -d '{&quot;title&quot;:&quot;测试标题&quot;,&quot;text&quot;:&quot;测试内容&quot;}'">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 &quot;https://${host}/message&quot; -H &quot;X-Gotify-Key: ${displayToken}&quot; -d &quot;title=告警&amp;message=服务器离线&quot;">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 &quot;https://${host}/${displayToken}/这是标题/这是内容&quot;">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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    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,完全私有自主,适合企业专网与独立主机部署!

推荐阅读