SubsTracker – 订阅管理与提醒系统 (Cloudflare workers)
- 取得連結
- X
- 以電子郵件傳送
- 其他應用程式
SubsTracker – 订阅管理与提醒系统是基于Cloudflare Workers的轻量级订阅管理系统,帮助您轻松跟踪各类订阅服务的到期时间,并通过Telegram发送及时提醒。
✨ 特性
- 🔔 自动提醒: 在订阅到期前自动发送Telegram通知
- 📊 订阅管理: 直观的Web界面管理所有订阅
- 🔄 周期计算: 智能计算循环订阅的下一个周期
- 📱 响应式设计: 完美适配移动端和桌面设备
- ☁️ 免服务器: 基于Cloudflare Workers,无需自建服务器
- 🔒 安全可靠: 数据存储在Cloudflare KV中,安全且高效
注意!!!!!:在开始之前,需在Cloudfalre控制左侧面板里,依次点击:存储和数据库 – KV – 创建 – 创建KV空间,空间名称和变量都填写:SUBSCRIPTIONS_KV 保存后再部署, 并给worker绑定上键值对,以及设置定时执行时间! 注意:这里的名称一定是:SUBSCRIPTIONS_KV 否则 SubsTracker 添加订阅将无法保存!
V3 版本代码:
// 订阅续期通知网站 - 基于CloudFlare Workers (完全优化版)// 定义HTML模板const loginPage = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>订阅管理系统</title><link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet"><style>.login-container {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);min-height: 100vh;}.login-box {backdrop-filter: blur(8px);background-color: rgba(255, 255, 255, 0.9);box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);}.btn-primary {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);transition: all 0.3s;}.btn-primary:hover {transform: translateY(-2px);box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);}.input-field {transition: all 0.3s;border: 1px solid #e2e8f0;}.input-field:focus {border-color: #667eea;box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.25);}</style></head><body class="login-container flex items-center justify-center"><div class="login-box p-8 rounded-xl w-full max-w-md"><div class="text-center mb-8"><h1 class="text-2xl font-bold text-gray-800"><i class="fas fa-calendar-check mr-2"></i>订阅管理系统</h1><p class="text-gray-600 mt-2">登录管理您的订阅提醒</p></div><form id="loginForm" class="space-y-6"><div><label for="username" class="block text-sm font-medium text-gray-700 mb-1"><i class="fas fa-user mr-2"></i>用户名</label><input type="text" id="username" name="username" requiredclass="input-field w-full px-4 py-3 rounded-lg text-gray-700 focus:outline-none"></div><div><label for="password" class="block text-sm font-medium text-gray-700 mb-1"><i class="fas fa-lock mr-2"></i>密码</label><input type="password" id="password" name="password" requiredclass="input-field w-full px-4 py-3 rounded-lg text-gray-700 focus:outline-none"></div><button type="submit"class="btn-primary w-full py-3 rounded-lg text-white font-medium focus:outline-none"><i class="fas fa-sign-in-alt mr-2"></i>登录</button><div id="errorMsg" class="text-red-500 text-center"></div></form></div><script>document.getElementById('loginForm').addEventListener('submit', async (e) => {e.preventDefault();const username = document.getElementById('username').value;const password = document.getElementById('password').value;const button = e.target.querySelector('button');const originalContent = button.innerHTML;button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>登录中...';button.disabled = true;try {const response = await fetch('/api/login', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ username, password })});const result = await response.json();if (result.success) {window.location.href = '/admin';} else {document.getElementById('errorMsg').textContent = result.message || '用户名或密码错误';button.innerHTML = originalContent;button.disabled = false;}} catch (error) {document.getElementById('errorMsg').textContent = '发生错误,请稍后再试';button.innerHTML = originalContent;button.disabled = false;}});</script></body></html>`;const adminPage = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>订阅管理系统</title><link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet"><style>.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: all 0.3s; }.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.btn-danger { background: linear-gradient(135deg, #f87171 0%, #dc2626 100%); transition: all 0.3s; }.btn-danger:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.btn-success { background: linear-gradient(135deg, #34d399 0%, #059669 100%); transition: all 0.3s; }.btn-success:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.btn-warning { background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%); transition: all 0.3s; }.btn-warning:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.btn-info { background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%); transition: all 0.3s; }.btn-info:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.table-container { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }.modal-container { backdrop-filter: blur(8px); }.readonly-input { background-color: #f8fafc; border-color: #e2e8f0; cursor: not-allowed; }.error-message { font-size: 0.875rem; margin-top: 0.25rem; display: none; }.error-message.show { display: block; }/* Toast 样式 */.toast {position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px;color: white; font-weight: 500; z-index: 1000; transform: translateX(400px);transition: all 0.3s ease-in-out; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);}.toast.show { transform: translateX(0); }.toast.success { background-color: #10b981; }.toast.error { background-color: #ef4444; }.toast.info { background-color: #3b82f6; }.toast.warning { background-color: #f59e0b; }</style></head><body class="bg-gray-100 min-h-screen"><div id="toast-container"></div><nav class="bg-white shadow-md"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center"><i class="fas fa-calendar-check text-indigo-600 text-2xl mr-2"></i><span class="font-bold text-xl text-gray-800">订阅管理系统</span></div><div class="flex items-center space-x-4"><a href="/admin" class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-list mr-1"></i>订阅列表</a><a href="/admin/config" class="text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-cog mr-1"></i>系统配置</a><a href="/api/logout" class="text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-sign-out-alt mr-1"></i>退出登录</a></div></div></div></nav><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><div class="flex justify-between items-center mb-6"><h2 class="text-2xl font-bold text-gray-800">订阅列表</h2><div class="flex space-x-2"><button id="addSubscriptionBtn" class="btn-primary text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"><i class="fas fa-plus mr-2"></i>添加新订阅</button></div></div><div class="table-container bg-white rounded-lg overflow-hidden"><table class="min-w-full divide-y divide-gray-200"><thead class="bg-gray-50"><tr><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">名称</th><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">到期时间 <i class="fas fa-sort-up ml-1 text-indigo-500" title="按到期时间升序排列"></i></th><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">提醒设置</th><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead><tbody id="subscriptionsBody" class="bg-white divide-y divide-gray-200"></tbody></table></div></div><!-- 添加/编辑订阅的模态框 --><div id="subscriptionModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 modal-container hidden flex items-center justify-center z-50"><div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-screen overflow-y-auto"><div class="bg-gray-50 px-6 py-4 border-b border-gray-200 rounded-t-lg"><div class="flex items-center justify-between"><h3 id="modalTitle" class="text-lg font-medium text-gray-900">添加新订阅</h3><button id="closeModal" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button></div></div><form id="subscriptionForm" class="p-6 space-y-6"><input type="hidden" id="subscriptionId"><div class="grid grid-cols-1 md:grid-cols-2 gap-6"><div><label for="name" class="block text-sm font-medium text-gray-700 mb-1">订阅名称 *</label><input type="text" id="name" requiredclass="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><div class="error-message text-red-500"></div></div><div><label for="customType" class="block text-sm font-medium text-gray-700 mb-1">订阅类型</label><input type="text" id="customType" placeholder="例如:流媒体、云服务、软件等"class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><div class="error-message text-red-500"></div></div></div><div class="grid grid-cols-1 md:grid-cols-3 gap-6"><div><label for="startDate" class="block text-sm font-medium text-gray-700 mb-1">开始日期</label><input type="date" id="startDate"class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><div class="error-message text-red-500"></div></div><div><label for="periodValue" class="block text-sm font-medium text-gray-700 mb-1">周期数值 *</label><input type="number" id="periodValue" min="1" value="1" requiredclass="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><div class="error-message text-red-500"></div></div><div><label for="periodUnit" class="block text-sm font-medium text-gray-700 mb-1">周期单位 *</label><select id="periodUnit" requiredclass="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><option value="day">天</option><option value="month" selected>月</option><option value="year">年</option></select><div class="error-message text-red-500"></div></div></div><div class="grid grid-cols-1 md:grid-cols-2 gap-6"><div><label for="expiryDate" class="block text-sm font-medium text-gray-700 mb-1">到期日期 *</label><input type="date" id="expiryDate" requiredclass="readonly-input w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none"><div class="error-message text-red-500"></div></div><div class="flex items-end"><button type="button" id="calculateExpiryBtn"class="btn-primary text-white px-4 py-2 rounded-md text-sm font-medium h-10"><i class="fas fa-calculator mr-2"></i>自动计算到期日期</button></div></div><div class="grid grid-cols-1 md:grid-cols-2 gap-6"><div><label for="reminderDays" class="block text-sm font-medium text-gray-700 mb-1">提前提醒天数</label><input type="number" id="reminderDays" min="0" value="7"class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"><p class="text-xs text-gray-500 mt-1">0 = 仅到期日当天提醒,1+ = 提前N天开始提醒</p><div class="error-message text-red-500"></div></div><div><label class="block text-sm font-medium text-gray-700 mb-3">选项设置</label><div class="space-y-2"><label class="inline-flex items-center"><input type="checkbox" id="isActive" checkedclass="form-checkbox h-4 w-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"><span class="ml-2 text-sm text-gray-700">启用订阅</span></label><label class="inline-flex items-center"><input type="checkbox" id="autoRenew" checkedclass="form-checkbox h-4 w-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"><span class="ml-2 text-sm text-gray-700">自动续订</span></label></div></div></div><div><label for="notes" class="block text-sm font-medium text-gray-700 mb-1">备注</label><textarea id="notes" rows="3" placeholder="可添加相关备注信息..."class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"></textarea><div class="error-message text-red-500"></div></div><div class="flex justify-end space-x-3 pt-4 border-t border-gray-200"><button type="button" id="cancelBtn"class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50">取消</button><button type="submit"class="btn-primary text-white px-4 py-2 rounded-md text-sm font-medium"><i class="fas fa-save mr-2"></i>保存</button></div></form></div></div><script>function showToast(message, type = 'success', duration = 3000) {const container = document.getElementById('toast-container');const toast = document.createElement('div');toast.className = 'toast ' + type;const icon = type === 'success' ? 'check-circle' :type === 'error' ? 'exclamation-circle' :type === 'warning' ? 'exclamation-triangle' : 'info-circle';toast.innerHTML = '<div class="flex items-center"><i class="fas fa-' + icon + ' mr-2"></i><span>' + message + '</span></div>';container.appendChild(toast);setTimeout(() => toast.classList.add('show'), 100);setTimeout(() => {toast.classList.remove('show');setTimeout(() => {if (container.contains(toast)) {container.removeChild(toast);}}, 300);}, duration);}function showFieldError(fieldId, message) {const field = document.getElementById(fieldId);const errorDiv = field.parentElement.querySelector('.error-message');if (errorDiv) {errorDiv.textContent = message;errorDiv.classList.add('show');field.classList.add('border-red-500');}}function clearFieldErrors() {document.querySelectorAll('.error-message').forEach(el => {el.classList.remove('show');el.textContent = '';});document.querySelectorAll('.border-red-500').forEach(el => {el.classList.remove('border-red-500');});}function validateForm() {clearFieldErrors();let isValid = true;const name = document.getElementById('name').value.trim();if (!name) {showFieldError('name', '请输入订阅名称');isValid = false;}const periodValue = document.getElementById('periodValue').value;if (!periodValue || periodValue < 1) {showFieldError('periodValue', '周期数值必须大于0');isValid = false;}const expiryDate = document.getElementById('expiryDate').value;if (!expiryDate) {showFieldError('expiryDate', '请选择到期日期');isValid = false;}const reminderDays = document.getElementById('reminderDays').value;if (reminderDays === '' || reminderDays < 0) {showFieldError('reminderDays', '提醒天数不能为负数');isValid = false;}return isValid;}// 获取所有订阅并按到期时间排序async function loadSubscriptions() {try {const tbody = document.getElementById('subscriptionsBody');tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4"><i class="fas fa-spinner fa-spin mr-2"></i>加载中...</td></tr>';const response = await fetch('/api/subscriptions');const data = await response.json();tbody.innerHTML = '';if (data.length === 0) {tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-gray-500">没有订阅数据</td></tr>';return;}// 按到期时间升序排序(最早到期的在前)data.sort((a, b) => new Date(a.expiryDate) - new Date(b.expiryDate));data.forEach(subscription => {const row = document.createElement('tr');row.className = subscription.isActive === false ? 'hover:bg-gray-50 bg-gray-100' : 'hover:bg-gray-50';const expiryDate = new Date(subscription.expiryDate);const now = new Date();const daysDiff = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));let statusHtml = '';if (!subscription.isActive) {statusHtml = '<span class="px-2 py-1 text-xs font-medium rounded-full text-white bg-gray-500"><i class="fas fa-pause-circle mr-1"></i>已停用</span>';} else if (daysDiff < 0) {statusHtml = '<span class="px-2 py-1 text-xs font-medium rounded-full text-white bg-red-500"><i class="fas fa-exclamation-circle mr-1"></i>已过期</span>';} else if (daysDiff <= (subscription.reminderDays || 7)) {statusHtml = '<span class="px-2 py-1 text-xs font-medium rounded-full text-white bg-yellow-500"><i class="fas fa-exclamation-triangle mr-1"></i>即将到期</span>';} else {statusHtml = '<span class="px-2 py-1 text-xs font-medium rounded-full text-white bg-green-500"><i class="fas fa-check-circle mr-1"></i>正常</span>';}let periodText = '';if (subscription.periodValue && subscription.periodUnit) {const unitMap = { day: '天', month: '月', year: '年' };periodText = subscription.periodValue + ' ' + (unitMap[subscription.periodUnit] || subscription.periodUnit);}const autoRenewIcon = subscription.autoRenew !== false ?'<i class="fas fa-sync-alt text-blue-500 ml-1" title="自动续订"></i>' :'<i class="fas fa-ban text-gray-400 ml-1" title="不自动续订"></i>';row.innerHTML ='<td class="px-6 py-4 whitespace-nowrap">' +'<div class="text-sm font-medium text-gray-900">' + subscription.name + '</div>' +(subscription.notes ? '<div class="text-xs text-gray-500">' + subscription.notes + '</div>' : '') +'</td>' +'<td class="px-6 py-4 whitespace-nowrap">' +'<div class="text-sm text-gray-900">' +'<i class="fas fa-tag mr-1"></i>' + (subscription.customType || '其他') +'</div>' +(periodText ? '<div class="text-xs text-gray-500">周期: ' + periodText + autoRenewIcon + '</div>' : '') +'</td>' +'<td class="px-6 py-4 whitespace-nowrap">' +'<div class="text-sm text-gray-900">' + new Date(subscription.expiryDate).toLocaleDateString() + '</div>' +'<div class="text-xs text-gray-500">' + (daysDiff < 0 ? '已过期' + Math.abs(daysDiff) + '天' : '还剩' + daysDiff + '天') + '</div>' +(subscription.startDate ? '<div class="text-xs text-gray-500">开始: ' + new Date(subscription.startDate).toLocaleDateString() + '</div>' : '') +'</td>' +'<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' +'<div><i class="fas fa-bell mr-1"></i>提前' + (subscription.reminderDays || 0) + '天</div>' +(subscription.reminderDays === 0 ? '<div class="text-xs text-gray-500">仅到期日提醒</div>' : '') +'</td>' +'<td class="px-6 py-4 whitespace-nowrap">' + statusHtml + '</td>' +'<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">' +'<div class="flex flex-wrap gap-1">' +'<button class="edit btn-primary text-white px-2 py-1 rounded text-xs" data-id="' + subscription.id + '"><i class="fas fa-edit mr-1"></i>编辑</button>' +'<button class="test-notify btn-info text-white px-2 py-1 rounded text-xs" data-id="' + subscription.id + '"><i class="fas fa-paper-plane mr-1"></i>测试</button>' +'<button class="delete btn-danger text-white px-2 py-1 rounded text-xs" data-id="' + subscription.id + '"><i class="fas fa-trash-alt mr-1"></i>删除</button>' +(subscription.isActive ?'<button class="toggle-status btn-warning text-white px-2 py-1 rounded text-xs" data-id="' + subscription.id + '" data-action="deactivate"><i class="fas fa-pause-circle mr-1"></i>停用</button>' :'<button class="toggle-status btn-success text-white px-2 py-1 rounded text-xs" data-id="' + subscription.id + '" data-action="activate"><i class="fas fa-play-circle mr-1"></i>启用</button>') +'</div>' +'</td>';tbody.appendChild(row);});document.querySelectorAll('.edit').forEach(button => {button.addEventListener('click', editSubscription);});document.querySelectorAll('.delete').forEach(button => {button.addEventListener('click', deleteSubscription);});document.querySelectorAll('.toggle-status').forEach(button => {button.addEventListener('click', toggleSubscriptionStatus);});document.querySelectorAll('.test-notify').forEach(button => {button.addEventListener('click', testSubscriptionNotification);});} catch (error) {console.error('加载订阅失败:', error);const tbody = document.getElementById('subscriptionsBody');tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>加载失败,请刷新页面重试</td></tr>';showToast('加载订阅列表失败', 'error');}}async function testSubscriptionNotification(e) {const button = e.target.tagName === 'BUTTON' ? e.target : e.target.parentElement;const id = button.dataset.id;const originalContent = button.innerHTML;button.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>';button.disabled = true;try {const response = await fetch('/api/subscriptions/' + id + '/test-notify', { method: 'POST' });const result = await response.json();if (result.success) {showToast(result.message || '测试通知已发送', 'success');} else {showToast(result.message || '测试通知发送失败', 'error');}} catch (error) {console.error('测试通知失败:', error);showToast('发送测试通知时发生错误', 'error');} finally {button.innerHTML = originalContent;button.disabled = false;}}async function toggleSubscriptionStatus(e) {const id = e.target.dataset.id || e.target.parentElement.dataset.id;const action = e.target.dataset.action || e.target.parentElement.dataset.action;const isActivate = action === 'activate';const button = e.target.tagName === 'BUTTON' ? e.target : e.target.parentElement;const originalContent = button.innerHTML;button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>' + (isActivate ? '启用中...' : '停用中...');button.disabled = true;try {const response = await fetch('/api/subscriptions/' + id + '/toggle-status', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ isActive: isActivate })});if (response.ok) {showToast((isActivate ? '启用' : '停用') + '成功', 'success');loadSubscriptions();} else {const error = await response.json();showToast((isActivate ? '启用' : '停用') + '失败: ' + (error.message || '未知错误'), 'error');button.innerHTML = originalContent;button.disabled = false;}} catch (error) {console.error((isActivate ? '启用' : '停用') + '订阅失败:', error);showToast((isActivate ? '启用' : '停用') + '失败,请稍后再试', 'error');button.innerHTML = originalContent;button.disabled = false;}}document.getElementById('addSubscriptionBtn').addEventListener('click', () => {document.getElementById('modalTitle').textContent = '添加新订阅';document.getElementById('subscriptionModal').classList.remove('hidden');document.getElementById('subscriptionForm').reset();document.getElementById('subscriptionId').value = '';clearFieldErrors();const today = new Date().toISOString().split('T')[0];document.getElementById('startDate').value = today;document.getElementById('reminderDays').value = '7';document.getElementById('isActive').checked = true;document.getElementById('autoRenew').checked = true;calculateExpiryDate();setupModalEventListeners();});function setupModalEventListeners() {document.getElementById('calculateExpiryBtn').removeEventListener('click', calculateExpiryDate);document.getElementById('calculateExpiryBtn').addEventListener('click', calculateExpiryDate);['startDate', 'periodValue', 'periodUnit'].forEach(id => {const element = document.getElementById(id);element.removeEventListener('change', calculateExpiryDate);element.addEventListener('change', calculateExpiryDate);});document.getElementById('cancelBtn').addEventListener('click', () => {document.getElementById('subscriptionModal').classList.add('hidden');});}function calculateExpiryDate() {const startDate = document.getElementById('startDate').value;const periodValue = parseInt(document.getElementById('periodValue').value);const periodUnit = document.getElementById('periodUnit').value;if (!startDate || !periodValue || !periodUnit) {return;}const start = new Date(startDate);const expiry = new Date(start);if (periodUnit === 'day') {expiry.setDate(start.getDate() + periodValue);} else if (periodUnit === 'month') {expiry.setMonth(start.getMonth() + periodValue);} else if (periodUnit === 'year') {expiry.setFullYear(start.getFullYear() + periodValue);}document.getElementById('expiryDate').value = expiry.toISOString().split('T')[0];}document.getElementById('closeModal').addEventListener('click', () => {document.getElementById('subscriptionModal').classList.add('hidden');});document.getElementById('subscriptionModal').addEventListener('click', (event) => {if (event.target === document.getElementById('subscriptionModal')) {document.getElementById('subscriptionModal').classList.add('hidden');}});document.getElementById('subscriptionForm').addEventListener('submit', async (e) => {e.preventDefault();if (!validateForm()) {return;}const id = document.getElementById('subscriptionId').value;const subscription = {name: document.getElementById('name').value.trim(),customType: document.getElementById('customType').value.trim(),notes: document.getElementById('notes').value.trim() || '',isActive: document.getElementById('isActive').checked,autoRenew: document.getElementById('autoRenew').checked,startDate: document.getElementById('startDate').value,expiryDate: document.getElementById('expiryDate').value,periodValue: parseInt(document.getElementById('periodValue').value),periodUnit: document.getElementById('periodUnit').value,reminderDays: parseInt(document.getElementById('reminderDays').value) || 0};const submitButton = e.target.querySelector('button[type="submit"]');const originalContent = submitButton.innerHTML;submitButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>' + (id ? '更新中...' : '保存中...');submitButton.disabled = true;try {const url = id ? '/api/subscriptions/' + id : '/api/subscriptions';const method = id ? 'PUT' : 'POST';const response = await fetch(url, {method: method,headers: { 'Content-Type': 'application/json' },body: JSON.stringify(subscription)});const result = await response.json();if (result.success) {showToast((id ? '更新' : '添加') + '订阅成功', 'success');document.getElementById('subscriptionModal').classList.add('hidden');loadSubscriptions();} else {showToast((id ? '更新' : '添加') + '订阅失败: ' + (result.message || '未知错误'), 'error');}} catch (error) {console.error((id ? '更新' : '添加') + '订阅失败:', error);showToast((id ? '更新' : '添加') + '订阅失败,请稍后再试', 'error');} finally {submitButton.innerHTML = originalContent;submitButton.disabled = false;}});async function editSubscription(e) {const id = e.target.dataset.id || e.target.parentElement.dataset.id;try {const response = await fetch('/api/subscriptions/' + id);const subscription = await response.json();if (subscription) {document.getElementById('modalTitle').textContent = '编辑订阅';document.getElementById('subscriptionId').value = subscription.id;document.getElementById('name').value = subscription.name;document.getElementById('customType').value = subscription.customType || '';document.getElementById('notes').value = subscription.notes || '';document.getElementById('isActive').checked = subscription.isActive !== false;document.getElementById('autoRenew').checked = subscription.autoRenew !== false;document.getElementById('startDate').value = subscription.startDate ? subscription.startDate.split('T')[0] : '';document.getElementById('expiryDate').value = subscription.expiryDate ? subscription.expiryDate.split('T')[0] : '';document.getElementById('periodValue').value = subscription.periodValue || 1;document.getElementById('periodUnit').value = subscription.periodUnit || 'month';document.getElementById('reminderDays').value = subscription.reminderDays !== undefined ? subscription.reminderDays : 7;clearFieldErrors();document.getElementById('subscriptionModal').classList.remove('hidden');setupModalEventListeners();}} catch (error) {console.error('获取订阅信息失败:', error);showToast('获取订阅信息失败', 'error');}}async function deleteSubscription(e) {const id = e.target.dataset.id || e.target.parentElement.dataset.id;if (!confirm('确定要删除这个订阅吗?此操作不可恢复。')) {return;}const button = e.target.tagName === 'BUTTON' ? e.target : e.target.parentElement;const originalContent = button.innerHTML;button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>删除中...';button.disabled = true;try {const response = await fetch('/api/subscriptions/' + id, {method: 'DELETE'});if (response.ok) {showToast('删除成功', 'success');loadSubscriptions();} else {const error = await response.json();showToast('删除失败: ' + (error.message || '未知错误'), 'error');button.innerHTML = originalContent;button.disabled = false;}} catch (error) {console.error('删除订阅失败:', error);showToast('删除失败,请稍后再试', 'error');button.innerHTML = originalContent;button.disabled = false;}}window.addEventListener('load', loadSubscriptions);</script></body></html>`;const configPage = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>系统配置 - 订阅管理系统</title><link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet"><style>.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: all 0.3s; }.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.btn-secondary { background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); transition: all 0.3s; }.btn-secondary:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }.toast {position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px;color: white; font-weight: 500; z-index: 1000; transform: translateX(400px);transition: all 0.3s ease-in-out; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);}.toast.show { transform: translateX(0); }.toast.success { background-color: #10b981; }.toast.error { background-color: #ef4444; }.toast.info { background-color: #3b82f6; }.toast.warning { background-color: #f59e0b; }.config-section {border: 1px solid #e5e7eb;border-radius: 8px;padding: 16px;margin-bottom: 24px;}.config-section.active {background-color: #f8fafc;border-color: #6366f1;}.config-section.inactive {background-color: #f9fafb;opacity: 0.7;}</style></head><body class="bg-gray-100 min-h-screen"><div id="toast-container"></div><nav class="bg-white shadow-md"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center"><i class="fas fa-calendar-check text-indigo-600 text-2xl mr-2"></i><span class="font-bold text-xl text-gray-800">订阅管理系统</span></div><div class="flex items-center space-x-4"><a href="/admin" class="text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-list mr-1"></i>订阅列表</a><a href="/admin/config" class="text-indigo-600 border-b-2 border-indigo-600 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-cog mr-1"></i>系统配置</a><a href="/api/logout" class="text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"><i class="fas fa-sign-out-alt mr-1"></i>退出登录</a></div></div></div></nav><div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><div class="bg-white rounded-lg shadow-md p-6"><h2 class="text-2xl font-bold text-gray-800 mb-6">系统配置</h2><form id="configForm" class="space-y-8"><div class="border-b border-gray-200 pb-6"><h3 class="text-lg font-medium text-gray-900 mb-4">管理员账户</h3><div class="grid grid-cols-1 md:grid-cols-2 gap-6"><div><label for="adminUsername" class="block text-sm font-medium text-gray-700">用户名</label><input type="text" id="adminUsername" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"></div><div><label for="adminPassword" class="block text-sm font-medium text-gray-700">密码</label><input type="password" id="adminPassword" placeholder="如不修改密码,请留空" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"><p class="mt-1 text-sm text-gray-500">留空表示不修改当前密码</p></div></div></div><div class="border-b border-gray-200 pb-6"><h3 class="text-lg font-medium text-gray-900 mb-4">通知设置</h3><div class="mb-6"><label class="block text-sm font-medium text-gray-700 mb-3">通知方式</label><div class="flex space-x-6"><label class="inline-flex items-center"><input type="radio" name="notificationType" value="telegram" class="form-radio h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"><span class="ml-2 text-sm text-gray-700">Telegram</span></label><label class="inline-flex items-center"><input type="radio" name="notificationType" value="notifyx" class="form-radio h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" checked><span class="ml-2 text-sm text-gray-700 font-semibold">NotifyX(推荐)</span></label><a href="https://www.notifyx.cn/" target="_blank" class="text-indigo-600 hover:text-indigo-800 text-sm"><i class="fas fa-external-link-alt ml-1"></i> NotifyX官网</a></div></div><div id="telegramConfig" class="config-section"><h4 class="text-md font-medium text-gray-900 mb-3">Telegram 配置</h4><div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"><div><label for="tgBotToken" class="block text-sm font-medium text-gray-700">Bot Token</label><input type="text" id="tgBotToken" placeholder="从 @BotFather 获取" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"></div><div><label for="tgChatId" class="block text-sm font-medium text-gray-700">Chat ID</label><input type="text" id="tgChatId" placeholder="可从 @userinfobot 获取" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"></div></div><div class="flex justify-end"><button type="button" id="testTelegramBtn" class="btn-secondary text-white px-4 py-2 rounded-md text-sm font-medium"><i class="fas fa-paper-plane mr-2"></i>测试 Telegram 通知</button></div></div><div id="notifyxConfig" class="config-section"><h4 class="text-md font-medium text-gray-900 mb-3">NotifyX 配置</h4><div class="mb-4"><label for="notifyxApiKey" class="block text-sm font-medium text-gray-700">API Key</label><input type="text" id="notifyxApiKey" placeholder="从 NotifyX 平台获取的 API Key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"><p class="mt-1 text-sm text-gray-500">从 <a href="https://www.notifyx.cn/" target="_blank" class="text-indigo-600 hover:text-indigo-800">NotifyX平台</a> 获取的 API Key</p></div><div class="flex justify-end"><button type="button" id="testNotifyXBtn" class="btn-secondary text-white px-4 py-2 rounded-md text-sm font-medium"><i class="fas fa-paper-plane mr-2"></i>测试 NotifyX 通知</button></div></div></div><div class="flex justify-end"><button type="submit" class="btn-primary text-white px-6 py-2 rounded-md text-sm font-medium"><i class="fas fa-save mr-2"></i>保存配置</button></div></form></div></div><script>function showToast(message, type = 'success', duration = 3000) {const container = document.getElementById('toast-container');const toast = document.createElement('div');toast.className = 'toast ' + type;const icon = type === 'success' ? 'check-circle' :type === 'error' ? 'exclamation-circle' :type === 'warning' ? 'exclamation-triangle' : 'info-circle';toast.innerHTML = '<div class="flex items-center"><i class="fas fa-' + icon + ' mr-2"></i><span>' + message + '</span></div>';container.appendChild(toast);setTimeout(() => toast.classList.add('show'), 100);setTimeout(() => {toast.classList.remove('show');setTimeout(() => {if (container.contains(toast)) {container.removeChild(toast);}}, 300);}, duration);}async function loadConfig() {try {const response = await fetch('/api/config');const config = await response.json();document.getElementById('adminUsername').value = config.ADMIN_USERNAME || '';document.getElementById('tgBotToken').value = config.TG_BOT_TOKEN || '';document.getElementById('tgChatId').value = config.TG_CHAT_ID || '';document.getElementById('notifyxApiKey').value = config.NOTIFYX_API_KEY || '';const notificationType = config.NOTIFICATION_TYPE || 'notifyx';document.querySelector('input[name="notificationType"][value="' + notificationType + '"]').checked = true;toggleNotificationConfig(notificationType);} catch (error) {console.error('加载配置失败:', error);showToast('加载配置失败,请刷新页面重试', 'error');}}function toggleNotificationConfig(type) {const telegramConfig = document.getElementById('telegramConfig');const notifyxConfig = document.getElementById('notifyxConfig');if (type === 'telegram') {telegramConfig.classList.remove('inactive');telegramConfig.classList.add('active');notifyxConfig.classList.remove('active');notifyxConfig.classList.add('inactive');} else if (type === 'notifyx') {telegramConfig.classList.remove('active');telegramConfig.classList.add('inactive');notifyxConfig.classList.remove('inactive');notifyxConfig.classList.add('active');}}document.querySelectorAll('input[name="notificationType"]').forEach(radio => {radio.addEventListener('change', (e) => {toggleNotificationConfig(e.target.value);});});document.getElementById('configForm').addEventListener('submit', async (e) => {e.preventDefault();const config = {ADMIN_USERNAME: document.getElementById('adminUsername').value.trim(),TG_BOT_TOKEN: document.getElementById('tgBotToken').value.trim(),TG_CHAT_ID: document.getElementById('tgChatId').value.trim(),NOTIFYX_API_KEY: document.getElementById('notifyxApiKey').value.trim(),NOTIFICATION_TYPE: document.querySelector('input[name="notificationType"]:checked').value};const passwordField = document.getElementById('adminPassword');if (passwordField.value.trim()) {config.ADMIN_PASSWORD = passwordField.value.trim();}const submitButton = e.target.querySelector('button[type="submit"]');const originalContent = submitButton.innerHTML;submitButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>保存中...';submitButton.disabled = true;try {const response = await fetch('/api/config', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(config)});const result = await response.json();if (result.success) {showToast('配置保存成功', 'success');passwordField.value = '';} else {showToast('配置保存失败: ' + (result.message || '未知错误'), 'error');}} catch (error) {console.error('保存配置失败:', error);showToast('保存配置失败,请稍后再试', 'error');} finally {submitButton.innerHTML = originalContent;submitButton.disabled = false;}});async function testNotification(type) {const button = document.getElementById(type === 'telegram' ? 'testTelegramBtn' : 'testNotifyXBtn');const originalContent = button.innerHTML;const serviceName = type === 'telegram' ? 'Telegram' : 'NotifyX';button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>测试中...';button.disabled = true;const config = {};if (type === 'telegram') {config.TG_BOT_TOKEN = document.getElementById('tgBotToken').value.trim();config.TG_CHAT_ID = document.getElementById('tgChatId').value.trim();if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) {showToast('请先填写 Telegram Bot Token 和 Chat ID', 'warning');button.innerHTML = originalContent;button.disabled = false;return;}} else {config.NOTIFYX_API_KEY = document.getElementById('notifyxApiKey').value.trim();if (!config.NOTIFYX_API_KEY) {showToast('请先填写 NotifyX API Key', 'warning');button.innerHTML = originalContent;button.disabled = false;return;}}try {const response = await fetch('/api/test-notification', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ type: type, ...config })});const result = await response.json();if (result.success) {showToast(serviceName + ' 通知测试成功!', 'success');} else {showToast(serviceName + ' 通知测试失败: ' + (result.message || '未知错误'), 'error');}} catch (error) {console.error('测试通知失败:', error);showToast('测试失败,请稍后再试', 'error');} finally {button.innerHTML = originalContent;button.disabled = false;}}document.getElementById('testTelegramBtn').addEventListener('click', () => {testNotification('telegram');});document.getElementById('testNotifyXBtn').addEventListener('click', () => {testNotification('notifyx');});window.addEventListener('load', loadConfig);</script></body></html>`;// 管理页面const admin = {async handleRequest(request, env, ctx) {const url = new URL(request.url);const pathname = url.pathname;const token = getCookieValue(request.headers.get('Cookie'), 'token');const config = await getConfig(env);const user = token ? await verifyJWT(token, config.JWT_SECRET) : null;if (!user) {return new Response('', {status: 302,headers: { 'Location': '/' }});}if (pathname === '/admin/config') {return new Response(configPage, {headers: { 'Content-Type': 'text/html; charset=utf-8' }});}return new Response(adminPage, {headers: { 'Content-Type': 'text/html; charset=utf-8' }});}};// 处理API请求const api = {async handleRequest(request, env, ctx) {const url = new URL(request.url);const path = url.pathname.slice(4);const method = request.method;const config = await getConfig(env);if (path === '/login' && method === 'POST') {const body = await request.json();if (body.username === config.ADMIN_USERNAME && body.password === config.ADMIN_PASSWORD) {const token = await generateJWT(body.username, config.JWT_SECRET);return new Response(JSON.stringify({ success: true }),{headers: {'Content-Type': 'application/json','Set-Cookie': 'token=' + token + '; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400'}});} else {return new Response(JSON.stringify({ success: false, message: '用户名或密码错误' }),{ headers: { 'Content-Type': 'application/json' } });}}if (path === '/logout' && (method === 'GET' || method === 'POST')) {return new Response('', {status: 302,headers: {'Location': '/','Set-Cookie': 'token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0'}});}const token = getCookieValue(request.headers.get('Cookie'), 'token');const user = token ? await verifyJWT(token, config.JWT_SECRET) : null;if (!user && path !== '/login') {return new Response(JSON.stringify({ success: false, message: '未授权访问' }),{ status: 401, headers: { 'Content-Type': 'application/json' } });}if (path === '/config') {if (method === 'GET') {const { JWT_SECRET, ADMIN_PASSWORD, ...safeConfig } = config;return new Response(JSON.stringify(safeConfig),{ headers: { 'Content-Type': 'application/json' } });}if (method === 'POST') {try {const newConfig = await request.json();const updatedConfig = {...config,ADMIN_USERNAME: newConfig.ADMIN_USERNAME || config.ADMIN_USERNAME,TG_BOT_TOKEN: newConfig.TG_BOT_TOKEN || '',TG_CHAT_ID: newConfig.TG_CHAT_ID || '',NOTIFYX_API_KEY: newConfig.NOTIFYX_API_KEY || '',NOTIFICATION_TYPE: newConfig.NOTIFICATION_TYPE || config.NOTIFICATION_TYPE};if (newConfig.ADMIN_PASSWORD) {updatedConfig.ADMIN_PASSWORD = newConfig.ADMIN_PASSWORD;}await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig));return new Response(JSON.stringify({ success: true }),{ headers: { 'Content-Type': 'application/json' } });} catch (error) {return new Response(JSON.stringify({ success: false, message: '更新配置失败' }),{ status: 400, headers: { 'Content-Type': 'application/json' } });}}}if (path === '/test-notification' && method === 'POST') {try {const body = await request.json();let success = false;let message = '';if (body.type === 'telegram') {const testConfig = {...config,TG_BOT_TOKEN: body.TG_BOT_TOKEN,TG_CHAT_ID: body.TG_CHAT_ID};const content = '*测试通知*\n\n这是一条测试通知,用于验证Telegram通知功能是否正常工作。\n\n发送时间: ' + new Date().toLocaleString();success = await sendTelegramNotification(content, testConfig);message = success ? 'Telegram通知发送成功' : 'Telegram通知发送失败,请检查配置';} else if (body.type === 'notifyx') {const testConfig = {...config,NOTIFYX_API_KEY: body.NOTIFYX_API_KEY};const title = '测试通知';const content = '## 这是一条测试通知\n\n用于验证NotifyX通知功能是否正常工作。\n\n发送时间: ' + new Date().toLocaleString();const description = '测试NotifyX通知功能';success = await sendNotifyXNotification(title, content, description, testConfig);message = success ? 'NotifyX通知发送成功' : 'NotifyX通知发送失败,请检查配置';}return new Response(JSON.stringify({ success, message }),{ headers: { 'Content-Type': 'application/json' } });} catch (error) {console.error('测试通知失败:', error);return new Response(JSON.stringify({ success: false, message: '测试通知失败: ' + error.message }),{ status: 500, headers: { 'Content-Type': 'application/json' } });}}if (path === '/subscriptions') {if (method === 'GET') {const subscriptions = await getAllSubscriptions(env);return new Response(JSON.stringify(subscriptions),{ headers: { 'Content-Type': 'application/json' } });}if (method === 'POST') {const subscription = await request.json();const result = await createSubscription(subscription, env);return new Response(JSON.stringify(result),{status: result.success ? 201 : 400,headers: { 'Content-Type': 'application/json' }});}}if (path.startsWith('/subscriptions/')) {const parts = path.split('/');const id = parts[2];if (parts[3] === 'toggle-status' && method === 'POST') {const body = await request.json();const result = await toggleSubscriptionStatus(id, body.isActive, env);return new Response(JSON.stringify(result),{status: result.success ? 200 : 400,headers: { 'Content-Type': 'application/json' }});}if (parts[3] === 'test-notify' && method === 'POST') {const result = await testSingleSubscriptionNotification(id, env);return new Response(JSON.stringify(result), { status: result.success ? 200 : 500, headers: { 'Content-Type': 'application/json' } });}if (method === 'GET') {const subscription = await getSubscription(id, env);return new Response(JSON.stringify(subscription),{ headers: { 'Content-Type': 'application/json' } });}if (method === 'PUT') {const subscription = await request.json();const result = await updateSubscription(id, subscription, env);return new Response(JSON.stringify(result),{status: result.success ? 200 : 400,headers: { 'Content-Type': 'application/json' }});}if (method === 'DELETE') {const result = await deleteSubscription(id, env);return new Response(JSON.stringify(result),{status: result.success ? 200 : 400,headers: { 'Content-Type': 'application/json' }});}}return new Response(JSON.stringify({ success: false, message: '未找到请求的资源' }),{ status: 404, headers: { 'Content-Type': 'application/json' } });}};// 工具函数async function getConfig(env) {try {const data = await env.SUBSCRIPTIONS_KV.get('config');const config = data ? JSON.parse(data) : {};return {ADMIN_USERNAME: config.ADMIN_USERNAME || 'admin',ADMIN_PASSWORD: config.ADMIN_PASSWORD || 'password',JWT_SECRET: config.JWT_SECRET || 'your-secret-key',TG_BOT_TOKEN: config.TG_BOT_TOKEN || '',TG_CHAT_ID: config.TG_CHAT_ID || '',NOTIFYX_API_KEY: config.NOTIFYX_API_KEY || '',NOTIFICATION_TYPE: config.NOTIFICATION_TYPE || 'notifyx'};} catch (error) {return {ADMIN_USERNAME: 'admin',ADMIN_PASSWORD: 'password',JWT_SECRET: 'your-secret-key',TG_BOT_TOKEN: '',TG_CHAT_ID: '',NOTIFYX_API_KEY: '',NOTIFICATION_TYPE: 'notifyx'};}}async function generateJWT(username, secret) {const header = { alg: 'HS256', typ: 'JWT' };const payload = { username, iat: Math.floor(Date.now() / 1000) };const headerBase64 = btoa(JSON.stringify(header));const payloadBase64 = btoa(JSON.stringify(payload));const signatureInput = headerBase64 + '.' + payloadBase64;const signature = await CryptoJS.HmacSHA256(signatureInput, secret);return headerBase64 + '.' + payloadBase64 + '.' + signature;}async function verifyJWT(token, secret) {try {const parts = token.split('.');if (parts.length !== 3) return null;const [headerBase64, payloadBase64, signature] = parts;const signatureInput = headerBase64 + '.' + payloadBase64;const expectedSignature = await CryptoJS.HmacSHA256(signatureInput, secret);if (signature !== expectedSignature) return null;const payload = JSON.parse(atob(payloadBase64));return payload;} catch (error) {return null;}}async function getAllSubscriptions(env) {try {const data = await env.SUBSCRIPTIONS_KV.get('subscriptions');return data ? JSON.parse(data) : [];} catch (error) {return [];}}async function getSubscription(id, env) {const subscriptions = await getAllSubscriptions(env);return subscriptions.find(s => s.id === id);}async function createSubscription(subscription, env) {try {const subscriptions = await getAllSubscriptions(env);if (!subscription.name || !subscription.expiryDate) {return { success: false, message: '缺少必填字段' };}let expiryDate = new Date(subscription.expiryDate);const now = new Date();if (expiryDate < now && subscription.periodValue && subscription.periodUnit) {while (expiryDate < now) {if (subscription.periodUnit === 'day') {expiryDate.setDate(expiryDate.getDate() + subscription.periodValue);} else if (subscription.periodUnit === 'month') {expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue);} else if (subscription.periodUnit === 'year') {expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue);}}subscription.expiryDate = expiryDate.toISOString();}const newSubscription = {id: Date.now().toString(),name: subscription.name,customType: subscription.customType || '',startDate: subscription.startDate || null,expiryDate: subscription.expiryDate,periodValue: subscription.periodValue || 1,periodUnit: subscription.periodUnit || 'month',reminderDays: subscription.reminderDays !== undefined ? subscription.reminderDays : 7,notes: subscription.notes || '',isActive: subscription.isActive !== false,autoRenew: subscription.autoRenew !== false,createdAt: new Date().toISOString()};subscriptions.push(newSubscription);await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));return { success: true, subscription: newSubscription };} catch (error) {return { success: false, message: '创建订阅失败' };}}async function updateSubscription(id, subscription, env) {try {const subscriptions = await getAllSubscriptions(env);const index = subscriptions.findIndex(s => s.id === id);if (index === -1) {return { success: false, message: '订阅不存在' };}if (!subscription.name || !subscription.expiryDate) {return { success: false, message: '缺少必填字段' };}let expiryDate = new Date(subscription.expiryDate);const now = new Date();if (expiryDate < now && subscription.periodValue && subscription.periodUnit) {while (expiryDate < now) {if (subscription.periodUnit === 'day') {expiryDate.setDate(expiryDate.getDate() + subscription.periodValue);} else if (subscription.periodUnit === 'month') {expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue);} else if (subscription.periodUnit === 'year') {expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue);}}subscription.expiryDate = expiryDate.toISOString();}subscriptions[index] = {...subscriptions[index],name: subscription.name,customType: subscription.customType || subscriptions[index].customType || '',startDate: subscription.startDate || subscriptions[index].startDate,expiryDate: subscription.expiryDate,periodValue: subscription.periodValue || subscriptions[index].periodValue || 1,periodUnit: subscription.periodUnit || subscriptions[index].periodUnit || 'month',reminderDays: subscription.reminderDays !== undefined ? subscription.reminderDays : (subscriptions[index].reminderDays !== undefined ? subscriptions[index].reminderDays : 7),notes: subscription.notes || '',isActive: subscription.isActive !== undefined ? subscription.isActive : subscriptions[index].isActive,autoRenew: subscription.autoRenew !== undefined ? subscription.autoRenew : (subscriptions[index].autoRenew !== undefined ? subscriptions[index].autoRenew : true),updatedAt: new Date().toISOString()};await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));return { success: true, subscription: subscriptions[index] };} catch (error) {return { success: false, message: '更新订阅失败' };}}async function deleteSubscription(id, env) {try {const subscriptions = await getAllSubscriptions(env);const filteredSubscriptions = subscriptions.filter(s => s.id !== id);if (filteredSubscriptions.length === subscriptions.length) {return { success: false, message: '订阅不存在' };}await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(filteredSubscriptions));return { success: true };} catch (error) {return { success: false, message: '删除订阅失败' };}}async function toggleSubscriptionStatus(id, isActive, env) {try {const subscriptions = await getAllSubscriptions(env);const index = subscriptions.findIndex(s => s.id === id);if (index === -1) {return { success: false, message: '订阅不存在' };}subscriptions[index] = {...subscriptions[index],isActive: isActive,updatedAt: new Date().toISOString()};await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));return { success: true, subscription: subscriptions[index] };} catch (error) {return { success: false, message: '更新订阅状态失败' };}}async function testSingleSubscriptionNotification(id, env) {try {const subscription = await getSubscription(id, env);if (!subscription) {return { success: false, message: '未找到该订阅' };}const config = await getConfig(env);const title = `手动测试通知: ${subscription.name}`;const description = `这是一个对订阅 "${subscription.name}" 的手动测试通知。`;let content = '';// 根据所选通知渠道格式化消息内容,与主提醒功能保持一致if (config.NOTIFICATION_TYPE === 'notifyx') {content = `## ${title}\n\n**订阅详情**:\n- **类型**: ${subscription.customType || '其他'}\n- **到期日**: ${new Date(subscription.expiryDate).toLocaleDateString()}\n- **备注**: ${subscription.notes || '无'}`;} else { // 默认 Telegramcontent = `*${title}*\n\n**订阅详情**:\n- **类型**: ${subscription.customType || '其他'}\n- **到期日**: ${new Date(subscription.expiryDate).toLocaleDateString()}\n- **备注**: ${subscription.notes || '无'}`;}const success = await sendNotification(title, content, description, config);if (success) {return { success: true, message: '测试通知已成功发送' };} else {return { success: false, message: '测试通知发送失败,请检查配置' };}} catch (error) {console.error('[手动测试] 发送失败:', error);return { success: false, message: '发送时发生错误: ' + error.message };}}async function sendWeComNotification(message, config) {// This is a placeholder. In a real scenario, you would implement the WeCom notification logic here.console.log("[企业微信] 通知功能未实现");return { success: false, message: "企业微信通知功能未实现" };}async function sendNotificationToAllChannels(title, commonContent, config, logPrefix = '[定时任务]') {if (!config.ENABLED_NOTIFIERS || config.ENABLED_NOTIFIERS.length === 0) {console.log(`${logPrefix} 未启用任何通知渠道。`);return;}if (config.ENABLED_NOTIFIERS.includes('notifyx')) {const notifyxContent = `## ${title}\n\n${commonContent}`;const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config);console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`);}if (config.ENABLED_NOTIFIERS.includes('telegram')) {const telegramContent = `*${title}*\n\n${commonContent.replace(/(\s)/g, ' ')}`;const success = await sendTelegramNotification(telegramContent, config);console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`);}if (config.ENABLED_NOTIFIERS.includes('weixin')) {const weixinContent = `【${title}】\n\n${commonContent.replace(/(\**|\*|##|#|`)/g, '')}`;const result = await sendWeComNotification(weixinContent, config);console.log(`${logPrefix} 发送企业微信通知 ${result.success ? '成功' : '失败'}. ${result.message}`);}}async function sendTelegramNotification(message, config) {try {if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) {console.error('[Telegram] 通知未配置,缺少Bot Token或Chat ID');return false;}console.log('[Telegram] 开始发送通知到 Chat ID: ' + config.TG_CHAT_ID);const url = 'https://api.telegram.org/bot' + config.TG_BOT_TOKEN + '/sendMessage';const response = await fetch(url, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({chat_id: config.TG_CHAT_ID,text: message,parse_mode: 'Markdown'})});const result = await response.json();console.log('[Telegram] 发送结果:', result);return result.ok;} catch (error) {console.error('[Telegram] 发送通知失败:', error);return false;}}async function sendNotifyXNotification(title, content, description, config) {try {if (!config.NOTIFYX_API_KEY) {console.error('[NotifyX] 通知未配置,缺少API Key');return false;}console.log('[NotifyX] 开始发送通知: ' + title);const url = 'https://www.notifyx.cn/api/v1/send/' + config.NOTIFYX_API_KEY;const response = await fetch(url, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({title: title,content: content,description: description || ''})});const result = await response.json();console.log('[NotifyX] 发送结果:', result);return result.status === 'queued';} catch (error) {console.error('[NotifyX] 发送通知失败:', error);return false;}}async function sendNotification(title, content, description, config) {if (config.NOTIFICATION_TYPE === 'notifyx') {return await sendNotifyXNotification(title, content, description, config);} else {return await sendTelegramNotification(content, config);}}// 定时检查即将到期的订阅 - 完全优化版async function checkExpiringSubscriptions(env) {try {console.log('[定时任务] 开始检查即将到期的订阅: ' + new Date().toISOString());const subscriptions = await getAllSubscriptions(env);console.log('[定时任务] 共找到 ' + subscriptions.length + ' 个订阅');const config = await getConfig(env);const now = new Date();const expiringSubscriptions = [];const updatedSubscriptions = [];let hasUpdates = false;for (const subscription of subscriptions) {if (subscription.isActive === false) {console.log('[定时任务] 订阅 "' + subscription.name + '" 已停用,跳过');continue;}const expiryDate = new Date(subscription.expiryDate);const daysDiff = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));console.log('[定时任务] 订阅 "' + subscription.name + '" 到期日期: ' + expiryDate.toISOString() + ', 剩余天数: ' + daysDiff);// 修复提前提醒天数逻辑const reminderDays = subscription.reminderDays !== undefined ? subscription.reminderDays : 7;let shouldRemind = false;if (reminderDays === 0) {// 当提前提醒天数为0时,只在到期日当天提醒shouldRemind = daysDiff === 0;} else {// 当提前提醒天数大于0时,在指定范围内提醒shouldRemind = daysDiff >= 0 && daysDiff <= reminderDays;}// 如果已过期,且设置了周期和自动续订,则自动更新到下一个周期if (daysDiff < 0 && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) {console.log('[定时任务] 订阅 "' + subscription.name + '" 已过期且启用自动续订,正在更新到下一个周期');const newExpiryDate = new Date(expiryDate);if (subscription.periodUnit === 'day') {newExpiryDate.setDate(expiryDate.getDate() + subscription.periodValue);} else if (subscription.periodUnit === 'month') {newExpiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue);} else if (subscription.periodUnit === 'year') {newExpiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue);}while (newExpiryDate < now) {console.log('[定时任务] 新计算的到期日期 ' + newExpiryDate.toISOString() + ' 仍然过期,继续计算下一个周期');if (subscription.periodUnit === 'day') {newExpiryDate.setDate(newExpiryDate.getDate() + subscription.periodValue);} else if (subscription.periodUnit === 'month') {newExpiryDate.setMonth(newExpiryDate.getMonth() + subscription.periodValue);} else if (subscription.periodUnit === 'year') {newExpiryDate.setFullYear(newExpiryDate.getFullYear() + subscription.periodValue);}}console.log('[定时任务] 订阅 "' + subscription.name + '" 更新到期日期: ' + newExpiryDate.toISOString());const updatedSubscription = { ...subscription, expiryDate: newExpiryDate.toISOString() };updatedSubscriptions.push(updatedSubscription);hasUpdates = true;const newDaysDiff = Math.ceil((newExpiryDate - now) / (1000 * 60 * 60 * 24));let shouldRemindAfterRenewal = false;if (reminderDays === 0) {shouldRemindAfterRenewal = newDaysDiff === 0;} else {shouldRemindAfterRenewal = newDaysDiff >= 0 && newDaysDiff <= reminderDays;}if (shouldRemindAfterRenewal) {console.log('[定时任务] 订阅 "' + subscription.name + '" 在提醒范围内,将发送通知');expiringSubscriptions.push({...updatedSubscription,daysRemaining: newDaysDiff});}} else if (daysDiff < 0 && subscription.autoRenew === false) {console.log('[定时任务] 订阅 "' + subscription.name + '" 已过期且未启用自动续订,将发送过期通知');expiringSubscriptions.push({...subscription,daysRemaining: daysDiff});} else if (shouldRemind) {console.log('[定时任务] 订阅 "' + subscription.name + '" 在提醒范围内,将发送通知');expiringSubscriptions.push({...subscription,daysRemaining: daysDiff});}}if (hasUpdates) {console.log('[定时任务] 有 ' + updatedSubscriptions.length + ' 个订阅需要更新到下一个周期');const mergedSubscriptions = subscriptions.map(sub => {const updated = updatedSubscriptions.find(u => u.id === sub.id);return updated || sub;});await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(mergedSubscriptions));console.log('[定时任务] 已更新订阅列表');}if (expiringSubscriptions.length > 0) {console.log('[定时任务] 有 ' + expiringSubscriptions.length + ' 个订阅需要发送通知');let commonContent = '';expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining);for (const sub of expiringSubscriptions) {const typeText = sub.customType || '其他';const periodText = (sub.periodValue && sub.periodUnit) ? `(周期: ${sub.periodValue} ${ { day: '天', month: '月', year: '年' }[sub.periodUnit] || sub.periodUnit})` : '';let statusText;if (sub.daysRemaining === 0) statusText = `⚠️ **${sub.name}** (${typeText}) ${periodText} 今天到期!`;else if (sub.daysRemaining < 0) statusText = `🚨 **${sub.name}** (${typeText}) ${periodText} 已过期 ${Math.abs(sub.daysRemaining)} 天`;else statusText = `📅 **${sub.name}** (${typeText}) ${periodText} 将在 ${sub.daysRemaining} 天后到期`;if (sub.notes) statusText += `\n 备注: ${sub.notes}`;commonContent += statusText + '\n\n';}await sendNotificationToAllChannels(title, commonContent, config, logPrefix);} else {console.log('[定时任务] 没有需要提醒的订阅');}console.log('[定时任务] 检查完成');} catch (error) {console.error('[定时任务] 检查即将到期的订阅失败:', error);}}function getCookieValue(cookieString, key) {if (!cookieString) return null;const match = cookieString.match(new RegExp('(^| )' + key + '=([^;]+)'));return match ? match[2] : null;}async function handleRequest(request, env, ctx) {return new Response(loginPage, {headers: { 'Content-Type': 'text/html; charset=utf-8' }});}const CryptoJS = {HmacSHA256: function(message, key) {const keyData = new TextEncoder().encode(key);const messageData = new TextEncoder().encode(message);return Promise.resolve().then(() => {return crypto.subtle.importKey("raw",keyData,{ name: "HMAC", hash: {name: "SHA-256"} },false,["sign"]);}).then(cryptoKey => {return crypto.subtle.sign("HMAC",cryptoKey,messageData);}).then(buffer => {const hashArray = Array.from(new Uint8Array(buffer));return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');});}};export default {async fetch(request, env, ctx) {const url = new URL(request.url);if (url.pathname.startsWith('/api')) {return api.handleRequest(request, env, ctx);} else if (url.pathname.startsWith('/admin')) {return admin.handleRequest(request, env, ctx);} else {return handleRequest(request, env, ctx);}},async scheduled(event, env, ctx) {console.log('[Workers] 定时任务触发时间:', new Date().toISOString());await checkExpiringSubscriptions(env);}};
SubsTracker 开源项目【仓库】
版本更新
V0: TG通知 获取 Bot Token 和Chat ID 的方法:
TG 上搜索: @BotFather
然后输入命令:/newbot 创建一个TG机器人
接着搜索:@getmyid_bot 这个机器人并发送信息/start 给它就可以获得你的 Chat ID
v1: TG通知+NotifyX通知
V2:
✅ 订阅列表按到期日期升序排序
✅ 修复了提醒天数逻辑(reminderDays: 0 只在到期日提醒)
✅ 添加了自动续费切换功能(autoRenew 字段)
✅ 增强了测试通知功能(在配置页面独立测试按钮)
✅ 实现了Toast通知系统
✅ 表单验证和错误处理
✅ 安全配置(不返回敏感信息)
🚀 部署指南
前提条件
- Cloudflare账户
- Telegram Bot (用于发送通知)
- 可以直接将代码丢给AI,帮助查漏补缺
部署步骤
1.登陆cloudflare,创建worker,粘贴本项目中的js代码,点击部署
2.创建KV键值 SUBSCRIPTIONS_KV
3.给worker绑定上键值对,以及设置定时执行时间!
4.打开worker提供的域名地址,输入默认账号密码: admin password (或者是:admin admin123),可以在代码中查看默认账号密码!
5.前往系统配置,修改账号密码,以及配置tg通知的信息
6.配置完成可以点击测试通知,查看是否能够正常通知,然后就可以正常添加订阅使用了!
赞助
- 取得連結
- X
- 以電子郵件傳送
- 其他應用程式
留言
張貼留言