< img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1123479138861938&ev=PageView&noscript=1" />
Saltar al contenido
Free Shipping|30-Day Money-Back Guarantee
Carro
0 elementos
Plantsrobot 邮件管理后台 V2.0
仪表盘
连接中...
总订阅者
--
加载中...
已确认
--
加载中...
待确认
--
加载中...
已退订
--
加载中...
待发送邮件
--
队列中
今日新增
--
加载中...

📈 最近7天趋势(订阅 / 打开率 / 点击率)

📋 欢迎序列概览

🔽 欢迎序列转化漏斗

📋 最近邮件事件

欢迎序列邮件

感谢邮件

手动触发发送

请从左侧选择一封邮件进行编辑

选择后将显示富文本编辑器

📧 新建群发活动

插入变量: |

📋 发送历史

👥 订阅者管理

加载中...

📊 数据分析

--
发送总数
--
打开率
--
点击率
--
退订率
--
退回率

📈 趋势分析(打开率 / 点击率 / 退订率)

🔥 活跃时段热力图(7×24)

📱 设备分布

需要追踪像素支持
设备分布数据需要在邮件中启用追踪像素(Tracking Pixel)后自动采集,当前版本暂不支持。
📱 移动端--
💻 桌面端--
📋 平板--

📧 邮件客户端分布

需要 User-Agent 追踪
邮件客户端分布需要通过追踪像素采集 User-Agent 信息,当前版本暂不支持。
Gmail--
Apple Mail--
Outlook--

🌍 地理分布 TOP 10

需要 IP 地理定位
地理分布需要通过追踪像素采集 IP 地址并进行地理定位,当前版本暂不支持。

🧪 A/B 测试

版本 A
主题行:🌱 Welcome to Plantsrobot — Your Plant Care Journey Starts Now
32.8%
打开率
8.5%
点击率
0.4%
退订率
版本 B
主题行:Welcome to Plantsrobot — Start Growing Today
24.1%
打开率
5.2%
点击率
0.6%
退订率

📈 24 小时打开率趋势对比

📋 测试历史

测试名称变量样本量胜出版本提升状态操作
欢迎邮件主题行主题行500版本 A+8.7%已完成
CTA 按钮颜色测试CTA 按钮350版本 B+5.2%已完成
发件人名称测试发件人名称420版本 A+3.1%已完成

🟢 系统状态检查

--

订阅者总数

--

活跃序列

--

邮件事件

--

群发活动

📧 邮件订阅全流程

🛒

Shopify 落地页

访客填写邮箱 + 点击订阅

📧

确认邮件

自动发送含验证链接的邮件

确认成功页面

显示品牌化确认页 + 启动序列

流程说明:访客在 Shopify 落地页输入邮箱并点击"Subscribe"按钮后,系统会向该邮箱发送一封确认邮件(包含唯一验证 Token)。 用户打开邮箱点击确认链接后,浏览器会跳转到 Supabase 的 confirm 函数,该函数验证 Token 并将订阅者状态改为"已确认", 同时重定向到 Shopify 托管的确认页面(plantsrobot.com/pages/confirm),显示品牌化确认页面(绿色背景 + 白色卡片 + Plantsrobot 品牌标识)。 确认后系统自动启动 Welcome 序列(7 封邮件,从 0h 到 360h / 15 天),每 30 分钟由 pg_cron 自动检查并发送到期邮件。

📋 Shopify 嵌入代码

复制以下 JS 钩子代码,粘贴到 Shopify 页面的 Custom Liquid 区块中。页面样式(HTML/CSS)由你自主设计,代码只提供订阅提交逻辑(感谢邮件模式)。

<!-- Plantsrobot 订阅 JS 钩子 — 粘贴到 Shopify Custom Liquid 区块 -->
<!-- email_mode: 'thank_you' = 用户订阅后直接确认,发送感谢邮件 -->
<!-- HTML/CSS 样式由你自主设计,以下只提供订阅提交逻辑 -->

<script>
  (function() {
    var SUPABASE_URL = 'https://evhoszenrrqbhgltvyqv.supabase.co';

    // ========== 核心订阅函数 ==========
    // 调用示例: pmSubscribe(email, firstName, tagsArray, onSuccess, onError)
    window.pmSubscribe = function(email, firstName, tags, onSuccess, onError) {
      firstName = firstName || '';
      tags = tags || [];
      return fetch(SUPABASE_URL + '/functions/v1/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email: email,
          first_name: firstName,
          tags: tags,
          email_mode: 'thank_you'
        }),
        signal: AbortSignal.timeout(60000)
      })
      .then(function(resp) { return resp.json(); })
      .then(function(data) {
        if (data.success) {
          if (onSuccess) onSuccess(data);
        } else {
          if (onError) onError(data.error || 'Subscription failed');
        }
        return data;
      })
      .catch(function(err) {
        if (onError) onError('Network error. Please try again.');
      });
    };

    // ========== 快速绑定:表单提交自动调用 pmSubscribe ==========
    // 用法: 给你的 <form> 添加 data-pm-subscribe 属性即可
    // <form data-pm-subscribe data-pm-success="#msg">...</form>
    document.addEventListener('DOMContentLoaded', function() {
      var forms = document.querySelectorAll('form[data-pm-subscribe]');
      forms.forEach(function(form) {
        form.addEventListener('submit', function(e) {
          e.preventDefault();
          var btn = form.querySelector('button[type="submit"], input[type="submit"]');
          if (btn) { btn.disabled = true; btn.textContent = 'Subscribing...'; }

          var email = (form.querySelector('input[type="email"]') || {}).value || '';
          var name  = (form.querySelector('input[name="first_name"]') || {}).value || '';

          window.pmSubscribe(email, name, ['waitlist'],
            function() {
              form.style.display = 'none';
              var sel = form.getAttribute('data-pm-success');
              if (sel) {
                var el = document.querySelector(sel);
                if (el) el.style.display = 'block';
              }
            },
            function(errMsg) {
              if (btn) { btn.disabled = false; btn.textContent = 'Subscribe'; }
              alert(errMsg);
            }
          );
        });
      });
    });
  })();
</script>

📝 嵌入步骤

1

进入 Shopify 页面编辑器

Online Store → Pages → 编辑你的落地页

2

添加 Custom Liquid 区块,粘贴 JS 钩子

点击 "Add block" → 选择 "Custom Liquid",将上方 JS 代码粘贴进去

3

自主设计表单样式并绑定

在你的 HTML 表单添加 data-pm-subscribe 属性即可自动绑定;或直接调用 pmSubscribe() 全局函数

✅ 确认页面说明

确认成功 (status: confirmed)

用户点击确认邮件中的链接后,会看到绿色品牌化页面:白色卡片居中显示,绿色圆形勾选图标,标题 "You're Confirmed!",正文显示用户姓名和邮箱,底部有"Back to Plantsrobot"按钮返回落地页。

已确认过 (already confirmed)

如果用户重复点击确认链接,会看到橙色信息图标页面,提示"Your email is already confirmed",不会报错也不会重复创建序列。

链接过期 (expired)

如果 Token 无效或已过期,显示红色错误页面,提示用户重新订阅以获取新的确认邮件。

确认流程:用户点击邮件中的确认链接 → Supabase Edge Function (confirm v9) 验证 Token 并更新订阅状态 → 302 重定向到 Shopify 托管的确认页面 (plantsrobot.com/pages/confirm),显示品牌化页面。你需要在 Shopify 创建一个名为"confirm"的页面,粘贴 confirm-page.html 的内容。品牌标识:Plantsrobot 绿色主题 + Smart Plant Care 标语。

🔗 关键链接

Subscribe https://evhoszenrrqbhgltvyqv.supabase.co/functions/v1/subscribe
Confirm https://evhoszenrrqbhgltvyqv.supabase.co/functions/v1/confirm?token=xxx
Unsubscribe https://evhoszenrrqbhgltvyqv.supabase.co/functions/v1/unsubscribe?id=xxx
Dashboard https://supabase.com/dashboard/project/evhoszenrrqbhgltvyqv

🧪 API 测试工具

快速测试订阅 API 是否正常工作

邮件预览

'; } else { fullDoc = '' + displayHtml + ' '; } iframe.srcdoc = fullDoc; iframe.style.height = '300px'; var modal = document.getElementById('preview-modal'); var overlay = document.getElementById('preview-overlay'); modal.className = 'preview-modal ' + this._mode; overlay.classList.add('show'); document.getElementById('preview-btn-desktop').classList.toggle('active', this._mode === 'desktop'); document.getElementById('preview-btn-mobile').classList.toggle('active', this._mode === 'mobile'); // Auto-resize var self = this; setTimeout(function() { try { var iframeDoc = iframe.contentDocument || iframe.contentWindow.document; if (iframeDoc && iframeDoc.body) { var body = iframeDoc.body; var html = iframeDoc.documentElement; var h = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); iframe.style.height = Math.min(Math.max(h + 40, 300), window.innerHeight * 0.75) + 'px'; } } catch(e) {} }, 300); }, switchMode(mode) { this._mode = mode; document.getElementById('preview-modal').className = 'preview-modal ' + mode; document.getElementById('preview-btn-desktop').classList.toggle('active', mode === 'desktop'); document.getElementById('preview-btn-mobile').classList.toggle('active', mode === 'mobile'); }, close() { document.getElementById('preview-overlay').classList.remove('show'); } }; // Click overlay background to close preview document.addEventListener('click', function(e) { if (e.target.id === 'preview-overlay') Preview.close(); }); // ================================================================ // MCP Layer 1: Sidebar — Navigation Sidebar // ================================================================ const Sidebar = { _menuItems: [ { section: '核心功能', items: [ { page: 'dashboard', label: '仪表盘', icon: '' }, { page: 'editor', label: '模板编辑器', icon: '' }, { page: 'campaign', label: '群发活动', icon: '' } ]}, { section: '数据分析', items: [ { page: 'analytics', label: '数据分析', icon: '' }, { page: 'abtest', label: 'A/B 测试', icon: '' } ]}, { section: '受众管理', items: [ { page: 'subscribers', label: '订阅者管理', icon: '' } ]}, { section: '设置', items: [ { page: 'guide', label: '使用指南', icon: '' } ]} ], init() { var nav = document.getElementById('sidebar-nav'); if (!nav) return; var html = ''; var self = this; this._menuItems.forEach(function(section) { html += ''; }); nav.innerHTML = html; } }; console.log('[MCP] Core modules initialized: APP_CONFIG, EventBus, Store, Router, Notify, Modal, Preview, Sidebar'); // ======================== // 动态加载 Supabase SDK(兼容 Shopify 环境) // Shopify Custom Liquid 只渲染 body,需动态注入 head 脚本 // ======================== (function() { var SCRIPTS = [ 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2' ]; // 注入缺失的脚本 SCRIPTS.forEach(function(src) { if (!document.querySelector('script[src="' + src + '"]')) { var s = document.createElement('script'); s.src = src; document.head.appendChild(s); } }); // 轮询等待 window.supabase 可用,最多等 10 秒 var maxWait = 100; // 100 * 100ms = 10s var attempts = 0; function waitForSdk() { attempts++; if (window.supabase) { // SDK 就绪,启动应用 bootstrapApp(); return; } if (attempts < maxWait) { setTimeout(waitForSdk, 100); } else { // 超时:显示错误提示 var overlay = document.getElementById('login-overlay'); var errEl = document.getElementById('login-error'); if (overlay && errEl) { overlay.classList.remove('hidden'); errEl.textContent = 'SDK 加载超时,请检查网络后刷新页面'; errEl.classList.add('show'); } } } waitForSdk(); })(); // ======================== // Login Module — 邮箱验证码登录 // ======================== (function() { var ADMIN_EMAIL = 'socialmedia@plantsrobot.com'; var SESSION_KEY = 'plantsrobot_admin_session'; var CODE_KEY = 'plantsrobot_admin_code'; var CODE_EXPIRY_KEY = 'plantsrobot_admin_code_expiry'; var CODE_LENGTH = 6; var CODE_EXPIRE_SEC = 300; // 验证码 5 分钟有效 var COOLDOWN_SEC = 60; // 60 秒后才能重新发送 var countdownTimer = null; function checkSession() { try { return sessionStorage.getItem(SESSION_KEY) === '1'; } catch(e) { return false; } } function setSession() { try { sessionStorage.setItem(SESSION_KEY, '1'); } catch(e) {} } function generateCode() { var code = ''; for (var i = 0; i < CODE_LENGTH; i++) { code += Math.floor(Math.random() * 10).toString(); } return code; } function storeCode(code) { try { sessionStorage.setItem(CODE_KEY, code); sessionStorage.setItem(CODE_EXPIRY_KEY, Date.now() + CODE_EXPIRE_SEC * 1000); } catch(e) {} } function getStoredCode() { try { var expiry = parseInt(sessionStorage.getItem(CODE_EXPIRY_KEY)); if (Date.now() > expiry) return null; // 已过期 return sessionStorage.getItem(CODE_KEY); } catch(e) { return null; } } function clearStoredCode() { try { sessionStorage.removeItem(CODE_KEY); sessionStorage.removeItem(CODE_EXPIRY_KEY); } catch(e) {} } // ===== 发送验证码 ===== window.handleSendCode = async function() { var email = document.getElementById('login-email').value.trim(); var errorEl = document.getElementById('login-error'); var successEl = document.getElementById('login-success'); var btn = document.getElementById('btn-send-code'); // 隐藏之前的提示 errorEl.classList.remove('show'); successEl.classList.remove('show'); // 验证邮箱 if (!email) { errorEl.textContent = '请输入管理员邮箱'; errorEl.classList.add('show'); return; } var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { errorEl.textContent = '请输入有效的邮箱地址'; errorEl.classList.add('show'); return; } if (email.toLowerCase() !== ADMIN_EMAIL.toLowerCase()) { errorEl.textContent = '该邮箱不是管理员邮箱'; errorEl.classList.add('show'); return; } // 生成验证码 var code = generateCode(); storeCode(code); // 发送验证码邮件 btn.disabled = true; btn.textContent = '正在发送...'; try { // 1. 查询管理员邮箱在 subscribers 表中的 ID var subId = null; try { var { data: subData, error: subErr } = await getDb().from('subscribers') .select('id') .eq('email', email.toLowerCase()) .maybeSingle(); if (!subErr && subData) { subId = subData.id; } } catch (e) { // 查询失败,继续尝试注册 console.warn('Query subscriber error:', e); } // 2. 如果管理员不在 subscribers 表中,先注册 if (!subId) { try { var regResp = await fetch(SUPABASE_URL + '/functions/v1/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, first_name: 'Admin', tags: [], email_mode: 'thank_you' }) }); var regData = await regResp.json(); // subscribe 接口返回 { success: true, subscriber: { id: ... } } if (regData.subscriber && regData.subscriber.id) { subId = regData.subscriber.id; } else if (regData.id) { subId = regData.id; } console.log('Subscribe response:', regData); if (!subId && regData.error) { throw new Error(regData.error); } } catch (e) { console.warn('Subscribe via Edge Function failed, trying direct insert:', e); } } // 2b. 如果 Edge Function 注册失败,尝试直接插入 subscribers 表 if (!subId) { try { var now = new Date().toISOString(); var { data: insData, error: insErr } = await getDb().from('subscribers') .upsert({ email: email.toLowerCase(), first_name: 'Admin', status: 'confirmed', created_at: now, updated_at: now }, { onConflict: 'email' }) .select('id') .single(); if (insErr) throw new Error(insErr.message); if (insData && insData.id) { subId = insData.id; console.log('Direct insert success, subId:', subId); } } catch (e) { console.warn('Direct insert failed:', e); } } if (!subId) { throw new Error('无法获取管理员订阅者 ID,请先在系统中添加该邮箱'); } // 3. 发送验证码邮件 var resp = await fetch(SUPABASE_URL + '/functions/v1/send-batch', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + SUPABASE_KEY }, body: JSON.stringify({ subject: 'Plantsrobot 管理后台 — 登录验证码', from_name: 'Plantsrobot Admin', html_body: '
' + '

🌱 Plantsrobot

' + '

您的登录验证码是:

' + '
' + '' + code + '' + '
' + '

验证码 ' + CODE_EXPIRE_SEC / 60 + ' 分钟内有效。如非本人操作,请忽略此邮件。

' + '

Plantsrobot Email Admin Panel

' + '
', text_body: 'Plantsrobot 管理后台登录验证码:' + code + '(' + CODE_EXPIRE_SEC / 60 + ' 分钟内有效)', target: { subscriber_ids: [subId] }, reply_to: '' }) }); var data = await resp.json(); if (data.sent !== undefined) { successEl.textContent = '验证码已发送至 ' + email + ',请查收邮件'; successEl.classList.add('show'); showCodeSection(); } else { errorEl.textContent = '发送失败: ' + (data.error || '未知错误'); errorEl.classList.add('show'); btn.disabled = false; btn.textContent = '发送验证码'; } } catch (e) { errorEl.textContent = '发送失败: ' + e.message; errorEl.classList.add('show'); btn.disabled = false; btn.textContent = '发送验证码'; } }; // ===== 显示验证码输入区 ===== function showCodeSection() { document.getElementById('code-section').style.display = 'block'; document.getElementById('btn-send-code').style.display = 'none'; document.getElementById('login-code').focus(); startCooldown(); } // ===== 倒计时(重新发送) ===== function startCooldown() { var remaining = COOLDOWN_SEC; var countdownEl = document.getElementById('code-countdown'); countdownEl.classList.add('active'); function tick() { if (remaining <= 0) { countdownEl.textContent = '可重新发送'; countdownEl.classList.remove('active'); countdownEl.style.cursor = 'pointer'; countdownEl.onclick = function() { document.getElementById('code-section').style.display = 'none'; document.getElementById('btn-send-code').style.display = 'block'; document.getElementById('btn-send-code').disabled = false; document.getElementById('btn-send-code').textContent = '重新发送验证码'; document.getElementById('login-code').value = ''; countdownEl.onclick = null; countdownEl.style.cursor = ''; }; return; } var min = Math.floor(remaining / 60); var sec = remaining % 60; countdownEl.textContent = min + ':' + (sec < 10 ? '0' : '') + sec; remaining--; countdownTimer = setTimeout(tick, 1000); } if (countdownTimer) clearTimeout(countdownTimer); tick(); } // ===== 验证验证码 ===== window.handleVerifyCode = function() { var inputCode = document.getElementById('login-code').value.trim(); var errorEl = document.getElementById('login-error'); var successEl = document.getElementById('login-success'); errorEl.classList.remove('show'); successEl.classList.remove('show'); if (!inputCode || inputCode.length !== CODE_LENGTH) { errorEl.textContent = '请输入 6 位验证码'; errorEl.classList.add('show'); return; } var storedCode = getStoredCode(); if (!storedCode) { errorEl.textContent = '验证码已过期,请重新发送'; errorEl.classList.add('show'); // 恢复发送按钮 document.getElementById('code-section').style.display = 'none'; document.getElementById('btn-send-code').style.display = 'block'; document.getElementById('btn-send-code').disabled = false; document.getElementById('btn-send-code').textContent = '重新发送验证码'; document.getElementById('login-code').value = ''; return; } if (inputCode === storedCode) { clearStoredCode(); setSession(); document.getElementById('login-overlay').classList.add('hidden'); initApp(); } else { errorEl.textContent = '验证码错误,请重新输入'; errorEl.classList.add('show'); document.getElementById('login-code').value = ''; } }; // 回车键:在验证码阶段提交验证,在邮箱阶段发送验证码 document.addEventListener('keydown', function(e) { if (e.key !== 'Enter') return; if (document.getElementById('login-overlay').classList.contains('hidden')) return; e.preventDefault(); var codeSection = document.getElementById('code-section'); if (codeSection.style.display !== 'none') { handleVerifyCode(); } else { handleSendCode(); } }); // bootstrapApp 由 SDK 加载完成后调用 window._bootstrapReady = true; })(); // SDK 加载完成后执行 function bootstrapApp() { if (window._bootstrapped) return; window._bootstrapped = true; // 如果已登录,直接进入 try { if (sessionStorage.getItem('plantsrobot_admin_session') === '1') { document.getElementById('login-overlay').classList.add('hidden'); initApp(); } } catch(e) { // sessionStorage 不可用,显示登录框 } } // ======================== // Supabase Init & Globals // ======================== const SUPABASE_URL = 'https://evhoszenrrqbhgltvyqv.supabase.co'; const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV2aG9zemVucnJxYmhnbHR2eXF2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODI0NDMwNDQsImV4cCI6MjA5ODAxOTA0NH0.6QMok7_tJs_hdo6QVYdK0FxRO5T5_4_D5Yz-IwfLCAI'; // 延迟初始化 Supabase 客户端(等待 CDN 脚本加载完成) let db = null; function getDb() { if (db) return db; if (!window.supabase) { throw new Error('SDK 加载中,请稍后重试'); } try { db = window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY); return db; } catch (e) { throw new Error('Supabase 初始化失败: ' + e.message); } } let currentPage = 'dashboard'; let currentEditingStep = null; let allSteps = []; let subscriberPage = 1; const SUBSCRIBER_PAGE_SIZE = 20; let subscriberDebounceTimer = null; // ======================== // Utility Functions // ======================== function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); const bgColor = type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600'; const icon = type === 'success' ? '' : type === 'error' ? '' : ''; toast.className = `toast-enter flex items-center gap-3 ${bgColor} text-white px-5 py-3 rounded-lg shadow-lg text-sm max-w-sm`; toast.innerHTML = `${icon}${message}`; container.appendChild(toast); setTimeout(() => { toast.classList.remove('toast-enter'); toast.classList.add('toast-exit'); setTimeout(() => toast.remove(), 300); }, 3500); } function formatDate(dateStr) { if (!dateStr) return '--'; const d = new Date(dateStr); return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function stripHtml(html) { const tmp = document.createElement('div'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function renderEmptyState(title, description, icon) { var defaultIcon = ''; var iconPath = icon || defaultIcon; return '
' + '' + iconPath + '' + '

' + title + '

' + '

' + description + '

'; } function renderErrorState(message, retryFn) { return '
' + '

' + message + '

' + '
'; } function getStatusBadge(status) { const map = { 'pending': '待确认', 'confirmed': '已确认', 'unsubscribed': '已退订', 'bounced': '已退回', 'draft': '草稿', 'scheduled': '已计划', 'sending': '发送中', 'sent': '已发送', 'cancelled': '已取消', }; return map[status] || `${escapeHtml(status)}`; } function getEventTypeBadge(eventType) { const map = { 'sent': '已发送', 'delivered': '已送达', 'opened': '已打开', 'clicked': '已点击', 'bounced_soft': '软退回', 'bounced_hard': '硬退回', }; return map[eventType] || `${escapeHtml(eventType)}`; } function initSummernoteEditor(elementId, existingHtml) { var $el = $('#' + elementId); if (!$el.length) return null; // Destroy previous instance if exists try { $el.summernote('destroy'); } catch (e) {} $el.summernote({ height: 400, toolbar: [ ['style', ['style']], ['font', ['bold', 'italic', 'underline', 'strikethrough', 'clear']], ['color', ['color']], ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], ['insert', ['link', 'picture']], ['view', ['codeview']] ], placeholder: '在此编写邮件内容...', // Allow ,自动补全闭合标签 var hasStyleOpen = //i.test(cleanHtml); if (hasStyleOpen && !hasStyleClose) { cleanHtml = cleanHtml + '\n'; } // 3. 如果有 '; } else { // Plain body HTML — wrap with default email styles fullDoc = '' + displayHtml + ' '; } // Use srcdoc for cleaner rendering (avoids document.open/write issues) iframe.srcdoc = fullDoc; // Auto-resize iframe height to fit content after render setTimeout(function() { try { var iframeDoc = iframe.contentDocument || iframe.contentWindow.document; if (!iframeDoc || !iframeDoc.body) return; var body = iframeDoc.body; var html = iframeDoc.documentElement; var h = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); iframe.style.height = Math.min(Math.max(h + 40, 300), window.innerHeight * 0.75) + 'px'; } catch(e) { console.warn('[Preview] iframe resize error:', e); } }, 300); } function openPreviewOverlay(mode) { currentPreviewMode = mode || 'desktop'; var overlay = document.getElementById('preview-overlay'); var modal = document.getElementById('preview-modal'); modal.className = 'preview-modal ' + currentPreviewMode; overlay.classList.add('show'); // Update toggle buttons document.getElementById('preview-btn-desktop').classList.toggle('active', currentPreviewMode === 'desktop'); document.getElementById('preview-btn-mobile').classList.toggle('active', currentPreviewMode === 'mobile'); } function switchPreviewMode(mode) { currentPreviewMode = mode; var modal = document.getElementById('preview-modal'); modal.className = 'preview-modal ' + mode; document.getElementById('preview-btn-desktop').classList.toggle('active', mode === 'desktop'); document.getElementById('preview-btn-mobile').classList.toggle('active', mode === 'mobile'); } function closePreview() { document.getElementById('preview-overlay').classList.remove('show'); } // Click overlay background to close document.addEventListener('click', function(e) { if (e.target.id === 'preview-overlay') { closePreview(); } }); // Keep backward compatibility function previewCampaign() { openCampaignPreview('desktop'); } function closePreviewModal() { closePreview(); } function showConfirmModal(title, message, onConfirm) { document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-message').textContent = message; document.getElementById('confirm-modal').classList.remove('hidden'); document.getElementById('confirm-action-btn').onclick = () => { closeConfirmModal(); onConfirm(); }; } function closeConfirmModal() { document.getElementById('confirm-modal').classList.add('hidden'); } async function sendCampaign() { const name = document.getElementById('campaign-name').value.trim(); const subject = document.getElementById('campaign-subject').value.trim(); const fromName = document.getElementById('campaign-from-name').value.trim(); if (!name || !subject) { showToast('请填写活动名称和邮件主题', 'error'); return; } var $campaignEl = $('#campaign-quill'); if (!$campaignEl.length || !$campaignEl.data('summernote')) return; const htmlBody = $campaignEl.summernote('code'); if (!htmlBody || htmlBody === '


') { showToast('请编写邮件内容', 'error'); return; } const textBody = stripHtml(htmlBody); const targetRadio = document.querySelector('input[name="campaign-target"]:checked').value; let target = 'all_confirmed'; if (targetRadio === 'tag') { const checkedTags = [...document.querySelectorAll('.campaign-tag-cb:checked')].map(cb => cb.value); if (checkedTags.length === 0) { showToast('请至少选择一个标签', 'error'); return; } target = { tags: checkedTags }; } else if (targetRadio === 'group') { const checkedGroups = [...document.querySelectorAll('.campaign-group-cb:checked')].map(cb => cb.value); if (checkedGroups.length === 0) { showToast('请至少选择一个分组', 'error'); return; } target = { groups: checkedGroups }; } // Count target subscribers for confirmation let countQuery = getDb().from('subscribers').select('id', { count: 'exact', head: true }).eq('status', 'confirmed'); if (typeof target === 'object') { let tagFilter = []; if (target.tags) { tagFilter = target.tags; } else if (target.groups) { tagFilter = target.groups.map(g => 'group:' + g); } const tagResult = await getDb().from('subscriber_tags').select('subscriber_id').in('tag', tagFilter); const subIds = (tagResult.data || []).map(t => t.subscriber_id); if (subIds.length === 0) { showToast('所选条件没有已确认的订阅者', 'error'); return; } countQuery = getDb().from('subscribers') .select('id', { count: 'exact', head: true }) .eq('status', 'confirmed') .in('id', subIds); } const { count } = await countQuery; const subscriberCount = count !== null ? count : 0; if (subscriberCount === 0) { showToast('当前没有已确认的订阅者,无法发送。请先在「订阅者管理」中确认订阅者。', 'error'); return; } const scheduleType = document.querySelector('input[name="campaign-schedule"]:checked').value; const scheduledAt = scheduleType === 'scheduled' ? document.getElementById('campaign-scheduled-at').value : null; const isImmediate = scheduleType === 'now'; var confirmMsg = isImmediate ? `即将发送 "${subject}" 给 ${subscriberCount} 位已确认订阅者。确定要继续吗?` : `将在 ${formatDate(scheduledAt)} 发送 "${subject}" 给 ${subscriberCount} 位已确认订阅者。确定要计划吗?`; showConfirmModal( isImmediate ? '确认发送群发邮件' : '确认计划群发邮件', confirmMsg, async () => { const btn = document.getElementById('btn-send-campaign'); btn.disabled = true; btn.innerHTML = '
' + (isImmediate ? '发送中...' : '计划中...'); try { // 1. Insert campaign record const campaignStatus = isImmediate ? 'sending' : 'scheduled'; const { data: campaign, error: campErr } = await getDb().from('newsletter_campaigns') .insert({ name, subject, html_body: htmlBody, text_body: textBody, status: campaignStatus, sent_count: 0, tag_filter: (typeof target === 'object' && (target.tags || target.groups)) ? (target.tags ? target.tags : target.groups.map(g => 'group:' + g)) : null }) .select() .single(); if (campErr) throw campErr; if (!isImmediate) { showToast(`群发活动已计划,将于 ${formatDate(scheduledAt)} 发送`); // Reset form document.getElementById('campaign-name').value = ''; document.getElementById('campaign-subject').value = ''; $('#campaign-quill').summernote('code', ''); await loadCampaignHistory(); return; } // 2. Call send-batch edge function (immediate only) let sentCount = 0; let errorCount = 0; try { const response = await fetch(SUPABASE_URL + '/functions/v1/send-batch', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + SUPABASE_KEY }, body: JSON.stringify({ campaign_id: campaign.id, subject, from_name: fromName, html_body: htmlBody, text_body: textBody, target }) }); if (response.ok) { const result = await response.json(); sentCount = result.sent || 0; errorCount = result.errors || 0; } else { const errText = await response.text(); console.error('send-batch error:', errText); showToast('群发任务已创建,但发送可能未完成。请检查发送历史。', 'error'); } } catch (fetchErr) { console.error('Edge function call failed:', fetchErr); showToast('群发任务已创建,但无法连接发送服务。请检查发送历史。', 'error'); } if (sentCount > 0) { showToast(`发送完成!成功 ${sentCount} 封${errorCount > 0 ? ',失败 ' + errorCount + ' 封' : ''}`); } else if (errorCount === 0) { showToast('群发任务已创建并提交发送!'); } // Reset form document.getElementById('campaign-name').value = ''; document.getElementById('campaign-subject').value = ''; $('#campaign-quill').summernote('code', ''); await loadCampaignHistory(); } catch (e) { console.error('Send campaign error:', e); showToast('发送失败: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = ' 发送群发'; } } ); } async function loadCampaignHistory() { const container = document.getElementById('campaign-history-container'); try { const { data, error } = await getDb().from('newsletter_campaigns') .select('*') .order('created_at', { ascending: false }) .limit(50); if (error) throw error; if (!data || data.length === 0) { container.innerHTML = renderEmptyState('暂无发送历史记录', '创建并发送群发活动后,这里将显示发送历史'); return; } let html = `
`; data.forEach(c => { html += ``; }); html += '
活动名称 主题 状态 发送数 创建时间 发送时间
${escapeHtml(c.name || '--')} ${escapeHtml(c.subject || '--')} ${getStatusBadge(c.status)} ${c.sent_count || 0} ${formatDate(c.created_at)} ${formatDate(c.sent_at)}
'; container.innerHTML = html; } catch (e) { console.error('Campaign history error:', e); container.innerHTML = renderErrorState('发送历史加载失败', 'loadCampaignHistory()'); } } // ======================== // Group Management // ======================== async function loadGroupFilter() { const select = document.getElementById('subscriber-group-filter'); try { const { data, error } = await getDb().from('subscriber_tags').select('tag').like('tag', 'group:%'); if (error) throw error; const groups = [...new Set((data || []).map(t => t.tag.replace('group:', '')))].sort(); select.innerHTML = '' + groups.map(g => ``).join(''); } catch (e) { console.error('Load groups error:', e); } } function showGroupManageModal() { loadGroupList().then(groups => { const html = `

分组管理

${groups.length === 0 ? '

暂无分组

' : groups.map(g => `
${escapeHtml(g)}
`).join('')}
`; document.body.insertAdjacentHTML('beforeend', html); }); } async function loadGroupList() { try { const { data, error } = await getDb().from('subscriber_tags').select('tag').like('tag', 'group:%'); if (error) throw error; const dbGroups = [...new Set((data || []).map(t => t.tag.replace('group:', '')))]; // Also include groups created but not yet assigned to any subscriber (stored in localStorage) const stored = getPendingGroups(); return [...new Set([...dbGroups, ...stored])].sort(); } catch (e) { console.error('Load group list error:', e); return getPendingGroups(); } } function getPendingGroups() { try { const raw = localStorage.getItem('pr_pending_groups'); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function savePendingGroups(groups) { localStorage.setItem('pr_pending_groups', JSON.stringify(groups)); } function addPendingGroup(name) { const groups = getPendingGroups(); if (!groups.includes(name)) { groups.push(name); savePendingGroups(groups); } } function removePendingGroup(name) { const groups = getPendingGroups().filter(g => g !== name); savePendingGroups(groups); } async function createGroup() { const name = document.getElementById('new-group-name').value.trim(); if (!name) { showToast('请输入分组名称', 'error'); return; } // Store group in localStorage as pending (until a subscriber is assigned) addPendingGroup(name); showToast('分组"' + name + '"已创建,可在订阅者中分配使用'); const modal = document.getElementById('group-manage-modal'); if (modal) modal.remove(); showGroupManageModal(); loadGroupFilter(); } async function deleteGroup(groupName) { try { const { error } = await getDb().from('subscriber_tags').delete().eq('tag', 'group:' + groupName); if (error) throw error; removePendingGroup(groupName); showToast('分组"' + groupName + '"已删除'); const modal = document.getElementById('group-manage-modal'); if (modal) modal.remove(); showGroupManageModal(); loadGroupFilter(); if (currentPage === 'subscribers') loadSubscribers(subscriberPage); } catch (e) { showToast('删除失败: ' + e.message, 'error'); } } function showSubscriberGroupModal(subId, email, groupsJson) { const decodedJson = groupsJson.replace(/"/g, '"'); const currentGroups = JSON.parse(decodedJson); loadGroupList().then(allGroups => { const html = `

管理分组 - ${escapeHtml(email)}

勾选或取消勾选来管理此订阅者的分组

${allGroups.length === 0 ? '

暂无分组,请先在"分组管理"中创建

' : allGroups.map(g => { const checked = currentGroups.includes(g); return ``; }).join('')}
`; document.body.insertAdjacentHTML('beforeend', html); }); } async function saveSubscriberGroups(subId) { const checked = [...document.querySelectorAll('.sub-group-cb:checked')].map(cb => cb.value); try { // Delete all existing group tags for this subscriber await getDb().from('subscriber_tags').delete().eq('subscriber_id', subId).like('tag', 'group:%'); // Insert new group tags if (checked.length > 0) { const inserts = checked.map(groupName => ({ subscriber_id: subId, tag: 'group:' + groupName })); const { error } = await getDb().from('subscriber_tags').insert(inserts); if (error) throw error; } showToast('分组已更新'); document.getElementById('sub-group-modal').remove(); await loadSubscribers(subscriberPage); loadGroupFilter(); } catch (e) { showToast('保存分组失败: ' + e.message, 'error'); } } // ======================== // Subscriber Management // ======================== async function loadSubscribers(page = 1) { subscriberPage = page; const container = document.getElementById('subscribers-table-container'); const search = document.getElementById('subscriber-search').value.trim(); const statusFilter = document.getElementById('subscriber-status-filter').value; const groupFilter = document.getElementById('subscriber-group-filter').value; container.innerHTML = '
'; try { // If group filter active, get subscriber IDs with that group tag first let groupSubIds = null; if (groupFilter !== 'all') { const { data: groupSubs } = await getDb().from('subscriber_tags').select('subscriber_id').eq('tag', 'group:' + groupFilter); groupSubIds = (groupSubs || []).map(t => t.subscriber_id); if (groupSubIds.length === 0) { container.innerHTML = renderEmptyState('该分组下暂无订阅者', '尝试切换到其他分组或查看全部订阅者'); document.getElementById('subscriber-count').textContent = ''; document.getElementById('subscriber-pagination').classList.add('hidden'); return; } } let query = getDb().from('subscribers').select('*', { count: 'exact' }); if (search) { // Escape ilike wildcard characters to avoid unintended pattern matching const escaped = search.replace(/[%_]/g, '\\$&'); query = query.or(`email.ilike.%${escaped}%,first_name.ilike.%${escaped}%,last_name.ilike.%${escaped}%`); } if (statusFilter !== 'all') { query = query.eq('status', statusFilter); } if (groupSubIds) { query = query.in('id', groupSubIds); } query = query.order('created_at', { ascending: false }) .range((page - 1) * SUBSCRIBER_PAGE_SIZE, page * SUBSCRIBER_PAGE_SIZE - 1); const { data, error, count } = await query; if (error) throw error; // Update count display const totalText = count !== null ? `共 ${count} 位订阅者` : ''; document.getElementById('subscriber-count').textContent = totalText; // Pagination const pagination = document.getElementById('subscriber-pagination'); pagination.classList.remove('hidden'); const totalPages = Math.ceil((count || 0) / SUBSCRIBER_PAGE_SIZE); document.getElementById('subscriber-page-info').textContent = `第 ${page} / ${Math.max(totalPages, 1)} 页`; document.getElementById('btn-prev-page').disabled = page <= 1; document.getElementById('btn-next-page').disabled = page >= totalPages; if (!data || data.length === 0) { container.innerHTML = renderEmptyState('未找到订阅者', '尝试调整搜索条件或筛选器,或导入新的订阅者'); return; } // Fetch all group: tags for subscribers const { data: allGroupTags } = await getDb().from('subscriber_tags').select('subscriber_id, tag').like('tag', 'group:%'); const groupMap = {}; (allGroupTags || []).forEach(t => { if (!groupMap[t.subscriber_id]) groupMap[t.subscriber_id] = []; groupMap[t.subscriber_id].push(t.tag.replace('group:', '')); }); let html = `
`; data.forEach(sub => { const name = ((sub.first_name || '') + ' ' + (sub.last_name || '')).trim() || '--'; const groups = groupMap[sub.id] || []; const groupsHtml = groups.length > 0 ? groups.map(g => `${escapeHtml(g)}`).join('') : '--'; html += ``; }); html += '
邮箱 姓名 状态 分组 注册时间 确认时间 操作
${escapeHtml(sub.email || '--')} ${escapeHtml(name)} ${getStatusBadge(sub.status)} ${groupsHtml} ${formatDate(sub.created_at)} ${formatDate(sub.confirmed_at)}
`; const escapedEmail = (sub.email || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const escapedGroups = JSON.stringify(groups).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '"'); if (sub.status === 'confirmed') { html += ``; } if (sub.status !== 'confirmed') { html += ``; } if (sub.status !== 'unsubscribed') { html += ``; } html += ``; html += ``; html += `
'; container.innerHTML = html; } catch (e) { console.error('Load subscribers error:', e); container.innerHTML = renderErrorState('订阅者列表加载失败', 'loadSubscribers()'); } } function debounceLoadSubscribers() { clearTimeout(subscriberDebounceTimer); subscriberDebounceTimer = setTimeout(() => loadSubscribers(1), 400); } async function confirmSubscriber(id) { try { const { error } = await getDb().from('subscribers') .update({ status: 'confirmed', confirmed_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .eq('id', id); if (error) throw error; showToast('订阅者已确认'); loadSubscribers(subscriberPage); } catch (e) { showToast('操作失败: ' + e.message, 'error'); } } async function unsubscribeSubscriber(id) { try { const { error } = await getDb().from('subscribers') .update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .eq('id', id); if (error) throw error; showToast('订阅者已退订'); loadSubscribers(subscriberPage); } catch (e) { showToast('操作失败: ' + e.message, 'error'); } } function deleteSubscriber(id, email) { showConfirmModal( '确认删除订阅者', `确定要删除订阅者 "${email}" 吗?此操作不可撤销。`, async () => { try { const { error } = await getDb().from('subscribers').delete().eq('id', id); if (error) throw error; showToast('订阅者已删除'); loadSubscribers(subscriberPage); } catch (e) { showToast('删除失败: ' + e.message, 'error'); } } ); } // ======================== // Batch Operations // ======================== function toggleSelectAllSubs(checkbox) { document.querySelectorAll('.sub-select-cb').forEach(cb => { cb.checked = checkbox.checked; }); updateBatchButtons(); } function updateBatchButtons() { var checked = document.querySelectorAll('.sub-select-cb:checked').length; document.getElementById('btn-batch-confirm').classList.toggle('hidden', checked === 0); document.getElementById('btn-batch-delete').classList.toggle('hidden', checked === 0); } function getSelectedSubIds() { return [...document.querySelectorAll('.sub-select-cb:checked')].map(cb => cb.value); } async function batchConfirmSubscribers() { var ids = getSelectedSubIds(); if (ids.length === 0) { showToast('请先选择订阅者', 'error'); return; } showConfirmModal('批量确认', `确定要批量确认 ${ids.length} 位订阅者吗?`, async () => { try { var { error } = await getDb().from('subscribers') .update({ status: 'confirmed', confirmed_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .in('id', ids); if (error) throw error; showToast(`已确认 ${ids.length} 位订阅者`); loadSubscribers(subscriberPage); } catch (e) { showToast('批量确认失败: ' + e.message, 'error'); } }); } async function batchDeleteSubscribers() { var ids = getSelectedSubIds(); if (ids.length === 0) { showToast('请先选择订阅者', 'error'); return; } showConfirmModal('批量删除', `确定要永久删除 ${ids.length} 位订阅者吗?此操作不可撤销。`, async () => { try { var { error } = await getDb().from('subscribers').delete().in('id', ids); if (error) throw error; showToast(`已删除 ${ids.length} 位订阅者`); loadSubscribers(subscriberPage); } catch (e) { showToast('批量删除失败: ' + e.message, 'error'); } }); } // ======================== // Subscriber Detail Modal // ======================== async function showSubscriberDetail(subId) { try { var { data: sub, error } = await getDb().from('subscribers').select('*').eq('id', subId).single(); if (error) throw error; if (!sub) { showToast('订阅者不存在', 'error'); return; } // Get groups and tags var { data: tags } = await getDb().from('subscriber_tags').select('tag').eq('subscriber_id', subId); var allTags = (tags || []).map(t => t.tag); var groups = allTags.filter(t => t.startsWith('group:')).map(t => t.replace('group:', '')); var plainTags = allTags.filter(t => !t.startsWith('group:')); // Get recent events var { data: events } = await getDb().from('email_events').select('*').eq('subscriber_id', subId).order('created_at', { ascending: false }).limit(10); var eventsHtml = (events && events.length > 0) ? events.map(ev => `
${getEventTypeBadge(ev.event_type)} ${formatDate(ev.created_at)}
`).join('') : '

暂无事件记录

'; var html = `

订阅者详情

邮箱

${escapeHtml(sub.email || '--')}

姓名

${escapeHtml(((sub.first_name || '') + ' ' + (sub.last_name || '')).trim() || '--')}

状态

${getStatusBadge(sub.status)}

ID

${escapeHtml(sub.id)}

创建时间

${formatDate(sub.created_at)}

确认时间

${formatDate(sub.confirmed_at)}

${groups.length > 0 ? `

分组

${groups.map(g => '' + escapeHtml(g) + '').join('')}
` : ''} ${plainTags.length > 0 ? `

标签

${plainTags.map(t => '' + escapeHtml(t) + '').join('')}
` : ''}

最近事件 (最近10条)

${eventsHtml}
`; document.body.insertAdjacentHTML('beforeend', html); } catch (e) { showToast('加载详情失败: ' + e.message, 'error'); } } // ======================== // Tag Management // ======================== function showTagManageModal() { loadTagList().then(tags => { var html = `

标签管理

${tags.length === 0 ? '

暂无标签

' : tags.map(t => `
${escapeHtml(t)}
`).join('')}
`; document.body.insertAdjacentHTML('beforeend', html); }); } async function loadTagList() { try { var { data, error } = await getDb().from('subscriber_tags').select('tag'); if (error) throw error; return [...new Set((data || []).map(t => t.tag).filter(t => t && !t.startsWith('group:')))].sort(); } catch (e) { console.error('Load tags error:', e); return []; } } function createTag() { var name = document.getElementById('new-tag-name').value.trim(); if (!name) { showToast('请输入标签名称', 'error'); return; } // Tags are created when assigned to subscribers. Just add to pending list for now. showToast('标签"' + name + '"已就绪,可在订阅者中分配使用(通过导入CSV或手动添加)'); document.getElementById('tag-manage-modal').remove(); } async function deleteTag(tagName) { try { var { error } = await getDb().from('subscriber_tags').delete().eq('tag', tagName); if (error) throw error; showToast('标签"' + tagName + '"已删除'); document.getElementById('tag-manage-modal').remove(); showTagManageModal(); } catch (e) { showToast('删除失败: ' + e.message, 'error'); } } // ======================== // Import / Export // ======================== function showImportModal() { const html = `

导入订阅者

支持 CSV 格式导入,表头需包含: email,first_name,last_name,group

`; document.body.insertAdjacentHTML('beforeend', html); } function downloadCsvTemplate() { var csvContent = 'email,first_name,last_name,group\n' + 'socialmedia@plantsrobot.com,Admin,,VIP\n' + 'user1@example.com,John,Doe,Newsletter\n' + 'user2@example.com,Jane,Smith,Newsletter;VIP\n' + 'user3@example.com,Bob,Johnson,Product Updates\n'; var bom = '\uFEFF'; var blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'plantsrobot_import_template.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('CSV 模板已下载'); } async function doImportCSV() { const fileInput = document.getElementById('import-csv-file'); const file = fileInput.files[0]; if (!file) { showToast('请选择一个CSV文件', 'error'); return; } const btn = document.getElementById('btn-do-import'); btn.disabled = true; btn.innerHTML = '
导入中...'; try { const text = await file.text(); const lines = text.split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) { showToast('CSV文件为空或格式不正确', 'error'); return; } // Parse header const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); const emailIdx = headers.indexOf('email'); const firstNameIdx = headers.indexOf('first_name'); const lastNameIdx = headers.indexOf('last_name'); const groupIdx = headers.indexOf('group'); if (emailIdx === -1) { showToast('CSV文件缺少 email 列', 'error'); return; } let successCount = 0; let skipCount = 0; let errorCount = 0; const skipExisting = document.getElementById('import-skip-existing').checked; for (let i = 1; i < lines.length; i++) { const cols = parseCSVLine(lines[i]); const email = (cols[emailIdx] || '').trim(); if (!email) { errorCount++; continue; } try { // Check if exists const { data: existing } = await getDb().from('subscribers').select('id').eq('email', email).maybeSingle(); if (existing) { if (skipExisting) { skipCount++; continue; } // Update existing const updates = { updated_at: new Date().toISOString() }; if (firstNameIdx >= 0) updates.first_name = (cols[firstNameIdx] || '').trim(); if (lastNameIdx >= 0) updates.last_name = (cols[lastNameIdx] || '').trim(); const { error: updErr } = await getDb().from('subscribers').update(updates).eq('id', existing.id); if (updErr) throw updErr; // Handle group if specified if (groupIdx >= 0) { const groupRaw = (cols[groupIdx] || '').trim(); if (groupRaw) { // Support semicolon-separated groups (matches export format) const groupNames = groupRaw.split(';').map(g => g.trim()).filter(Boolean); if (groupNames.length > 0) { await getDb().from('subscriber_tags').delete().eq('subscriber_id', existing.id).like('tag', 'group:%'); const inserts = groupNames.map(g => ({ subscriber_id: existing.id, tag: 'group:' + g })); await getDb().from('subscriber_tags').insert(inserts); } } } successCount++; } else { // Insert new const newSub = { email, status: 'confirmed', confirmed_at: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; if (firstNameIdx >= 0) newSub.first_name = (cols[firstNameIdx] || '').trim(); if (lastNameIdx >= 0) newSub.last_name = (cols[lastNameIdx] || '').trim(); const { data: inserted, error: insErr } = await getDb().from('subscribers').insert(newSub).select().single(); if (insErr) throw insErr; // Handle group if (groupIdx >= 0 && inserted) { const groupRaw = (cols[groupIdx] || '').trim(); if (groupRaw) { // Support semicolon-separated groups (matches export format) const groupNames = groupRaw.split(';').map(g => g.trim()).filter(Boolean); for (const g of groupNames) { await getDb().from('subscriber_tags').insert({ subscriber_id: inserted.id, tag: 'group:' + g }); } } } successCount++; } } catch (rowErr) { console.error('Import row error:', email, rowErr); errorCount++; } } document.getElementById('import-result').classList.remove('hidden'); document.getElementById('import-result').innerHTML = `

✓ 成功导入: ${successCount} 条

⊘ 跳过(已存在): ${skipCount} 条

${errorCount > 0 ? `

✗ 失败: ${errorCount} 条

` : ''}
`; showToast(`导入完成:成功 ${successCount},跳过 ${skipCount},失败 ${errorCount}`); loadSubscribers(subscriberPage); loadGroupFilter(); } catch (e) { console.error('Import error:', e); showToast('导入失败: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = '开始导入'; } } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (ch === ',' && !inQuotes) { result.push(current); current = ''; } else { current += ch; } } result.push(current); return result; } async function exportSubscribersCSV() { try { const search = document.getElementById('subscriber-search').value.trim(); const statusFilter = document.getElementById('subscriber-status-filter').value; const groupFilter = document.getElementById('subscriber-group-filter').value; let query = getDb().from('subscribers').select('*').order('created_at', { ascending: false }); if (search) { query = query.or(`email.ilike.%${search}%,first_name.ilike.%${search}%,last_name.ilike.%${search}%`); } if (statusFilter !== 'all') { query = query.eq('status', statusFilter); } // Group filter if (groupFilter !== 'all') { const { data: groupSubs } = await getDb().from('subscriber_tags').select('subscriber_id').eq('tag', 'group:' + groupFilter); const subIds = (groupSubs || []).map(t => t.subscriber_id); if (subIds.length > 0) { query = query.in('id', subIds); } } const { data: subs, error } = await query; if (error) throw error; if (!subs || subs.length === 0) { showToast('没有可导出的数据', 'info'); return; } // Get groups for all subscribers const { data: allGroupTags } = await getDb().from('subscriber_tags').select('subscriber_id, tag').like('tag', 'group:%'); const groupMap = {}; (allGroupTags || []).forEach(t => { if (!groupMap[t.subscriber_id]) groupMap[t.subscriber_id] = []; groupMap[t.subscriber_id].push(t.tag.replace('group:', '')); }); // Build CSV const headers = ['email', 'first_name', 'last_name', 'status', 'groups', 'created_at', 'confirmed_at']; const rows = [headers.join(',')]; subs.forEach(sub => { const groups = (groupMap[sub.id] || []).join(';'); rows.push([ csvEscape(sub.email || ''), csvEscape(sub.first_name || ''), csvEscape(sub.last_name || ''), csvEscape(sub.status || ''), csvEscape(groups), csvEscape(sub.created_at || ''), csvEscape(sub.confirmed_at || '') ].join(',')); }); const csvContent = rows.join('\n'); const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `subscribers_export_${new Date().toISOString().slice(0, 10)}.csv`; link.click(); URL.revokeObjectURL(url); showToast(`已导出 ${subs.length} 条订阅者数据`); } catch (e) { console.error('Export error:', e); showToast('导出失败: ' + e.message, 'error'); } } function csvEscape(str) { if (!str) return ''; const s = String(str); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; } // ======================== // Funnel Chart // ======================== async function loadFunnelChart() { const container = document.getElementById('funnelChart'); try { // Try to build a per-step sequence funnel first const [stepsResult, sendsResult] = await Promise.all([ getDb().from('sequence_steps').select('id, step_number, subject, delay_hours').order('step_number'), getDb().from('sequence_sends').select('step_id, status') ]); const steps = stepsResult.data || []; const sends = sendsResult.data || []; if (steps.length > 0) { // Per-step sequence funnel const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899']; // Count sent per step const sentByStep = {}; sends.forEach(function(s) { if (s.status === 'sent') { sentByStep[s.step_id] = (sentByStep[s.step_id] || 0) + 1; } }); const stages = steps.map(function(step, idx) { const sent = sentByStep[step.id] || 0; return { name: 'Step ' + step.step_number, subject: step.subject ? (step.subject.length > 30 ? step.subject.substring(0, 30) + '...' : step.subject) : '', count: sent, color: colors[idx % colors.length], delay: step.delay_hours }; }); const maxCount = Math.max.apply(null, stages.map(function(s) { return s.count; }).concat([1])); // If no sends at all, show empty state if (maxCount === 0) { container.innerHTML = '

暂无序列发送数据

当欢迎序列开始发送邮件后,这里将显示各步骤的转化漏斗

'; return; } let html = ''; stages.forEach(function(stage, idx) { const pct = Math.round((stage.count / maxCount) * 100); const prevCount = idx > 0 ? stages[idx - 1].count : stage.count; const dropRate = prevCount > 0 ? Math.round(((prevCount - stage.count) / prevCount) * 100) : 0; const dropDisplay = idx > 0 && dropRate > 0 ? ('-' + dropRate + '%') : (idx === 0 ? '' : '0%'); const delayText = stage.delay !== null && stage.delay !== undefined ? (stage.delay + 'h') : ''; html += '
' + '' + (idx + 1) + '' + '
' + '
' + '
' + '' + stage.name + ' ' + stage.count + '' + (stage.subject ? '
' + stage.subject + '' : '') + (delayText ? '(' + delayText + ')' : '') + '
' + (idx > 0 ? '' + dropDisplay + '' : '100%') + '
'; }); container.innerHTML = html; } else { // Fallback: generic event funnel (no sequence steps defined) const { data: events, error } = await getDb().from('email_events').select('event_type'); if (error) throw error; const counts = {}; (events || []).forEach(function(e) { counts[e.event_type] = (counts[e.event_type] || 0) + 1; }); const confirmedCount = parseInt(document.getElementById('stat-confirmed').textContent) || 0; const sent = counts['sent'] || 0; const delivered = counts['delivered'] || 0; const opened = counts['opened'] || 0; const clicked = counts['clicked'] || 0; const stages = [ { name: '已确认', count: confirmedCount, color: '#3b82f6' }, { name: '已发送', count: sent, color: '#8b5cf6' }, { name: '已送达', count: delivered, color: '#10b981' }, { name: '已打开', count: opened, color: '#f59e0b' }, { name: '已点击', count: clicked, color: '#ef4444' } ]; if (stages.every(function(s) { return s.count === 0; })) { container.innerHTML = '

暂无漏斗数据

当邮件开始发送后,这里将显示转化漏斗

'; return; } const maxCount = Math.max.apply(null, stages.map(function(s) { return s.count; }).concat([1])); let html = ''; stages.forEach(function(stage, idx) { const pct = Math.round((stage.count / maxCount) * 100); const prevCount = idx > 0 ? stages[idx - 1].count : stage.count; const dropRate = prevCount > 0 ? Math.round(((prevCount - stage.count) / prevCount) * 100) : 0; const dropDisplay = idx > 0 && dropRate > 0 ? ('-' + dropRate + '%') : (idx === 0 ? '' : '0%'); html += '
' + '' + (idx + 1) + '' + '
' + '
' + '
' + '' + stage.name + ' ' + stage.count + '' + (idx > 0 ? '' + dropDisplay + '' : '100%') + '
'; }); container.innerHTML = html; } } catch (e) { console.error('Funnel chart error:', e); container.innerHTML = '

漏斗加载失败

'; } } // ======================== // Trend Chart (Chart.js) // ======================== let trendChartInstance = null; async function loadTrendChart() { try { const now = new Date(); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Query subscriber data and email events in parallel const [subResult, evtResult] = await Promise.all([ getDb().from('subscribers').select('created_at').order('created_at', { ascending: true }), getDb().from('email_events').select('event_type, created_at').gte('created_at', sevenDaysAgo.toISOString()).order('created_at') ]); const subData = subResult.data || []; const evtData = evtResult.data || []; // Build day labels and data for last 7 days const days = []; const subCounts = []; const openRates = []; const clickRates = []; // Group email events by date const evtByDate = {}; evtData.forEach(function(e) { var dateStr = e.created_at.slice(0, 10); if (!evtByDate[dateStr]) evtByDate[dateStr] = { sent: 0, opened: 0, clicked: 0 }; if (e.event_type === 'sent') evtByDate[dateStr].sent++; else if (e.event_type === 'opened') evtByDate[dateStr].opened++; else if (e.event_type === 'clicked') evtByDate[dateStr].clicked++; }); for (let i = 6; i >= 0; i--) { const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); const dateStr = d.toISOString().slice(0, 10); days.push((d.getMonth() + 1) + '/' + d.getDate()); // Subscriber count const count = subData.filter(s => s.created_at && s.created_at.startsWith(dateStr)).length; subCounts.push(count); // Open/click rates const dayEvents = evtByDate[dateStr] || { sent: 0, opened: 0, clicked: 0 }; openRates.push(dayEvents.sent > 0 ? parseFloat(((dayEvents.opened / dayEvents.sent) * 100).toFixed(1)) : null); clickRates.push(dayEvents.sent > 0 ? parseFloat(((dayEvents.clicked / dayEvents.sent) * 100).toFixed(1)) : null); } const ctx = document.getElementById('trendChart'); if (!ctx) return; if (trendChartInstance) trendChartInstance.destroy(); trendChartInstance = new Chart(ctx, { type: 'line', data: { labels: days, datasets: [ { label: '新增订阅', data: subCounts, borderColor: '#2D5016', backgroundColor: 'rgba(45, 80, 22, 0.12)', borderWidth: 2.5, tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#2D5016', pointBorderColor: '#fff', pointBorderWidth: 2, pointHoverRadius: 6, yAxisID: 'y' }, { label: '打开率 %', data: openRates, borderColor: '#4CAF50', backgroundColor: 'rgba(76,175,80,0.06)', borderWidth: 2, tension: 0.4, fill: false, pointRadius: 3, pointHoverRadius: 5, yAxisID: 'y1', spanGaps: true }, { label: '点击率 %', data: clickRates, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.06)', borderWidth: 2, tension: 0.4, fill: false, pointRadius: 3, pointHoverRadius: 5, yAxisID: 'y1', spanGaps: true } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { usePointStyle: true, padding: 16, font: { family: 'Inter', size: 11 } } }, tooltip: { backgroundColor: '#1a2e0f', titleFont: { family: 'Inter' }, bodyFont: { family: 'Inter' }, padding: 12, cornerRadius: 8, callbacks: { label: function(ctx) { if (ctx.dataset.yAxisID === 'y1') return ctx.dataset.label + ': ' + (ctx.raw !== null ? ctx.raw + '%' : 'N/A'); return ctx.dataset.label + ': ' + ctx.raw; } } } }, scales: { x: { grid: { display: false }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8' } }, y: { type: 'linear', position: 'left', beginAtZero: true, grid: { color: '#f1f5f9' }, title: { display: true, text: '订阅数', font: { family: 'Inter', size: 11 }, color: '#94a3b8' }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8', stepSize: 1, callback: function(v) { return v === Math.floor(v) ? v : ''; } } }, y1: { type: 'linear', position: 'right', beginAtZero: true, max: 100, grid: { drawOnChartArea: false }, title: { display: true, text: '百分比 %', font: { family: 'Inter', size: 11 }, color: '#94a3b8' }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8', callback: function(v) { return v + '%'; } } } }, interaction: { intersect: false, mode: 'index' } } }); } catch (e) { console.error('Trend chart error:', e); var ctx = document.getElementById('trendChart'); if (ctx) { var ctx2d = ctx.getContext('2d'); ctx2d.clearRect(0, 0, ctx.width, ctx.height); ctx2d.font = '13px Inter, sans-serif'; ctx2d.fillStyle = '#dc2626'; ctx2d.textAlign = 'center'; ctx2d.fillText('趋势图加载失败,请刷新重试', ctx.width / 2, ctx.height / 2); } } } // ======================== // Analytics Charts // ======================== var analyticsRange = '7d'; var analyticsTrendInstance = null; var abTrendInstance = null; function setAnalyticsRange(range, btn) { analyticsRange = range; document.querySelectorAll('.analytics-filters button').forEach(function(b) { b.classList.remove('active'); }); if (btn) btn.classList.add('active'); initAnalyticsCharts(); } function initAnalyticsCharts() { if (typeof Chart === 'undefined') { setTimeout(initAnalyticsCharts, 200); return; } loadAnalyticsKPIs(); loadAnalyticsTrend(); loadHeatmap(); } function loadAnalyticsKPIs() { (async function() { try { var rangeDays = { '7d': 7, '30d': 30, '90d': 90, '1y': 365 }; var days = rangeDays[analyticsRange] || 7; var now = new Date(); var curStart = new Date(now); curStart.setDate(curStart.getDate() - days); var prevStart = new Date(curStart); prevStart.setDate(prevStart.getDate() - days); // Query email_events for both current and previous periods var { data: events, error: evtErr } = await getDb().from('email_events') .select('event_type, created_at') .gte('created_at', prevStart.toISOString()); if (evtErr) throw evtErr; events = events || []; // Query unsubscribes for both periods from subscribers table var { data: unsubs, error: unsubErr } = await getDb().from('subscribers') .select('unsubscribed_at') .not('unsubscribed_at', 'is', null) .gte('unsubscribed_at', prevStart.toISOString()); if (unsubErr) throw unsubErr; unsubs = unsubs || []; // Split into current vs previous period var curEvents = events.filter(function(e) { return new Date(e.created_at) >= curStart; }); var prevEvents = events.filter(function(e) { return new Date(e.created_at) < curStart; }); var curUnsubs = unsubs.filter(function(u) { return new Date(u.unsubscribed_at) >= curStart; }).length; var prevUnsubs = unsubs.filter(function(u) { return new Date(u.unsubscribed_at) < curStart; }).length; function countType(arr, type) { return arr.filter(function(e) { return e.event_type === type; }).length; } var curSent = countType(curEvents, 'sent'); var curOpened = countType(curEvents, 'opened'); var curClicked = countType(curEvents, 'clicked'); var curBounced = countType(curEvents, 'bounced_soft') + countType(curEvents, 'bounced_hard'); var prevSent = countType(prevEvents, 'sent'); var prevOpened = countType(prevEvents, 'opened'); var prevClicked = countType(prevEvents, 'clicked'); var prevBounced = countType(prevEvents, 'bounced_soft') + countType(prevEvents, 'bounced_hard'); function rate(n, d) { return d > 0 ? ((n / d) * 100).toFixed(1) + '%' : '0%'; } function fmt(n) { return n.toLocaleString(); } document.getElementById('akpi-sent').textContent = fmt(curSent); document.getElementById('akpi-open').textContent = rate(curOpened, curSent); document.getElementById('akpi-click').textContent = rate(curClicked, curSent); document.getElementById('akpi-unsub').textContent = rate(curUnsubs, curSent); document.getElementById('akpi-bounce').textContent = rate(curBounced, curSent); // Calculate change vs previous period function changeHTML(curRate, prevRate) { if (prevRate === 0 && curRate === 0) return '-- vs 上期'; var diff = curRate - prevRate; var cls = diff >= 0 ? 'up' : 'down'; var sign = diff >= 0 ? '+' : ''; return '' + sign + diff.toFixed(1) + '% vs 上期'; } var prevOpenRate = prevSent > 0 ? (prevOpened / prevSent) * 100 : 0; var curOpenRate = curSent > 0 ? (curOpened / curSent) * 100 : 0; var prevClickRate = prevSent > 0 ? (prevClicked / prevSent) * 100 : 0; var curClickRate = curSent > 0 ? (curClicked / curSent) * 100 : 0; var prevUnsubRate = prevSent > 0 ? (prevUnsubs / prevSent) * 100 : 0; var curUnsubRate = curSent > 0 ? (curUnsubs / curSent) * 100 : 0; var prevBounceRate = prevSent > 0 ? (prevBounced / prevSent) * 100 : 0; var curBounceRate = curSent > 0 ? (curBounced / curSent) * 100 : 0; document.getElementById('akpi-open-change').innerHTML = prevSent > 0 ? changeHTML(curOpenRate, prevOpenRate) : '-- vs 上期'; document.getElementById('akpi-click-change').innerHTML = prevSent > 0 ? changeHTML(curClickRate, prevClickRate) : '-- vs 上期'; document.getElementById('akpi-unsub-change').innerHTML = prevSent > 0 ? changeHTML(curUnsubRate, prevUnsubRate) : '-- vs 上期'; document.getElementById('akpi-bounce-change').innerHTML = prevSent > 0 ? changeHTML(curBounceRate, prevBounceRate) : '-- vs 上期'; } catch (e) { console.error('Analytics KPIs error:', e); ['akpi-sent','akpi-open','akpi-click','akpi-unsub','akpi-bounce'].forEach(function(id) { document.getElementById(id).textContent = '--'; }); ['akpi-open-change','akpi-click-change','akpi-unsub-change','akpi-bounce-change'].forEach(function(id) { document.getElementById(id).innerHTML = '重试'; }); } })(); } function loadAnalyticsTrend() { (async function() { try { var ctx = document.getElementById('analyticsTrendChart'); if (!ctx) return; if (analyticsTrendInstance) analyticsTrendInstance.destroy(); var rangeDays = { '7d': 7, '30d': 30, '90d': 90, '1y': 365 }; var days = rangeDays[analyticsRange] || 7; var startDate = new Date(); startDate.setDate(startDate.getDate() - days); var { data: events, error } = await getDb().from('email_events') .select('event_type, created_at') .gte('created_at', startDate.toISOString()) .order('created_at'); if (error) throw error; events = events || []; // Group by date var dateMap = {}; events.forEach(function(e) { var date = e.created_at.slice(0, 10); if (!dateMap[date]) dateMap[date] = { sent: 0, opened: 0, clicked: 0 }; if (e.event_type === 'sent') dateMap[date].sent++; else if (e.event_type === 'opened') dateMap[date].opened++; else if (e.event_type === 'clicked') dateMap[date].clicked++; }); // Fill in all dates in the range (even days with no events) var labels = [], openData = [], clickData = []; var isMonth = analyticsRange === '1y'; for (var i = 0; i < days; i++) { var d = new Date(startDate); d.setDate(d.getDate() + i); var dateStr = d.toISOString().slice(0, 10); if (isMonth) { if (i % 30 === 0) labels.push((d.getMonth() + 1) + '月'); else labels.push(''); } else { labels.push((d.getMonth() + 1) + '/' + d.getDate()); } var day = dateMap[dateStr] || { sent: 0, opened: 0, clicked: 0 }; openData.push(day.sent > 0 ? parseFloat(((day.opened / day.sent) * 100).toFixed(1)) : 0); clickData.push(day.sent > 0 ? parseFloat(((day.clicked / day.sent) * 100).toFixed(1)) : 0); } // If no data at all, show empty state message on canvas if (events.length === 0) { var ctx2d = ctx.getContext('2d'); ctx2d.clearRect(0, 0, ctx.width, ctx.height); ctx2d.font = '14px Inter, sans-serif'; ctx2d.fillStyle = '#94a3b8'; ctx2d.textAlign = 'center'; ctx2d.fillText('暂无邮件发送数据,发送邮件后将自动追踪趋势', ctx.width / 2, ctx.height / 2); return; } analyticsTrendInstance = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [ { label: '打开率 %', data: openData, borderColor: '#4CAF50', backgroundColor: 'rgba(76,175,80,0.08)', borderWidth: 2.5, tension: 0.4, fill: true, pointRadius: 3, pointHoverRadius: 5 }, { label: '点击率 %', data: clickData, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.08)', borderWidth: 2.5, tension: 0.4, fill: true, pointRadius: 3, pointHoverRadius: 5 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { usePointStyle: true, padding: 20, font: { family: 'Inter', size: 12 } } }, tooltip: { backgroundColor: '#1a2e0f', padding: 12, cornerRadius: 8 } }, scales: { x: { grid: { display: false }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8', maxRotation: 45 } }, y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8', callback: function(v) { return v + '%'; } } } }, interaction: { intersect: false, mode: 'index' } } }); } catch (e) { console.error('Analytics Trend error:', e); var ctx = document.getElementById('analyticsTrendChart'); if (ctx) { var ctx2d = ctx.getContext('2d'); ctx2d.clearRect(0, 0, ctx.width, ctx.height); ctx2d.font = '13px Inter, sans-serif'; ctx2d.fillStyle = '#dc2626'; ctx2d.textAlign = 'center'; ctx2d.fillText('数据加载失败,请刷新页面重试', ctx.width / 2, ctx.height / 2); } } })(); } function loadHeatmap() { (async function() { try { var container = document.getElementById('engagement-heatmap'); if (!container) return; var startDate = new Date(); startDate.setDate(startDate.getDate() - 90); var { data: events, error } = await getDb().from('email_events') .select('created_at') .in('event_type', ['opened', 'clicked']) .gte('created_at', startDate.toISOString()); if (error) throw error; events = events || []; // Build 7x24 matrix: count events per (dayOfWeek, hour) var matrix = []; for (var i = 0; i < 7; i++) { matrix.push([]); for (var j = 0; j < 24; j++) matrix[i].push(0); } events.forEach(function(e) { var d = new Date(e.created_at); var dow = (d.getDay() + 6) % 7; // Monday=0 var hour = d.getHours(); matrix[dow][hour]++; }); // Find max value for color scaling var maxVal = 1; for (var di = 0; di < 7; di++) { for (var hi = 0; hi < 24; hi++) { if (matrix[di][hi] > maxVal) maxVal = matrix[di][hi]; } } var days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; var hours = []; for (var h = 0; h <= 23; h++) hours.push(h + 'h'); var html = '
'; html += '
'; for (var hi = 0; hi < hours.length; hi++) html += '
' + hours[hi] + '
'; for (var di = 0; di < days.length; di++) { html += '
' + days[di] + '
'; for (var hj = 0; hj < hours.length; hj++) { var count = matrix[di][hj]; var rate = maxVal > 0 ? count / maxVal : 0; var color = rate > 0.7 ? '#2D5016' : rate > 0.4 ? '#4CAF50' : rate > 0.2 ? '#a3d9a5' : rate > 0.05 ? '#e8f5e9' : '#f5f5f5'; html += '
' + count + '
'; } } html += '
'; if (events.length === 0) { container.innerHTML = '

暂无活跃数据

当订阅者开始打开和点击邮件后,这里将显示活跃时段分布

'; } else { container.innerHTML = html; } } catch (e) { console.error('Heatmap error:', e); var container = document.getElementById('engagement-heatmap'); if (container) { container.innerHTML = '

热力图加载失败

'; } } })(); } // ======================== // A/B Test Functions // ======================== function initABTestCharts() { if (typeof Chart === 'undefined') { setTimeout(initABTestCharts, 200); return; } loadABTrend(); } function loadABTrend() { var ctx = document.getElementById('abTrendChart'); if (!ctx) return; if (abTrendInstance) abTrendInstance.destroy(); var labels = []; var dataA = []; var dataB = []; for (var h = 0; h < 24; h++) { labels.push(h + ':00'); dataA.push(2 + Math.random() * 4 + (h >= 8 && h <= 20 ? Math.random() * 3 : 0)); dataB.push(1.5 + Math.random() * 3 + (h >= 8 && h <= 20 ? Math.random() * 2.5 : 0)); } abTrendInstance = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [ { label: '版本 A', data: dataA, borderColor: '#4CAF50', backgroundColor: 'rgba(76,175,80,0.1)', borderWidth: 2.5, tension: 0.4, fill: true, pointRadius: 0, pointHoverRadius: 4 }, { label: '版本 B', data: dataB, borderColor: '#94a3b8', backgroundColor: 'rgba(148,163,184,0.08)', borderWidth: 2.5, tension: 0.4, fill: true, pointRadius: 0, pointHoverRadius: 4, borderDash: [5, 3] } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { usePointStyle: true, padding: 20, font: { family: 'Inter', size: 12 } } }, tooltip: { backgroundColor: '#1a2e0f', padding: 12, cornerRadius: 8, callbacks: { label: function(ctx) { return ctx.dataset.label + ': ' + ctx.raw.toFixed(1) + '%'; } } } }, scales: { x: { grid: { display: false }, ticks: { font: { family: 'Inter', size: 10 }, color: '#94a3b8', maxTicksLimit: 12 } }, y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { family: 'Inter', size: 11 }, color: '#94a3b8', callback: function(v) { return v.toFixed(1) + '%'; } } } }, interaction: { intersect: false, mode: 'index' } } }); } function showNewABTestForm() { document.getElementById('ab-new-test-form').style.display = 'block'; document.getElementById('ab-active-test').style.opacity = '0.6'; } function hideNewABTestForm() { document.getElementById('ab-new-test-form').style.display = 'none'; document.getElementById('ab-active-test').style.opacity = '1'; } function startABTest() { var name = document.getElementById('ab-name').value.trim(); if (!name) { showToast('请输入测试名称', 'error'); return; } showToast('A/B 测试 "' + name + '" 已启动!', 'success'); hideNewABTestForm(); // Reset form document.getElementById('ab-name').value = ''; } // ======================== // Stat card navigation helper // ======================== function navigateToSubscribersWithFilter(status) { // Switch to subscribers page const navEl = document.querySelector('.nav-item[onclick*="subscribers"]'); switchPage('subscribers', navEl); // Set the status filter setTimeout(() => { const statusFilter = document.getElementById('subscriber-status-filter'); if (statusFilter) { statusFilter.value = status; loadSubscribers(1); } }, 200); } // ======================== // System Status Check // ======================== async function checkSystemStatus() { var btn = document.getElementById('btn-check-status'); if (btn) { btn.disabled = true; btn.innerHTML = '
检查中...'; } try { var results = await Promise.allSettled([ getDb().from('subscribers').select('id', { count: 'exact', head: true }), getDb().from('sequences').select('id').eq('status', 'active'), getDb().from('email_events').select('id', { count: 'exact', head: true }), getDb().from('newsletter_campaigns').select('id', { count: 'exact', head: true }) ]); var statuses = [ { id: 'status-subscribers', result: results[0], label: '订阅者' }, { id: 'status-sequences', result: results[1], label: '序列' }, { id: 'status-events', result: results[2], label: '事件' }, { id: 'status-campaigns', result: results[3], label: '活动' } ]; statuses.forEach(s => { var el = document.getElementById(s.id); if (s.result.status === 'fulfilled' && !s.result.value.error) { var count = s.result.value.count !== undefined ? s.result.value.count : (s.result.value.data ? s.result.value.data.length : '?'); el.innerHTML = '' + count + '正常'; } else { el.innerHTML = '异常'; } }); } catch (e) { console.error('Status check error:', e); } finally { if (btn) { btn.disabled = false; btn.innerHTML = '🔄 刷新检查'; } } } // ======================== // API Test Tool // ======================== async function testSubscribeAPI() { var email = document.getElementById('api-test-email').value.trim(); var name = document.getElementById('api-test-name').value.trim(); if (!email) { showToast('请输入测试邮箱', 'error'); return; } var btn = document.getElementById('btn-api-test'); btn.disabled = true; btn.innerHTML = '
发送中...'; var resultEl = document.getElementById('api-test-result'); resultEl.classList.remove('hidden'); resultEl.innerHTML = '

正在发送请求...

'; try { var resp = await fetch(SUPABASE_URL + '/functions/v1/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, first_name: name || '', tags: [], email_mode: 'thank_you' }) }); var data = await resp.json(); if (resp.ok && data.success) { var modeLabel = '感谢邮件'; resultEl.className = 'p-4 rounded-lg border text-sm bg-green-50 border-green-200'; resultEl.innerHTML = '

✓ API 请求成功

订阅者已创建/更新(' + modeLabel + '模式),邮件已发送到 ' + escapeHtml(email) + '

' + escapeHtml(JSON.stringify(data, null, 2)) + '
'; } else { resultEl.className = 'p-4 rounded-lg border text-sm bg-red-50 border-red-200'; resultEl.innerHTML = '

✗ API 请求失败

' + escapeHtml(data.error || '未知错误') + '

' + escapeHtml(JSON.stringify(data, null, 2)) + '
'; } } catch (e) { resultEl.className = 'p-4 rounded-lg border text-sm bg-red-50 border-red-200'; resultEl.innerHTML = '

✗ 请求异常

' + escapeHtml(e.message) + '

'; } finally { btn.disabled = false; btn.innerHTML = '发送测试请求'; } } // ======================== // Initialization (由登录模块调用) // ======================== function initApp() { // 如果已经初始化过,跳过 if (window._appInitialized) return; window._appInitialized = true; // 确认 SDK 已就绪 if (!window.supabase) { showToast('SDK 尚未加载完成,请刷新页面重试', 'error'); return; } // 初始化侧边栏导航 Sidebar.init(); // Test connection with retry (async function() { var connected = false; var lastError = ''; for (var attempt = 0; attempt < 3; attempt++) { try { const { data, error } = await getDb().from('subscribers').select('id', { count: 'exact', head: true }); if (error) throw error; connected = true; break; } catch (e) { lastError = e.message; if (attempt < 2) await new Promise(function(r) { setTimeout(r, 1000); }); } } if (connected) { document.getElementById('connection-status').textContent = '已连接'; document.getElementById('connection-status').style.color = '#16a34a'; var dot = document.querySelector('#connection-indicator span:first-child'); if (dot) { dot.classList.remove('bg-yellow-500', 'animate-pulse'); dot.classList.add('bg-green-500'); } document.getElementById('connection-indicator').style.borderColor = '#bbf7d0'; document.getElementById('connection-indicator').style.background = '#f0fdf4'; } else { document.getElementById('connection-status').textContent = '连接失败'; document.getElementById('connection-status').style.color = '#dc2626'; var dot = document.querySelector('#connection-indicator span:first-child'); if (dot) { dot.classList.remove('bg-yellow-500', 'animate-pulse'); dot.classList.add('bg-red-500'); } document.getElementById('connection-indicator').style.borderColor = '#fecaca'; document.getElementById('connection-indicator').style.background = '#fef2f2'; showToast('Supabase 连接失败: ' + lastError, 'error'); } // Load initial dashboard loadDashboard(); })(); }

¡Gracias por suscribirte!

¡Este correo electrónico ha sido registrado!

Compra el look
Elige opciones
Opción de edición
Back In Stock Notification
Shop the lookElige opciones
this is just a warning
Carro de la compra
0 elementos