项目截图展示:
![图片[1]-SubsTracker – 订阅管理与提醒系统,再也不怕忘记续费-米渡资源网](https://www.micdu.com/wp-content/uploads/2025/09/20250827225534154-SubsTracker.webp)
![图片[2]-SubsTracker – 订阅管理与提醒系统,再也不怕忘记续费-米渡资源网](https://www.micdu.com/wp-content/uploads/2025/09/20250827225941857-wechat_2025-08-27_225715_124.webp)
百度网盘视频:
SubsTracker – 订阅管理
提取码: axcx
离线下载观看:
SubsTracker – 订阅管理
格式:mkv
大小:57.8 MB
项目特性:
- 🔔 自动提醒: 在订阅到期前自动发送Telegram通知
- 📊 订阅管理: 直观的Web界面管理所有订阅
- 🔄 周期计算: 智能计算循环订阅的下一个周期
- 📱 响应式设计: 完美适配移动端和桌面设备
- ☁️ 免服务器: 基于Cloudflare Workers,无需自建服务器
- 🔒 安全可靠: 数据存储在Cloudflare KV中,安全且高效
项目资料:
代码部署:
// 订阅续期通知网站 - 基于CloudFlare Workers (完全优化版)
// 时区处理工具函数
function getCurrentTimeInTimezone(timezone = 'UTC') {
try {
// 直接返回当前时间,时区转换在后续计算中处理
// 这样可以避免时区信息丢失的问题
return new Date();
} catch (error) {
console.error(`时区转换错误: ${error.message}`);
// 如果时区无效,返回UTC时间
return new Date();
}
}
function getTimestampInTimezone(timezone = 'UTC') {
return getCurrentTimeInTimezone(timezone).getTime();
}
function convertUTCToTimezone(utcTime, timezone = 'UTC') {
try {
// 直接返回原始时间,时区转换在后续计算中处理
// 这样可以避免时区信息丢失的问题
return new Date(utcTime);
} catch (error) {
console.error(`时区转换错误: ${error.message}`);
return new Date(utcTime);
}
}
function calculateExpirationTime(expirationMinutes, timezone = 'UTC') {
const currentTime = getCurrentTimeInTimezone(timezone);
const expirationTime = new Date(currentTime.getTime() + (expirationMinutes * 60 * 1000));
return expirationTime;
}
function isExpired(targetTime, timezone = 'UTC') {
const currentTime = getCurrentTimeInTimezone(timezone);
const target = new Date(targetTime);
return currentTime > target;
}
function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') {
try {
const date = new Date(time);
if (format === 'date') {
return date.toLocaleDateString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
} else if (format === 'datetime') {
return date.toLocaleString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
// full format
return date.toLocaleString('zh-CN', {
timeZone: timezone
});
}
} catch (error) {
console.error(`时间格式化错误: ${error.message}`);
return new Date(time).toISOString();
}
}
function getTimezoneOffset(timezone = 'UTC') {
try {
// 使用更准确的时区偏移计算方法
const now = new Date();
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const parts = dtf.formatToParts(now);
const get = type => Number(parts.find(x => x.type === type).value);
const target = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
const utc = now.getTime();
return Math.round((target - utc) / (1000 * 60 * 60));
} catch (error) {
console.error(`获取时区偏移量错误: ${error.message}`);
return 0;
}
}
// 格式化时区显示,包含UTC偏移
function formatTimezoneDisplay(timezone = 'UTC') {
try {
const offset = getTimezoneOffset(timezone);
const offsetStr = offset >= 0 ? `+${offset}` : `${offset}`;
// 时区中文名称映射
const timezoneNames = {
'UTC': '世界标准时间',
'Asia/Shanghai': '中国标准时间',
'Asia/Hong_Kong': '香港时间',
'Asia/Taipei': '台北时间',
'Asia/Singapore': '新加坡时间',
'Asia/Tokyo': '日本时间',
'Asia/Seoul': '韩国时间',
'America/New_York': '美国东部时间',
'America/Los_Angeles': '美国太平洋时间',
'America/Chicago': '美国中部时间',
'America/Denver': '美国山地时间',
'Europe/London': '英国时间',
'Europe/Paris': '巴黎时间',
'Europe/Berlin': '柏林时间',
'Europe/Moscow': '莫斯科时间',
'Australia/Sydney': '悉尼时间',
'Australia/Melbourne': '墨尔本时间',
'Pacific/Auckland': '奥克兰时间'
};
const timezoneName = timezoneNames[timezone] || timezone;
return `${timezoneName} (UTC${offsetStr})`;
} catch (error) {
console.error('格式化时区显示失败:', error);
return timezone;
}
}
// 兼容性函数 - 保持原有接口
function formatBeijingTime(date = new Date(), format = 'full') {
return formatTimeInTimezone(date, 'Asia/Shanghai', format);
}
// 时区处理中间件函数
function extractTimezone(request) {
// 优先级:URL参数 > 请求头 > 默认值
const url = new URL(request.url);
const timezoneParam = url.searchParams.get('timezone');
if (timezoneParam) {
return timezoneParam;
}
// 从请求头获取时区
const timezoneHeader = request.headers.get('X-Timezone');
if (timezoneHeader) {
return timezoneHeader;
}
// 从Accept-Language头推断时区(简化处理)
const acceptLanguage = request.headers.get('Accept-Language');
if (acceptLanguage) {
// 简单的时区推断逻辑
if (acceptLanguage.includes('zh')) {
return 'Asia/Shanghai';
} else if (acceptLanguage.includes('en-US')) {
return 'America/New_York';
} else if (acceptLanguage.includes('en-GB')) {
return 'Europe/London';
}
}
// 默认返回UTC
return 'UTC';
}
function isValidTimezone(timezone) {
try {
// 尝试使用该时区格式化时间
new Date().toLocaleString('en-US', { timeZone: timezone });
return true;
} catch (error) {
return false;
}
}
// 农历转换工具函数
const lunarCalendar = {
// 农历数据 (1900-2100年)
lunarInfo: [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0
],
// 天干地支
gan: ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'],
zhi: ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'],
// 农历月份
months: ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'],
// 农历日期
days: ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'],
// 获取农历年天数
lunarYearDays: function(year) {
let sum = 348;
for (let i = 0x8000; i > 0x8; i >>= 1) {
sum += (this.lunarInfo[year - 1900] & i) ? 1 : 0;
}
return sum + this.leapDays(year);
},
// 获取闰月天数
leapDays: function(year) {
if (this.leapMonth(year)) {
return (this.lunarInfo[year - 1900] & 0x10000) ? 30 : 29;
}
return 0;
},
// 获取闰月月份
leapMonth: function(year) {
return this.lunarInfo[year - 1900] & 0xf;
},
// 获取农历月天数
monthDays: function(year, month) {
return (this.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29;
},
// 公历转农历
solar2lunar: function(year, month, day) {
if (year < 1900 || year > 2100) return null;
const baseDate = new Date(1900, 0, 31);
const objDate = new Date(year, month - 1, day);
//let offset = Math.floor((objDate - baseDate) / 86400000);
let offset = Math.round((objDate - baseDate) / 86400000);
let temp = 0;
let lunarYear = 1900;
for (lunarYear = 1900; lunarYear < 2101 && offset > 0; lunarYear++) {
temp = this.lunarYearDays(lunarYear);
offset -= temp;
}
if (offset < 0) {
offset += temp;
lunarYear--;
}
let lunarMonth = 1;
let leap = this.leapMonth(lunarYear);
let isLeap = false;
for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) {
if (leap > 0 && lunarMonth === (leap + 1) && !isLeap) {
--lunarMonth;
isLeap = true;
temp = this.leapDays(lunarYear);
} else {
temp = this.monthDays(lunarYear, lunarMonth);
}
if (isLeap && lunarMonth === (leap + 1)) isLeap = false;
offset -= temp;
}
if (offset === 0 && leap > 0 && lunarMonth === leap + 1) {
if (isLeap) {
isLeap = false;
} else {
isLeap = true;
--lunarMonth;
}
}
if (offset < 0) {
offset += temp;
--lunarMonth;
}
const lunarDay = offset + 1;
// 生成农历字符串
const ganIndex = (lunarYear - 4) % 10;
const zhiIndex = (lunarYear - 4) % 12;
const yearStr = this.gan[ganIndex] + this.zhi[zhiIndex] + '年';
const monthStr = (isLeap ? '闰' : '') + this.months[lunarMonth - 1] + '月';
const dayStr = this.days[lunarDay - 1];
return {
year: lunarYear,
month: lunarMonth,
day: lunarDay,
isLeap: isLeap,
yearStr: yearStr,
monthStr: monthStr,
dayStr: dayStr,
fullStr: yearStr + monthStr + dayStr
};
}
};
// 1. 新增 lunarBiz 工具模块,支持农历加周期、农历转公历、农历距离天数
const lunarBiz = {
// 农历加周期,返回新的农历日期对象
addLunarPeriod(lunar, periodValue, periodUnit) {
let { year, month, day, isLeap } = lunar;
if (periodUnit === 'year') {
year += periodValue;
const leap = lunarCalendar.leapMonth(year);
if (isLeap && leap === month) {
isLeap = true;
} else {
isLeap = false;
}
} else if (periodUnit === 'month') {
let totalMonths = (year - 1900) * 12 + (month - 1) + periodValue;
year = Math.floor(totalMonths / 12) + 1900;
month = (totalMonths % 12) + 1;
const leap = lunarCalendar.leapMonth(year);
if (isLeap && leap === month) {
isLeap = true;
} else {
isLeap = false;
}
} else if (periodUnit === 'day') {
const solar = lunarBiz.lunar2solar(lunar);
const date = new Date(solar.year, solar.month - 1, solar.day + periodValue);
return lunarCalendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
let maxDay = isLeap
? lunarCalendar.leapDays(year)
: lunarCalendar.monthDays(year, month);
let targetDay = Math.min(day, maxDay);
while (targetDay > 0) {
let solar = lunarBiz.lunar2solar({ year, month, day: targetDay, isLeap });
if (solar) {
return { year, month, day: targetDay, isLeap };
}
targetDay--;
}
return { year, month, day, isLeap };
},
// 农历转公历(遍历法,适用1900-2100年)
lunar2solar(lunar) {
for (let y = lunar.year - 1; y <= lunar.year + 1; y++) {
for (let m = 1; m <= 12; m++) {
for (let d = 1; d <= 31; d++) {
const date = new Date(y, m - 1, d);
if (date.getFullYear() !== y || date.getMonth() + 1 !== m || date.getDate() !== d) continue;
const l = lunarCalendar.solar2lunar(y, m, d);
if (
l &&
l.year === lunar.year &&
l.month === lunar.month &&
l.day === lunar.day &&
l.isLeap === lunar.isLeap
) {
return { year: y, month: m, day: d };
}
}
}
}
return null;
},
// 距离农历日期还有多少天
daysToLunar(lunar) {
const solar = lunarBiz.lunar2solar(lunar);
const date = new Date(solar.year, solar.month - 1, solar.day);
const now = new Date();
return Math.ceil((date - now) / (1000 * 60 * 60 * 24));
}
};
// 定义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://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
<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" required
class="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" required
class="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://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
<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; }
/* 通用悬浮提示优化 */
.hover-container {
position: relative;
width: 100%;
}
.hover-text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: all 0.3s ease;
display: block;
}
.hover-text:hover { color: #3b82f6; }
.hover-tooltip {
position: fixed;
z-index: 9999;
background: #1f2937;
color: white;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.875rem;
max-width: 320px;
word-wrap: break-word;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
transform: translateY(-10px);
white-space: normal;
pointer-events: none;
line-height: 1.4;
}
.hover-tooltip.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.hover-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 20px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #1f2937;
}
.hover-tooltip.tooltip-above::before {
top: auto;
bottom: -6px;
border-bottom: none;
border-top: 6px solid #1f2937;
}
/* 备注显示优化 */
.notes-container {
position: relative;
max-width: 200px;
width: 100%;
}
.notes-text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: all 0.3s ease;
display: block;
}
.notes-text:hover { color: #3b82f6; }
.notes-tooltip {
position: fixed;
z-index: 9999;
background: #1f2937;
color: white;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.875rem;
max-width: 320px;
word-wrap: break-word;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
transform: translateY(-10px);
white-space: normal;
pointer-events: none;
line-height: 1.4;
}
.notes-tooltip.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.notes-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 20px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #1f2937;
}
.notes-tooltip.tooltip-above::before {
top: auto;
bottom: -6px;
border-bottom: none;
border-top: 6px solid #1f2937;
}
/* 农历显示样式 */
.lunar-display {
font-size: 0.75rem;
color: #6366f1;
margin-top: 2px;
opacity: 0;
transition: opacity 0.3s ease;
}
.lunar-display.show {
opacity: 1;
}
/* 自定义日期选择器样式 */
.hidden {
display: none !important;
}
.custom-date-picker {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
border-radius: 12px;
min-width: 380px;
}
.custom-date-picker .calendar-day {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 48px;
height: 60px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
padding: 4px;
font-size: 14px;
}
.custom-date-picker .calendar-day:hover {
background-color: #e0e7ff;
transform: scale(1.05);
}
.custom-date-picker .calendar-day.selected {
background-color: #6366f1;
color: white;
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.custom-date-picker .calendar-day.today {
background-color: #e0e7ff;
color: #6366f1;
font-weight: 600;
border: 2px solid #6366f1;
}
.custom-date-picker .calendar-day.other-month {
color: #d1d5db;
}
.custom-date-picker .calendar-day .lunar-text {
font-size: 11px;
line-height: 1.2;
margin-top: 3px;
opacity: 0.85;
text-align: center;
font-weight: 500;
}
.custom-date-picker .calendar-day.selected .lunar-text {
color: rgba(255, 255, 255, 0.9);
}
.custom-date-picker .calendar-day.today .lunar-text {
color: #6366f1;
}
/* 月份和年份选择器样式 */
.month-option, .year-option {
transition: all 0.2s ease;
border: 1px solid transparent;
}
.month-option:hover, .year-option:hover {
background-color: #e0e7ff !important;
border-color: #6366f1;
color: #6366f1;
}
.month-option.selected, .year-option.selected {
background-color: #6366f1 !important;
color: white;
border-color: #6366f1;
}
.lunar-toggle {
display: inline-flex;
align-items: center;
margin-bottom: 8px;
font-size: 0.875rem;
}
.lunar-toggle input[type="checkbox"] {
margin-right: 6px;
}
/* 表格布局优化 */
.table-container {
width: 100%;
overflow: visible;
}
.table-container table {
table-layout: fixed;
width: 100%;
}
/* 防止表格内容溢出 */
.table-container td {
overflow: hidden;
word-wrap: break-word;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 响应式优化 */
.responsive-table { table-layout: fixed; width: 100%; }
.td-content-wrapper { word-wrap: break-word; white-space: normal; text-align: left; width: 100%; }
.td-content-wrapper > * { text-align: left; } /* Align content left within the wrapper */
@media (max-width: 767px) {
.table-container { overflow-x: initial; } /* Override previous setting */
.responsive-table thead { display: none; }
.responsive-table tbody, .responsive-table tr, .responsive-table td { display: block; width: 100%; }
.responsive-table tr { margin-bottom: 1.5rem; border: 1px solid #ddd; border-radius: 0.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); overflow: hidden; }
.responsive-table td { display: flex; justify-content: flex-start; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.responsive-table td:last-of-type { border-bottom: none; }
.responsive-table td:before { content: attr(data-label); font-weight: 600; text-align: left; padding-right: 1rem; color: #374151; white-space: nowrap; }
.action-buttons-wrapper { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; }
.notes-container, .hover-container {
max-width: 180px; /* Adjust for new layout */
text-align: right;
}
.td-content-wrapper .notes-text {
text-align: right;
}
}
@media (max-width: 767px) {
#systemTimeDisplay {
display: none !important;
}
}
@media (min-width: 768px) {
.table-container {
overflow: visible;
}
/* .td-content-wrapper is aligned left by default */
}
/* 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>
<span id="systemTimeDisplay" class="ml-4 text-base text-indigo-600 font-normal"></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 items-center space-x-4">
<label class="lunar-toggle">
<input type="checkbox" id="listShowLunar" class="form-checkbox h-4 w-4 text-indigo-600">
<span class="text-gray-700">显示农历</span>
</label>
<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">
<div class="overflow-x-auto">
<table class="w-full divide-y divide-gray-200 responsive-table">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 25%;">
名称
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 15%;">
类型
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 20%;">
到期时间 <i class="fas fa-sort-up ml-1 text-indigo-500" title="按到期时间升序排列"></i>
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 15%;">
提醒设置
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 10%;">
状态
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="width: 15%;">
操作
</th>
</tr>
</thead>
<tbody id="subscriptionsBody" class="bg-white divide-y divide-gray-200">
</tbody>
</table>
</div>
</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" required
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="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="mb-4 flex items-center space-x-6">
<label class="lunar-toggle">
<input type="checkbox" id="showLunar" class="form-checkbox h-4 w-4 text-indigo-600">
<span class="text-gray-700">显示农历日期</span>
</label>
<label class="lunar-toggle">
<input type="checkbox" id="useLunar" class="form-checkbox h-4 w-4 text-indigo-600">
<span class="text-gray-700">周期按农历</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label for="startDate" class="block text-sm font-medium text-gray-700 mb-1">开始日期</label>
<div class="relative">
<input type="text" id="startDate" readonly
class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 cursor-pointer"
placeholder="点击选择日期">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-calendar text-gray-400"></i>
</div>
<div id="startDatePicker" class="custom-date-picker hidden absolute top-full left-0 z-50 bg-white border border-gray-300 rounded-md shadow-lg p-6 min-w-[380px]">
<div class="flex justify-between items-center mb-4">
<button type="button" id="startDatePrevMonth" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-left"></i>
</button>
<div class="flex items-center space-x-2">
<span id="startDateMonth" class="font-medium text-gray-900 cursor-pointer hover:text-indigo-600">1月</span>
<span class="text-gray-400">|</span>
<span id="startDateYear" class="font-medium text-gray-900 cursor-pointer hover:text-indigo-600">2024</span>
</div>
<button type="button" id="startDateNextMonth" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- 月份选择器 -->
<div id="startDateMonthPicker" class="hidden mb-4">
<div class="flex justify-between items-center mb-3">
<span class="font-medium text-gray-900">选择月份</span>
<button type="button" id="startDateBackToCalendar" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-times"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="0">1月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="1">2月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="2">3月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="3">4月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="4">5月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="5">6月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="6">7月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="7">8月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="8">9月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="9">10月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="10">11月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="11">12月</button>
</div>
</div>
<!-- 年份选择器 -->
<div id="startDateYearPicker" class="hidden mb-4">
<div class="flex justify-between items-center mb-3">
<span class="font-medium text-gray-900">选择年份</span>
<button type="button" id="startDateBackToCalendarFromYear" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex justify-between items-center mb-3">
<button type="button" id="startDatePrevYearDecade" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-left"></i>
</button>
<span id="startDateYearRange" class="font-medium text-gray-900">2020-2029</span>
<button type="button" id="startDateNextYearDecade" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div id="startDateYearGrid" class="grid grid-cols-3 gap-2">
<!-- 年份按钮将通过JavaScript动态生成 -->
</div>
</div>
<div class="grid grid-cols-7 gap-2 mb-3">
<div class="text-center text-sm font-semibold text-gray-600 py-2">日</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">一</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">二</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">三</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">四</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">五</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">六</div>
</div>
<div id="startDateCalendar" class="grid grid-cols-7 gap-2"></div>
<!-- 回到今天按钮 -->
<div class="mt-4 pt-3 border-t border-gray-200">
<button type="button" id="startDateGoToToday" class="w-full px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 rounded-md">
<i class="fas fa-calendar-day mr-2"></i>回到今天
</button>
</div>
</div>
</div>
<div id="startDateLunar" class="lunar-display"></div>
<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" required
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="periodUnit" class="block text-sm font-medium text-gray-700 mb-1">周期单位 *</label>
<select id="periodUnit" required
class="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>
<div class="relative">
<input type="text" id="expiryDate" readonly required
class="readonly-input w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none cursor-pointer"
placeholder="点击选择日期">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-calendar text-gray-400"></i>
</div>
<div id="expiryDatePicker" class="custom-date-picker hidden absolute top-full left-0 z-50 bg-white border border-gray-300 rounded-md shadow-lg p-6 min-w-[380px]">
<div class="flex justify-between items-center mb-4">
<button type="button" id="expiryDatePrevMonth" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-left"></i>
</button>
<div class="flex items-center space-x-2">
<span id="expiryDateMonth" class="font-medium text-gray-900 cursor-pointer hover:text-indigo-600">1月</span>
<span class="text-gray-400">|</span>
<span id="expiryDateYear" class="font-medium text-gray-900 cursor-pointer hover:text-indigo-600">2024</span>
</div>
<button type="button" id="expiryDateNextMonth" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- 月份选择器 -->
<div id="expiryDateMonthPicker" class="hidden mb-4">
<div class="flex justify-between items-center mb-3">
<span class="font-medium text-gray-900">选择月份</span>
<button type="button" id="expiryDateBackToCalendar" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-times"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="0">1月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="1">2月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="2">3月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="3">4月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="4">5月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="5">6月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="6">7月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="7">8月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="8">9月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="9">10月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="10">11月</button>
<button type="button" class="month-option px-3 py-2 text-sm rounded hover:bg-gray-100" data-month="11">12月</button>
</div>
</div>
<!-- 年份选择器 -->
<div id="expiryDateYearPicker" class="hidden mb-4">
<div class="flex justify-between items-center mb-3">
<span class="font-medium text-gray-900">选择年份</span>
<button type="button" id="expiryDateBackToCalendarFromYear" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex justify-between items-center mb-3">
<button type="button" id="expiryDatePrevYearDecade" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-left"></i>
</button>
<span id="expiryDateYearRange" class="font-medium text-gray-900">2020-2029</span>
<button type="button" id="expiryDateNextYearDecade" class="text-gray-600 hover:text-gray-800">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div id="expiryDateYearGrid" class="grid grid-cols-3 gap-2">
<!-- 年份按钮将通过JavaScript动态生成 -->
</div>
</div>
<div class="grid grid-cols-7 gap-2 mb-3">
<div class="text-center text-sm font-semibold text-gray-600 py-2">日</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">一</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">二</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">三</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">四</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">五</div>
<div class="text-center text-sm font-semibold text-gray-600 py-2">六</div>
</div>
<div id="expiryDateCalendar" class="grid grid-cols-7 gap-2"></div>
<!-- 回到今天按钮 -->
<div class="mt-4 pt-3 border-t border-gray-200">
<button type="button" id="expiryDateGoToToday" class="w-full px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 rounded-md">
<i class="fas fa-calendar-day mr-2"></i>回到今天
</button>
</div>
</div>
</div>
<div id="expiryDateLunar" class="lunar-display"></div>
<div class="error-message text-red-500"></div>
<div class="flex justify-end mt-2">
<button type="button" id="calculateExpiryBtn"
class="btn-primary text-white px-4 py-2 rounded-md text-sm font-medium whitespace-nowrap">
<i class="fas fa-calculator mr-2"></i>自动计算到期日期
</button>
</div>
</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" checked
class="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" checked
class="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 formatBeijingTime(date = new Date(), format = 'full') {
try {
const timezone = 'Asia/Shanghai';
const dateObj = new Date(date);
if (format === 'date') {
return dateObj.toLocaleDateString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
} else if (format === 'datetime') {
return dateObj.toLocaleString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
// full format
return dateObj.toLocaleString('zh-CN', {
timeZone: timezone
});
}
} catch (error) {
console.error('时间格式化错误: ' + error.message);
return new Date(date).toISOString();
}
}
// 农历转换工具函数 - 前端版本
const lunarCalendar = {
// 农历数据 (1900-2100年)
lunarInfo: [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0
],
// 天干地支
gan: ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'],
zhi: ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'],
// 农历月份
months: ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'],
// 农历日期
days: ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'],
// 获取农历年天数
lunarYearDays: function(year) {
let sum = 348;
for (let i = 0x8000; i > 0x8; i >>= 1) {
sum += (this.lunarInfo[year - 1900] & i) ? 1 : 0;
}
return sum + this.leapDays(year);
},
// 获取闰月天数
leapDays: function(year) {
if (this.leapMonth(year)) {
return (this.lunarInfo[year - 1900] & 0x10000) ? 30 : 29;
}
return 0;
},
// 获取闰月月份
leapMonth: function(year) {
return this.lunarInfo[year - 1900] & 0xf;
},
// 获取农历月天数
monthDays: function(year, month) {
return (this.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29;
},
// 公历转农历
solar2lunar: function(year, month, day) {
if (year < 1900 || year > 2100) return null;
const baseDate = new Date(1900, 0, 31);
const objDate = new Date(year, month - 1, day);
//let offset = Math.floor((objDate - baseDate) / 86400000);
let offset = Math.round((objDate - baseDate) / 86400000);
let temp = 0;
let lunarYear = 1900;
for (lunarYear = 1900; lunarYear < 2101 && offset > 0; lunarYear++) {
temp = this.lunarYearDays(lunarYear);
offset -= temp;
}
if (offset < 0) {
offset += temp;
lunarYear--;
}
let lunarMonth = 1;
let leap = this.leapMonth(lunarYear);
let isLeap = false;
for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) {
if (leap > 0 && lunarMonth === (leap + 1) && !isLeap) {
--lunarMonth;
isLeap = true;
temp = this.leapDays(lunarYear);
} else {
temp = this.monthDays(lunarYear, lunarMonth);
}
if (isLeap && lunarMonth === (leap + 1)) isLeap = false;
offset -= temp;
}
if (offset === 0 && leap > 0 && lunarMonth === leap + 1) {
if (isLeap) {
isLeap = false;
} else {
isLeap = true;
--lunarMonth;
}
}
if (offset < 0) {
offset += temp;
--lunarMonth;
}
const lunarDay = offset + 1;
// 生成农历字符串
const ganIndex = (lunarYear - 4) % 10;
const zhiIndex = (lunarYear - 4) % 12;
const yearStr = this.gan[ganIndex] + this.zhi[zhiIndex] + '年';
const monthStr = (isLeap ? '闰' : '') + this.months[lunarMonth - 1] + '月';
const dayStr = this.days[lunarDay - 1];
return {
year: lunarYear,
month: lunarMonth,
day: lunarDay,
isLeap: isLeap,
yearStr: yearStr,
monthStr: monthStr,
dayStr: dayStr,
fullStr: yearStr + monthStr + dayStr
};
}
};
// 新增修改,农历转公历(简化,适用1900-2100年)
function lunar2solar(lunar) {
for (let y = lunar.year - 1; y <= lunar.year + 1; y++) {
for (let m = 1; m <= 12; m++) {
for (let d = 1; d <= 31; d++) {
const date = new Date(y, m - 1, d);
if (date.getFullYear() !== y || date.getMonth() + 1 !== m || date.getDate() !== d) continue;
const l = lunarCalendar.solar2lunar(y, m, d);
if (
l &&
l.year === lunar.year &&
l.month === lunar.month &&
l.day === lunar.day &&
l.isLeap === lunar.isLeap
) {
return { year: y, month: m, day: d };
}
}
}
}
return null;
}
// 新增修改,农历加周期,前期版本
function addLunarPeriod(lunar, periodValue, periodUnit) {
let { year, month, day, isLeap } = lunar;
if (periodUnit === 'year') {
year += periodValue;
const leap = lunarCalendar.leapMonth(year);
if (isLeap && leap === month) {
isLeap = true;
} else {
isLeap = false;
}
} else if (periodUnit === 'month') {
let totalMonths = (year - 1900) * 12 + (month - 1) + periodValue;
year = Math.floor(totalMonths / 12) + 1900;
month = (totalMonths % 12) + 1;
const leap = lunarCalendar.leapMonth(year);
if (isLeap && leap === month) {
isLeap = true;
} else {
isLeap = false;
}
} else if (periodUnit === 'day') {
const solar = lunar2solar(lunar);
const date = new Date(solar.year, solar.month - 1, solar.day + periodValue);
return lunarCalendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
let maxDay = isLeap
? lunarCalendar.leapDays(year)
: lunarCalendar.monthDays(year, month);
let targetDay = Math.min(day, maxDay);
while (targetDay > 0) {
let solar = lunar2solar({ year, month, day: targetDay, isLeap });
if (solar) {
return { year, month, day: targetDay, isLeap };
}
targetDay--;
}
return { year, month, day, isLeap };
}
// 前端版本的 lunarBiz 对象
const lunarBiz = {
// 农历加周期,返回新的农历日期对象
addLunarPeriod(lunar, periodValue, periodUnit) {
return addLunarPeriod(lunar, periodValue, periodUnit);
},
// 农历转公历(遍历法,适用1900-2100年)
lunar2solar(lunar) {
return lunar2solar(lunar);
},
// 距离农历日期还有多少天
daysToLunar(lunar) {
const solar = lunarBiz.lunar2solar(lunar);
const date = new Date(solar.year, solar.month - 1, solar.day);
const now = new Date();
return Math.ceil((date - now) / (1000 * 60 * 60 * 24));
}
};
// 农历显示相关函数
function updateLunarDisplay(dateInputId, lunarDisplayId) {
const dateInput = document.getElementById(dateInputId);
const lunarDisplay = document.getElementById(lunarDisplayId);
const showLunar = document.getElementById('showLunar');
if (!dateInput || !lunarDisplay) {
return;
}
if (!dateInput.value || !showLunar || !showLunar.checked) {
lunarDisplay.classList.remove('show');
return;
}
const date = new Date(dateInput.value);
const lunar = lunarCalendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
if (lunar) {
lunarDisplay.textContent = '农历:' + lunar.fullStr;
lunarDisplay.classList.add('show');
} else {
lunarDisplay.classList.remove('show');
}
}
function toggleLunarDisplay() {
const showLunar = document.getElementById('showLunar');
if (!showLunar) {
return;
}
updateLunarDisplay('startDate', 'startDateLunar');
updateLunarDisplay('expiryDate', 'expiryDateLunar');
// 保存用户偏好
localStorage.setItem('showLunar', showLunar.checked);
}
function loadLunarPreference() {
const showLunar = document.getElementById('showLunar');
if (!showLunar) {
return;
}
const saved = localStorage.getItem('showLunar');
if (saved !== null) {
showLunar.checked = saved === 'true';
} else {
showLunar.checked = true; // 默认显示
}
toggleLunarDisplay();
}
function handleListLunarToggle() {
const listShowLunar = document.getElementById('listShowLunar');
// 保存用户偏好
localStorage.setItem('showLunar', listShowLunar.checked);
// 重新加载订阅列表以应用农历显示设置
loadSubscriptions();
}
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;
}
// 创建带悬浮提示的文本元素
function createHoverText(text, maxLength = 30, className = 'text-sm text-gray-900') {
if (!text || text.length <= maxLength) {
return '<div class="' + className + '">' + text + '</div>';
}
const truncated = text.substring(0, maxLength) + '...';
return '<div class="hover-container">' +
'<div class="hover-text ' + className + '" data-full-text="' + text.replace(/"/g, '"') + '">' +
truncated +
'</div>' +
'<div class="hover-tooltip"></div>' +
'</div>';
}
// 获取所有订阅并按到期时间排序
async function loadSubscriptions() {
try {
// 加载农历显示偏好
const listShowLunar = document.getElementById('listShowLunar');
const saved = localStorage.getItem('showLunar');
if (listShowLunar) {
if (saved !== null) {
listShowLunar.checked = saved === 'true';
} else {
listShowLunar.checked = true; // 默认显示
}
}
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';
// 新增修改:日历类型显示
let calendarTypeHtml = '';
if (subscription.useLunar) {
calendarTypeHtml = '<div class="text-xs text-purple-600 mt-1">日历类型:农历</div>';
} else {
calendarTypeHtml = '<div class="text-xs text-gray-600 mt-1">日历类型:公历</div>';
}
const expiryDate = new Date(subscription.expiryDate);
// 使用配置的时区计算天数差,确保时区变化时天数计算正确
const currentTime = new Date();
// 获取当前时间在配置时区的日期部分(午夜时间)
const currentDtf = new Intl.DateTimeFormat('en-US', {
timeZone: globalTimezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const currentParts = currentDtf.formatToParts(currentTime);
const getCurrent = type => Number(currentParts.find(x => x.type === type).value);
const currentDateInTimezone = Date.UTC(getCurrent('year'), getCurrent('month') - 1, getCurrent('day'), 0, 0, 0);
// 获取到期时间在配置时区的日期部分(午夜时间)
const expiryDtf = new Intl.DateTimeFormat('en-US', {
timeZone: globalTimezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const expiryParts = expiryDtf.formatToParts(expiryDate);
const getExpiry = type => Number(expiryParts.find(x => x.type === type).value);
const expiryDateInTimezone = Date.UTC(getExpiry('year'), getExpiry('month') - 1, getExpiry('day'), 0, 0, 0);
// 计算天数差(基于时区日期的午夜时间)
const daysDiff = Math.round((expiryDateInTimezone - currentDateInTimezone) / (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>';
// 检查是否显示农历
const showLunar = listShowLunar ? listShowLunar.checked : false;
let lunarExpiryText = '';
let startLunarText = '';
if (showLunar) {
// 计算农历日期
const expiryDateObj = new Date(subscription.expiryDate);
const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate());
lunarExpiryText = lunarExpiry ? lunarExpiry.fullStr : '';
if (subscription.startDate) {
const startDateObj = new Date(subscription.startDate);
const lunarStart = lunarCalendar.solar2lunar(startDateObj.getFullYear(), startDateObj.getMonth() + 1, startDateObj.getDate());
startLunarText = lunarStart ? lunarStart.fullStr : '';
}
}
// 处理备注显示
let notesHtml = '';
if (subscription.notes) {
const notes = subscription.notes;
if (notes.length > 50) {
const truncatedNotes = notes.substring(0, 50) + '...';
notesHtml = '<div class="notes-container">' +
'<div class="notes-text text-xs text-gray-500" data-full-notes="' + notes.replace(/"/g, '"') + '">' +
truncatedNotes +
'</div>' +
'<div class="notes-tooltip"></div>' +
'</div>';
} else {
notesHtml = '<div class="text-xs text-gray-500">' + notes + '</div>';
}
}
// 生成各列内容
const nameHtml = createHoverText(subscription.name, 20, 'text-sm font-medium text-gray-900');
const typeHtml = createHoverText((subscription.customType || '其他'), 15, 'text-sm text-gray-900');
const periodHtml = periodText ? createHoverText('周期: ' + periodText, 20, 'text-xs text-gray-500 mt-1') : '';
// 到期时间相关信息 - 使用全局时区
function formatDateInTimezone(date, timezone) {
return date.toLocaleDateString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
const expiryDateText = formatDateInTimezone(new Date(subscription.expiryDate), globalTimezone);
const lunarHtml = lunarExpiryText ? createHoverText('农历: ' + lunarExpiryText, 25, 'text-xs text-blue-600 mt-1') : '';
const daysLeftText = daysDiff < 0 ? '已过期' + Math.abs(daysDiff) + '天' : '还剩' + daysDiff + '天';
const startDateText = subscription.startDate ?
'开始: ' + formatDateInTimezone(new Date(subscription.startDate), globalTimezone) + (startLunarText ? ' (' + startLunarText + ')' : '') : '';
const startDateHtml = startDateText ? createHoverText(startDateText, 30, 'text-xs text-gray-500 mt-1') : '';
//新增修改,修改日历类型
row.innerHTML =
'<td data-label="名称" class="px-4 py-3"><div class="td-content-wrapper">' +
nameHtml +
notesHtml +
'</div></td>' +
'<td data-label="类型" class="px-4 py-3"><div class="td-content-wrapper">' +
'<div class="flex items-center"><i class="fas fa-tag mr-1"></i><span>' + typeHtml + '</span></div>' +
(periodHtml ? '<div class="flex items-center">' + periodHtml + autoRenewIcon + '</div>' : '') +
calendarTypeHtml + // 新增:日历类型
'</div></td>' +
// ...existing code...
'<td data-label="到期时间" class="px-4 py-3"><div class="td-content-wrapper">' +
'<div class="text-sm text-gray-900">' + expiryDateText + '</div>' +
lunarHtml +
'<div class="text-xs text-gray-500 mt-1">' + daysLeftText + '</div>' +
startDateHtml +
'</div></td>' +
// ...existing code...
'<td data-label="提醒设置" class="px-4 py-3"><div class="td-content-wrapper">' +
'<div><i class="fas fa-bell mr-1"></i>提前' + (subscription.reminderDays || 0) + '天</div>' +
(subscription.reminderDays === 0 ? '<div class="text-xs text-gray-500 mt-1">仅到期日提醒</div>' : '') +
'</div></td>' +
'<td data-label="状态" class="px-4 py-3"><div class="td-content-wrapper">' + statusHtml + '</div></td>' +
'<td data-label="操作" class="px-4 py-3">' +
'<div class="action-buttons-wrapper">' +
'<button class="edit btn-primary text-white px-2 py-1 rounded text-xs whitespace-nowrap" 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 whitespace-nowrap" 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 whitespace-nowrap" 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 whitespace-nowrap" 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 whitespace-nowrap" 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);
});
// 添加悬停功能
function addHoverListeners() {
// 计算悬浮提示位置
function positionTooltip(element, tooltip) {
const rect = element.getBoundingClientRect();
const tooltipHeight = 100; // 预估高度
const viewportHeight = window.innerHeight;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
let top = rect.bottom + scrollTop + 8;
let left = rect.left;
// 如果下方空间不够,显示在上方
if (rect.bottom + tooltipHeight > viewportHeight) {
top = rect.top + scrollTop - tooltipHeight - 8;
tooltip.style.transform = 'translateY(10px)';
// 调整箭头位置
tooltip.classList.add('tooltip-above');
} else {
tooltip.style.transform = 'translateY(-10px)';
tooltip.classList.remove('tooltip-above');
}
// 确保不超出右边界
const maxLeft = window.innerWidth - 320 - 20;
if (left > maxLeft) {
left = maxLeft;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
// 备注悬停功能
document.querySelectorAll('.notes-text').forEach(notesElement => {
const fullNotes = notesElement.getAttribute('data-full-notes');
const tooltip = notesElement.parentElement.querySelector('.notes-tooltip');
if (fullNotes && tooltip) {
notesElement.addEventListener('mouseenter', () => {
tooltip.textContent = fullNotes;
positionTooltip(notesElement, tooltip);
tooltip.classList.add('show');
});
notesElement.addEventListener('mouseleave', () => {
tooltip.classList.remove('show');
});
// 滚动时隐藏提示
window.addEventListener('scroll', () => {
if (tooltip.classList.contains('show')) {
tooltip.classList.remove('show');
}
}, { passive: true });
}
});
// 通用悬停功能
document.querySelectorAll('.hover-text').forEach(hoverElement => {
const fullText = hoverElement.getAttribute('data-full-text');
const tooltip = hoverElement.parentElement.querySelector('.hover-tooltip');
if (fullText && tooltip) {
hoverElement.addEventListener('mouseenter', () => {
tooltip.textContent = fullText;
positionTooltip(hoverElement, tooltip);
tooltip.classList.add('show');
});
hoverElement.addEventListener('mouseleave', () => {
tooltip.classList.remove('show');
});
// 滚动时隐藏提示
window.addEventListener('scroll', () => {
if (tooltip.classList.contains('show')) {
tooltip.classList.remove('show');
}
}, { passive: true });
}
});
}
addHoverListeners();
// 添加农历开关事件监听
if (listShowLunar) {
listShowLunar.removeEventListener('change', handleListLunarToggle);
listShowLunar.addEventListener('change', handleListLunarToggle);
}
} 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;
loadLunarPreference();
calculateExpiryDate();
setupModalEventListeners();
});
// 自定义日期选择器功能
class CustomDatePicker {
constructor(inputId, pickerId, calendarId, monthId, yearId, prevBtnId, nextBtnId) {
console.log('CustomDatePicker 构造函数:', { inputId, pickerId, calendarId, monthId, yearId, prevBtnId, nextBtnId });
this.input = document.getElementById(inputId);
this.picker = document.getElementById(pickerId);
this.calendar = document.getElementById(calendarId);
this.monthElement = document.getElementById(monthId);
this.yearElement = document.getElementById(yearId);
this.prevBtn = document.getElementById(prevBtnId);
this.nextBtn = document.getElementById(nextBtnId);
// 新增元素
this.monthPicker = document.getElementById(pickerId.replace('Picker', 'MonthPicker'));
this.yearPicker = document.getElementById(pickerId.replace('Picker', 'YearPicker'));
this.backToCalendarBtn = document.getElementById(pickerId.replace('Picker', 'BackToCalendar'));
this.backToCalendarFromYearBtn = document.getElementById(pickerId.replace('Picker', 'BackToCalendarFromYear'));
this.goToTodayBtn = document.getElementById(pickerId.replace('Picker', 'GoToToday'));
this.prevYearDecadeBtn = document.getElementById(pickerId.replace('Picker', 'PrevYearDecade'));
this.nextYearDecadeBtn = document.getElementById(pickerId.replace('Picker', 'NextYearDecade'));
this.yearRangeElement = document.getElementById(pickerId.replace('Picker', 'YearRange'));
this.yearGrid = document.getElementById(pickerId.replace('Picker', 'YearGrid'));
console.log('找到的元素:', {
input: !!this.input,
picker: !!this.picker,
calendar: !!this.calendar,
monthElement: !!this.monthElement,
yearElement: !!this.yearElement,
prevBtn: !!this.prevBtn,
nextBtn: !!this.nextBtn
});
this.currentDate = new Date();
this.selectedDate = null;
this.currentView = 'calendar'; // 'calendar', 'month', 'year'
this.yearDecade = Math.floor(this.currentDate.getFullYear() / 10) * 10;
this.init();
}
init() {
console.log('初始化日期选择器,输入框:', !!this.input, '选择器:', !!this.picker);
// 绑定基本事件
if (this.input) {
// 移除之前的事件监听器(如果存在)
this.input.removeEventListener('click', this._forceShowHandler);
this._forceShowHandler = () => this.forceShow();
this.input.addEventListener('click', this._forceShowHandler);
}
if (this.prevBtn) {
this.prevBtn.removeEventListener('click', this._prevHandler);
this._prevHandler = () => this.previousMonth();
this.prevBtn.addEventListener('click', this._prevHandler);
}
if (this.nextBtn) {
this.nextBtn.removeEventListener('click', this._nextHandler);
this._nextHandler = () => this.nextMonth();
this.nextBtn.addEventListener('click', this._nextHandler);
}
// 绑定月份和年份点击事件
if (this.monthElement) {
this.monthElement.removeEventListener('click', this._showMonthHandler);
this._showMonthHandler = () => this.showMonthPicker();
this.monthElement.addEventListener('click', this._showMonthHandler);
}
if (this.yearElement) {
this.yearElement.removeEventListener('click', this._showYearHandler);
this._showYearHandler = () => this.showYearPicker();
this.yearElement.addEventListener('click', this._showYearHandler);
}
// 绑定月份选择器事件
if (this.monthPicker) {
this.monthPicker.removeEventListener('click', this._monthSelectHandler);
this._monthSelectHandler = (e) => {
if (e.target.classList.contains('month-option')) {
const month = parseInt(e.target.dataset.month);
this.selectMonth(month);
}
};
this.monthPicker.addEventListener('click', this._monthSelectHandler);
}
if (this.backToCalendarBtn) {
this.backToCalendarBtn.removeEventListener('click', this._backToCalendarHandler);
this._backToCalendarHandler = () => this.showCalendar();
this.backToCalendarBtn.addEventListener('click', this._backToCalendarHandler);
}
if (this.backToCalendarFromYearBtn) {
this.backToCalendarFromYearBtn.removeEventListener('click', this._backToCalendarFromYearHandler);
this._backToCalendarFromYearHandler = () => this.showCalendar();
this.backToCalendarFromYearBtn.addEventListener('click', this._backToCalendarFromYearHandler);
}
// 绑定年份选择器事件
if (this.prevYearDecadeBtn) {
this.prevYearDecadeBtn.removeEventListener('click', this._prevYearDecadeHandler);
this._prevYearDecadeHandler = (e) => {
e.stopPropagation(); // 防止事件冒泡到表单
this.previousYearDecade();
};
this.prevYearDecadeBtn.addEventListener('click', this._prevYearDecadeHandler);
}
if (this.nextYearDecadeBtn) {
this.nextYearDecadeBtn.removeEventListener('click', this._nextYearDecadeHandler);
this._nextYearDecadeHandler = (e) => {
e.stopPropagation(); // 防止事件冒泡到表单
this.nextYearDecade();
};
this.nextYearDecadeBtn.addEventListener('click', this._nextYearDecadeHandler);
}
// 绑定回到今天事件
if (this.goToTodayBtn) {
this.goToTodayBtn.removeEventListener('click', this._goToTodayHandler);
this._goToTodayHandler = () => this.goToToday();
this.goToTodayBtn.addEventListener('click', this._goToTodayHandler);
}
// 点击外部关闭
if (this._outsideClickHandler) {
document.removeEventListener('click', this._outsideClickHandler);
}
this._outsideClickHandler = (e) => {
if (this.picker && !this.picker.contains(e.target) && !this.input.contains(e.target)) {
console.log('点击外部,隐藏日期选择器');
this.hide();
}
};
document.addEventListener('click', this._outsideClickHandler);
// 初始化显示
this.render();
this.renderYearGrid();
}
toggle() {
console.log('toggle 被调用');
console.log('picker 元素:', this.picker);
console.log('picker 类名:', this.picker ? this.picker.className : 'null');
console.log('是否包含 hidden:', this.picker ? this.picker.classList.contains('hidden') : 'null');
if (this.picker && this.picker.classList.contains('hidden')) {
console.log('显示日期选择器');
this.show();
} else {
console.log('隐藏日期选择器');
this.hide();
}
}
// 强制显示日期选择器
forceShow() {
console.log('forceShow 被调用');
if (this.picker) {
// 确保选择器显示
this.picker.classList.remove('hidden');
// 重置到日历视图
this.currentView = 'calendar';
this.hideAllViews();
this.render();
console.log('日期选择器已显示');
} else {
console.error('日期选择器元素不存在');
}
}
show() {
if (this.picker) {
this.picker.classList.remove('hidden');
this.render();
}
}
hide() {
if (this.picker) {
this.picker.classList.add('hidden');
}
}
previousMonth() {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.render();
}
nextMonth() {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.render();
}
selectDate(date) {
this.selectedDate = date;
if (this.input) {
// 使用本地时间格式化,避免时区问题
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
this.input.value = year + '-' + month + '-' + day;
}
this.hide();
// 触发change事件,但不冒泡到表单
if (this.input) {
const event = new Event('change', { bubbles: false });
this.input.dispatchEvent(event);
}
}
render() {
if (!this.monthElement || !this.yearElement || !this.calendar) return;
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// 更新月份年份显示
this.monthElement.textContent = (month + 1) + '月';
this.yearElement.textContent = year;
// 清空日历
this.calendar.innerHTML = '';
// 获取当月第一天和最后一天
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
// 生成日历网格
for (let i = 0; i < 42; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dayElement = document.createElement('div');
dayElement.className = 'calendar-day';
// 判断是否是当前月份
if (date.getMonth() !== month) {
dayElement.classList.add('other-month');
}
// 判断是否是今天
const today = new Date();
if (date.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// 判断是否是选中日期
if (this.selectedDate && date.toDateString() === this.selectedDate.toDateString()) {
dayElement.classList.add('selected');
}
// 获取农历信息
let lunarText = '';
try {
const lunar = lunarCalendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
if (lunar) {
if (lunar.day === 1) {
// 初一,只显示月份
lunarText = lunar.isLeap ? '闰' + lunar.monthStr.replace('闰', '') : lunar.monthStr;
} else {
// 不是初一,显示日
lunarText = lunar.dayStr;
}
}
} catch (error) {
console.error('农历转换错误:', error);
}
dayElement.innerHTML =
'<div>' + date.getDate() + '</div>' +
'<div class="lunar-text">' + lunarText + '</div>';
dayElement.addEventListener('click', () => this.selectDate(date));
this.calendar.appendChild(dayElement);
}
}
// 显示月份选择器
showMonthPicker() {
this.currentView = 'month';
this.hideAllViews();
if (this.monthPicker) {
this.monthPicker.classList.remove('hidden');
// 高亮当前月份
const monthOptions = this.monthPicker.querySelectorAll('.month-option');
monthOptions.forEach((option, index) => {
option.classList.remove('selected');
if (index === this.currentDate.getMonth()) {
option.classList.add('selected');
}
});
}
}
// 显示年份选择器
showYearPicker() {
this.currentView = 'year';
this.hideAllViews();
if (this.yearPicker) {
this.yearPicker.classList.remove('hidden');
}
this.renderYearGrid();
}
// 显示日历视图
showCalendar() {
this.currentView = 'calendar';
this.hideAllViews();
this.render();
}
// 隐藏所有视图
hideAllViews() {
if (this.monthPicker) this.monthPicker.classList.add('hidden');
if (this.yearPicker) this.yearPicker.classList.add('hidden');
// 注意:不隐藏日历视图,因为它是主视图
}
// 选择月份
selectMonth(month) {
this.currentDate.setMonth(month);
this.showCalendar();
}
// 选择年份
selectYear(year) {
this.currentDate.setFullYear(year);
this.showCalendar();
}
// 上一十年
previousYearDecade() {
this.yearDecade -= 10;
this.renderYearGrid();
}
// 下一十年
nextYearDecade() {
this.yearDecade += 10;
this.renderYearGrid();
}
// 渲染年份网格
renderYearGrid() {
if (!this.yearGrid || !this.yearRangeElement) return;
const startYear = this.yearDecade;
const endYear = this.yearDecade + 9;
// 更新年份范围显示
this.yearRangeElement.textContent = startYear + '-' + endYear;
// 清空年份网格
this.yearGrid.innerHTML = '';
// 生成年份按钮
for (let year = startYear; year <= endYear; year++) {
const yearBtn = document.createElement('button');
yearBtn.type = 'button';
yearBtn.className = 'year-option px-3 py-2 text-sm rounded hover:bg-gray-100';
yearBtn.textContent = year;
yearBtn.dataset.year = year;
// 高亮当前年份
if (year === this.currentDate.getFullYear()) {
yearBtn.classList.add('bg-indigo-100', 'text-indigo-600');
}
// 限制年份范围 1900-2100
if (year < 1900 || year > 2100) {
yearBtn.disabled = true;
yearBtn.classList.add('opacity-50', 'cursor-not-allowed');
} else {
yearBtn.addEventListener('click', () => this.selectYear(year));
}
this.yearGrid.appendChild(yearBtn);
}
}
// 回到今天
goToToday() {
this.currentDate = new Date();
this.yearDecade = Math.floor(this.currentDate.getFullYear() / 10) * 10;
this.showCalendar();
}
destroy() {
this.hide();
// 清理事件监听器
if (this.input && this._forceShowHandler) {
this.input.removeEventListener('click', this._forceShowHandler);
}
if (this.prevBtn && this._prevHandler) {
this.prevBtn.removeEventListener('click', this._prevHandler);
}
if (this.nextBtn && this._nextHandler) {
this.nextBtn.removeEventListener('click', this._nextHandler);
}
if (this.monthElement && this._showMonthHandler) {
this.monthElement.removeEventListener('click', this._showMonthHandler);
}
if (this.yearElement && this._showYearHandler) {
this.yearElement.removeEventListener('click', this._showYearHandler);
}
if (this.monthPicker && this._monthSelectHandler) {
this.monthPicker.removeEventListener('click', this._monthSelectHandler);
}
if (this.backToCalendarBtn && this._backToCalendarHandler) {
this.backToCalendarBtn.removeEventListener('click', this._backToCalendarHandler);
}
if (this.backToCalendarFromYearBtn && this._backToCalendarFromYearHandler) {
this.backToCalendarFromYearBtn.removeEventListener('click', this._backToCalendarFromYearHandler);
}
if (this.prevYearDecadeBtn && this._prevYearDecadeHandler) {
this.prevYearDecadeBtn.removeEventListener('click', this._prevYearDecadeHandler);
}
if (this.nextYearDecadeBtn && this._nextYearDecadeHandler) {
this.nextYearDecadeBtn.removeEventListener('click', this._nextYearDecadeHandler);
}
if (this.goToTodayBtn && this._goToTodayHandler) {
this.goToTodayBtn.removeEventListener('click', this._goToTodayHandler);
}
if (this._outsideClickHandler) {
document.removeEventListener('click', this._outsideClickHandler);
}
}
}
function setupModalEventListeners() {
// 获取DOM元素
const calculateExpiryBtn = document.getElementById('calculateExpiryBtn');
const useLunar = document.getElementById('useLunar');
const showLunar = document.getElementById('showLunar');
const startDate = document.getElementById('startDate');
const expiryDate = document.getElementById('expiryDate');
const cancelBtn = document.getElementById('cancelBtn');
// 直接绑定事件监听器(简化处理,避免重复移除的问题)
if (calculateExpiryBtn) {
calculateExpiryBtn.addEventListener('click', calculateExpiryDate);
}
if (useLunar) {
useLunar.addEventListener('change', calculateExpiryDate);
}
if (showLunar) {
showLunar.addEventListener('change', toggleLunarDisplay);
}
if (startDate) {
startDate.addEventListener('change', () => updateLunarDisplay('startDate', 'startDateLunar'));
}
if (expiryDate) {
expiryDate.addEventListener('change', () => updateLunarDisplay('expiryDate', 'expiryDateLunar'));
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
document.getElementById('subscriptionModal').classList.add('hidden');
});
}
// 为周期相关字段添加事件监听
['startDate', 'periodValue', 'periodUnit'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', calculateExpiryDate);
}
});
// 初始化自定义日期选择器
try {
// 安全地清理之前的实例
if (window.startDatePicker && typeof window.startDatePicker.destroy === 'function') {
window.startDatePicker.destroy();
}
if (window.expiryDatePicker && typeof window.expiryDatePicker.destroy === 'function') {
window.expiryDatePicker.destroy();
}
// 清理全局变量
window.startDatePicker = null;
window.expiryDatePicker = null;
// 确保DOM元素存在后再创建选择器
setTimeout(() => {
console.log('创建开始日期选择器...');
window.startDatePicker = new CustomDatePicker(
'startDate', 'startDatePicker', 'startDateCalendar',
'startDateMonth', 'startDateYear', 'startDatePrevMonth', 'startDateNextMonth'
);
console.log('创建到期日期选择器...');
window.expiryDatePicker = new CustomDatePicker(
'expiryDate', 'expiryDatePicker', 'expiryDateCalendar',
'expiryDateMonth', 'expiryDateYear', 'expiryDatePrevMonth', 'expiryDateNextMonth'
);
console.log('日期选择器初始化完成');
}, 50);
} catch (error) {
console.error('初始化日期选择器失败:', error);
// 确保清理失败的实例
window.startDatePicker = null;
window.expiryDatePicker = null;
}
}
// 3. 新增修改, calculateExpiryDate 函数,支持农历周期推算
function calculateExpiryDate() {
const startDate = document.getElementById('startDate').value;
const periodValue = parseInt(document.getElementById('periodValue').value);
const periodUnit = document.getElementById('periodUnit').value;
const useLunar = document.getElementById('useLunar').checked;
if (!startDate || !periodValue || !periodUnit) {
return;
}
if (useLunar) {
// 农历推算
const start = new Date(startDate);
const lunar = lunarCalendar.solar2lunar(start.getFullYear(), start.getMonth() + 1, start.getDate());
let nextLunar = addLunarPeriod(lunar, periodValue, periodUnit);
const solar = lunar2solar(nextLunar);
// 使用与公历相同的方式创建日期
const expiry = new Date(startDate); // 从原始日期开始
expiry.setFullYear(solar.year);
expiry.setMonth(solar.month - 1);
expiry.setDate(solar.day);
document.getElementById('expiryDate').value = expiry.toISOString().split('T')[0];
console.log('start:', start);
console.log('nextLunar:', nextLunar);
console.log('expiry:', expiry);
console.log('expiryDate:', document.getElementById('expiryDate').value);
console.log('solar from lunar2solar:', solar);
console.log('solar.year:', solar.year, 'solar.month:', solar.month, 'solar.day:', solar.day);
console.log('expiry.getTime():', expiry.getTime());
console.log('expiry.toString():', expiry.toString());
} else {
// 公历推算
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];
console.log('start:', start);
console.log('expiry:', expiry);
console.log('expiryDate:', document.getElementById('expiryDate').value);
}
// 更新农历显示
updateLunarDisplay('startDate', 'startDateLunar');
updateLunarDisplay('expiryDate', 'expiryDateLunar');
}
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');
// }
// });
// 4. 新增修改,监听 useLunar 复选框变化时也自动重新计算
// 注意:这个事件监听器已经在 setupModalEventListeners 中处理了
// 新增修改,表单提交时带上 useLunar 字段
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,
useLunar: document.getElementById('useLunar').checked // 新增修改
};
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;
}
});
// 新增修改,编辑订阅时回显 useLunar 字段
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;
document.getElementById('useLunar').checked = !!subscription.useLunar; // 新增修改
clearFieldErrors();
loadLunarPreference();
document.getElementById('subscriptionModal').classList.remove('hidden');
// 重要:编辑订阅时也需要重新设置事件监听器
setupModalEventListeners();
// 更新农历显示
setTimeout(() => {
updateLunarDisplay('startDate', 'startDateLunar');
updateLunarDisplay('expiryDate', 'expiryDateLunar');
}, 100);
}
} 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;
}
}
// 全局时区配置
let globalTimezone = 'UTC';
// 检测时区更新
function checkTimezoneUpdate() {
const lastUpdate = localStorage.getItem('timezoneUpdated');
if (lastUpdate) {
const updateTime = parseInt(lastUpdate);
const currentTime = Date.now();
// 如果时区更新发生在最近5秒内,则刷新页面
if (currentTime - updateTime < 5000) {
localStorage.removeItem('timezoneUpdated');
window.location.reload();
}
}
}
// 页面加载时检查时区更新
window.addEventListener('load', () => {
checkTimezoneUpdate();
loadSubscriptions();
});
// 定期检查时区更新(每2秒检查一次)
setInterval(checkTimezoneUpdate, 2000);
// 实时显示系统时间和时区
async function showSystemTime() {
try {
// 获取后台配置的时区
const response = await fetch('/api/config');
const config = await response.json();
globalTimezone = config.TIMEZONE || 'UTC';
// 格式化当前时间
function formatTime(dt, tz) {
return dt.toLocaleString('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatTimezoneDisplay(tz) {
try {
// 使用更准确的时区偏移计算方法
const now = new Date();
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const parts = dtf.formatToParts(now);
const get = type => Number(parts.find(x => x.type === type).value);
const target = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
const utc = now.getTime();
const offset = Math.round((target - utc) / (1000 * 60 * 60));
// 时区中文名称映射
const timezoneNames = {
'UTC': '世界标准时间',
'Asia/Shanghai': '中国标准时间',
'Asia/Hong_Kong': '香港时间',
'Asia/Taipei': '台北时间',
'Asia/Singapore': '新加坡时间',
'Asia/Tokyo': '日本时间',
'Asia/Seoul': '韩国时间',
'America/New_York': '美国东部时间',
'America/Los_Angeles': '美国太平洋时间',
'America/Chicago': '美国中部时间',
'America/Denver': '美国山地时间',
'Europe/London': '英国时间',
'Europe/Paris': '巴黎时间',
'Europe/Berlin': '柏林时间',
'Europe/Moscow': '莫斯科时间',
'Australia/Sydney': '悉尼时间',
'Australia/Melbourne': '墨尔本时间',
'Pacific/Auckland': '奥克兰时间'
};
const offsetStr = offset >= 0 ? '+' + offset : offset;
const timezoneName = timezoneNames[tz] || tz;
return timezoneName + ' (UTC' + offsetStr + ')';
} catch (error) {
console.error('格式化时区显示失败:', error);
return tz;
}
}
function update() {
const now = new Date();
const timeStr = formatTime(now, globalTimezone);
const tzStr = formatTimezoneDisplay(globalTimezone);
const el = document.getElementById('systemTimeDisplay');
if (el) {
el.textContent = timeStr + ' ' + tzStr;
}
}
update();
// 每秒刷新
setInterval(update, 1000);
// 定期检查时区变化并重新加载订阅列表(每30秒检查一次)
setInterval(async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
const newTimezone = config.TIMEZONE || 'UTC';
if (globalTimezone !== newTimezone) {
globalTimezone = newTimezone;
console.log('时区已更新为:', globalTimezone);
// 重新加载订阅列表以更新天数计算
loadSubscriptions();
}
} catch (error) {
console.error('检查时区更新失败:', error);
}
}, 30000);
// 初始加载订阅列表
loadSubscriptions();
} catch (e) {
// 出错时显示本地时间
const el = document.getElementById('systemTimeDisplay');
if (el) {
el.textContent = new Date().toLocaleString();
}
}
}
showSystemTime();
</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://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
<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>
<span id="systemTimeDisplay" class="ml-4 text-base text-indigo-600 font-normal"></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="inline-flex items-center">
<input type="checkbox" id="showLunarGlobal" class="form-checkbox 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">在通知中显示农历日期</span>
</label>
<p class="mt-1 text-sm text-gray-500">控制是否在通知消息中包含农历日期信息</p>
</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 for="timezone" class="block text-sm font-medium text-gray-700 mb-1">时区选择</label>
<select id="timezone" name="timezone" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 bg-white">
<option value="UTC">世界标准时间(UTC+0)</option>
<option value="Asia/Shanghai">中国标准时间(UTC+8)</option>
<option value="Asia/Hong_Kong">香港时间(UTC+8)</option>
<option value="Asia/Taipei">台北时间(UTC+8)</option>
<option value="Asia/Singapore">新加坡时间(UTC+8)</option>
<option value="Asia/Tokyo">日本时间(UTC+9)</option>
<option value="Asia/Seoul">韩国时间(UTC+9)</option>
<option value="America/New_York">美国东部时间(UTC-5)</option>
<option value="America/Chicago">美国中部时间(UTC-6)</option>
<option value="America/Denver">美国山地时间(UTC-7)</option>
<option value="America/Los_Angeles">美国太平洋时间(UTC-8)</option>
<option value="Europe/London">英国时间(UTC+0)</option>
<option value="Europe/Paris">巴黎时间(UTC+1)</option>
<option value="Europe/Berlin">柏林时间(UTC+1)</option>
<option value="Europe/Moscow">莫斯科时间(UTC+3)</option>
<option value="Australia/Sydney">悉尼时间(UTC+10)</option>
<option value="Australia/Melbourne">墨尔本时间(UTC+10)</option>
<option value="Pacific/Auckland">奥克兰时间(UTC+12)</option>
</select>
<p class="mt-1 text-sm text-gray-500">选择需要使用时区,计算到期日期</p>
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="inline-flex items-center">
<input type="checkbox" name="enabledNotifiers" value="telegram" class="form-checkbox 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="checkbox" name="enabledNotifiers" value="notifyx" class="form-checkbox 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>
<label class="inline-flex items-center">
<input type="checkbox" name="enabledNotifiers" value="webhook" class="form-checkbox h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<span class="ml-2 text-sm text-gray-700">企业微信应用通知</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" name="enabledNotifiers" value="wechatbot" class="form-checkbox h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<span class="ml-2 text-sm text-gray-700">企业微信机器人</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" name="enabledNotifiers" value="email" class="form-checkbox h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<span class="ml-2 text-sm text-gray-700">邮件通知</span>
</label>
</div>
<div class="mt-2 flex flex-wrap gap-4">
<a href="https://www.micdu.com/?golink=aHR0cHM6Ly93d3cubm90aWZ5eC5jbi8=&nonce=6e4b095836" target="_blank" class="text-indigo-600 hover:text-indigo-800 text-sm">
<i class="fas fa-external-link-alt ml-1"></i> NotifyX官网
</a>
<a href="https://push.wangwangit.com" target="_blank" class="text-indigo-600 hover:text-indigo-800 text-sm">
<i class="fas fa-external-link-alt ml-1"></i> 企业微信应用通知官网
</a>
<a href="https://www.micdu.com/?golink=aHR0cHM6Ly9kZXZlbG9wZXIud29yay53ZWl4aW4ucXEuY29tL2RvY3VtZW50L3BhdGgvOTE3NzA=&nonce=6e4b095836" target="_blank" class="text-indigo-600 hover:text-indigo-800 text-sm">
<i class="fas fa-external-link-alt ml-1"></i> 企业微信机器人文档
</a>
<a href="https://www.micdu.com/?golink=aHR0cHM6Ly9kZXZlbG9wZXJzLmNsb3VkZmxhcmUuY29tL3dvcmtlcnMvdHV0b3JpYWxzL3NlbmQtZW1haWxzLXdpdGgtcmVzZW5kLw==&nonce=6e4b095836" target="_blank" class="text-indigo-600 hover:text-indigo-800 text-sm">
<i class="fas fa-external-link-alt ml-1"></i> 获取 Resend API Key
</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.micdu.com/?golink=aHR0cHM6Ly93d3cubm90aWZ5eC5jbi8=&nonce=6e4b095836" 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 id="webhookConfig" class="config-section">
<h4 class="text-md font-medium text-gray-900 mb-3">企业微信应用通知 配置</h4>
<div class="grid grid-cols-1 gap-4 mb-4">
<div>
<label for="webhookUrl" class="block text-sm font-medium text-gray-700">企业微信应用通知 URL</label>
<input type="url" id="webhookUrl" placeholder="https://push.wangwangit.com/api/send/your-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://push.wangwangit.com" target="_blank" class="text-indigo-600 hover:text-indigo-800">企业微信应用通知平台</a> 获取的推送URL</p>
</div>
<div>
<label for="webhookMethod" class="block text-sm font-medium text-gray-700">请求方法</label>
<select id="webhookMethod" 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">
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
</select>
</div>
<div>
<label for="webhookHeaders" class="block text-sm font-medium text-gray-700">自定义请求头 (JSON格式,可选)</label>
<textarea id="webhookHeaders" rows="3" placeholder='{"Authorization": "Bearer your-token", "Content-Type": "application/json"}' 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"></textarea>
<p class="mt-1 text-sm text-gray-500">JSON格式的自定义请求头,留空使用默认</p>
</div>
<div>
<label for="webhookTemplate" class="block text-sm font-medium text-gray-700">消息模板 (JSON格式,可选)</label>
<textarea id="webhookTemplate" rows="4" placeholder='{"title": "{{title}}", "content": "{{content}}", "timestamp": "{{timestamp}}"}' 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"></textarea>
<p class="mt-1 text-sm text-gray-500">支持变量: {{title}}, {{content}}, {{timestamp}}。留空使用默认格式</p>
</div>
</div>
<div class="flex justify-end">
<button type="button" id="testWebhookBtn" class="btn-secondary text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-paper-plane mr-2"></i>测试 企业微信应用通知
</button>
</div>
</div>
<div id="wechatbotConfig" class="config-section">
<h4 class="text-md font-medium text-gray-900 mb-3">企业微信机器人 配置</h4>
<div class="grid grid-cols-1 gap-4 mb-4">
<div>
<label for="wechatbotWebhook" class="block text-sm font-medium text-gray-700">机器人 Webhook URL</label>
<input type="url" id="wechatbotWebhook" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-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">从企业微信群聊中添加机器人获取的 Webhook URL</p>
</div>
<div>
<label for="wechatbotMsgType" class="block text-sm font-medium text-gray-700">消息类型</label>
<select id="wechatbotMsgType" 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">
<option value="text">文本消息</option>
<option value="markdown">Markdown消息</option>
</select>
<p class="mt-1 text-sm text-gray-500">选择发送的消息格式类型</p>
</div>
<div>
<label for="wechatbotAtMobiles" class="block text-sm font-medium text-gray-700">@手机号 (可选)</label>
<input type="text" id="wechatbotAtMobiles" placeholder="13800138000,13900139000" 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>
<label for="wechatbotAtAll" class="block text-sm font-medium text-gray-700 mb-2">@所有人</label>
<label class="inline-flex items-center">
<input type="checkbox" id="wechatbotAtAll" class="form-checkbox h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<span class="ml-2 text-sm text-gray-700">发送消息时@所有人</span>
</label>
</div>
</div>
<div class="flex justify-end">
<button type="button" id="testWechatBotBtn" class="btn-secondary text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-paper-plane mr-2"></i>测试 企业微信机器人
</button>
</div>
</div>
<div id="emailConfig" class="config-section">
<h4 class="text-md font-medium text-gray-900 mb-3">邮件通知 配置</h4>
<div class="grid grid-cols-1 gap-4 mb-4">
<div>
<label for="resendApiKey" class="block text-sm font-medium text-gray-700">Resend API Key</label>
<input type="text" id="resendApiKey" placeholder="re_xxxxxxxxxx" 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://resend.com/api-keys" target="_blank" class="text-indigo-600 hover:text-indigo-800">Resend控制台</a> 获取的 API Key</p>
</div>
<div>
<label for="emailFrom" class="block text-sm font-medium text-gray-700">发件人邮箱</label>
<input type="email" id="emailFrom" placeholder="[email protected]" 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">必须是已在Resend验证的域名邮箱</p>
</div>
<div>
<label for="emailFromName" class="block text-sm font-medium text-gray-700">发件人名称</label>
<input type="text" id="emailFromName" 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>
<label for="emailTo" class="block text-sm font-medium text-gray-700">收件人邮箱</label>
<input type="email" id="emailTo" placeholder="[email protected]" 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 class="flex justify-end">
<button type="button" id="testEmailBtn" class="btn-secondary text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-paper-plane mr-2"></i>测试 邮件通知
</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 || '';
document.getElementById('webhookUrl').value = config.WEBHOOK_URL || '';
document.getElementById('webhookMethod').value = config.WEBHOOK_METHOD || 'POST';
document.getElementById('webhookHeaders').value = config.WEBHOOK_HEADERS || '';
document.getElementById('webhookTemplate').value = config.WEBHOOK_TEMPLATE || '';
document.getElementById('wechatbotWebhook').value = config.WECHATBOT_WEBHOOK || '';
document.getElementById('wechatbotMsgType').value = config.WECHATBOT_MSG_TYPE || 'text';
document.getElementById('wechatbotAtMobiles').value = config.WECHATBOT_AT_MOBILES || '';
document.getElementById('wechatbotAtAll').checked = config.WECHATBOT_AT_ALL === 'true';
document.getElementById('resendApiKey').value = config.RESEND_API_KEY || '';
document.getElementById('emailFrom').value = config.EMAIL_FROM || '';
document.getElementById('emailFromName').value = config.EMAIL_FROM_NAME || '订阅提醒系统';
document.getElementById('emailTo').value = config.EMAIL_TO || '';
// 加载农历显示设置
document.getElementById('showLunarGlobal').checked = config.SHOW_LUNAR === true;
// 动态生成时区选项,并设置保存的值
generateTimezoneOptions(config.TIMEZONE || 'UTC');
// 处理多选通知渠道
const enabledNotifiers = config.ENABLED_NOTIFIERS || ['notifyx'];
document.querySelectorAll('input[name="enabledNotifiers"]').forEach(checkbox => {
checkbox.checked = enabledNotifiers.includes(checkbox.value);
});
toggleNotificationConfigs(enabledNotifiers);
} catch (error) {
console.error('加载配置失败:', error);
showToast('加载配置失败,请刷新页面重试', 'error');
}
}
// 动态生成时区选项
function generateTimezoneOptions(selectedTimezone = 'UTC') {
const timezoneSelect = document.getElementById('timezone');
const timezones = [
{ value: 'UTC', name: '世界标准时间', offset: '+0' },
{ value: 'Asia/Shanghai', name: '中国标准时间', offset: '+8' },
{ value: 'Asia/Hong_Kong', name: '香港时间', offset: '+8' },
{ value: 'Asia/Taipei', name: '台北时间', offset: '+8' },
{ value: 'Asia/Singapore', name: '新加坡时间', offset: '+8' },
{ value: 'Asia/Tokyo', name: '日本时间', offset: '+9' },
{ value: 'Asia/Seoul', name: '韩国时间', offset: '+9' },
{ value: 'America/New_York', name: '美国东部时间', offset: '-5' },
{ value: 'America/Chicago', name: '美国中部时间', offset: '-6' },
{ value: 'America/Denver', name: '美国山地时间', offset: '-7' },
{ value: 'America/Los_Angeles', name: '美国太平洋时间', offset: '-8' },
{ value: 'Europe/London', name: '英国时间', offset: '+0' },
{ value: 'Europe/Paris', name: '巴黎时间', offset: '+1' },
{ value: 'Europe/Berlin', name: '柏林时间', offset: '+1' },
{ value: 'Europe/Moscow', name: '莫斯科时间', offset: '+3' },
{ value: 'Australia/Sydney', name: '悉尼时间', offset: '+10' },
{ value: 'Australia/Melbourne', name: '墨尔本时间', offset: '+10' },
{ value: 'Pacific/Auckland', name: '奥克兰时间', offset: '+12' }
];
// 清空现有选项
timezoneSelect.innerHTML = '';
// 添加新选项
timezones.forEach(tz => {
const option = document.createElement('option');
option.value = tz.value;
option.textContent = tz.name + '(UTC' + tz.offset + ')';
timezoneSelect.appendChild(option);
});
// 设置选中的时区
timezoneSelect.value = selectedTimezone;
}
function toggleNotificationConfigs(enabledNotifiers) {
const telegramConfig = document.getElementById('telegramConfig');
const notifyxConfig = document.getElementById('notifyxConfig');
const webhookConfig = document.getElementById('webhookConfig');
const wechatbotConfig = document.getElementById('wechatbotConfig');
const emailConfig = document.getElementById('emailConfig');
// 重置所有配置区域
[telegramConfig, notifyxConfig, webhookConfig, wechatbotConfig, emailConfig].forEach(config => {
config.classList.remove('active', 'inactive');
config.classList.add('inactive');
});
// 激活选中的配置区域
enabledNotifiers.forEach(type => {
if (type === 'telegram') {
telegramConfig.classList.remove('inactive');
telegramConfig.classList.add('active');
} else if (type === 'notifyx') {
notifyxConfig.classList.remove('inactive');
notifyxConfig.classList.add('active');
} else if (type === 'webhook') {
webhookConfig.classList.remove('inactive');
webhookConfig.classList.add('active');
} else if (type === 'wechatbot') {
wechatbotConfig.classList.remove('inactive');
wechatbotConfig.classList.add('active');
} else if (type === 'email') {
emailConfig.classList.remove('inactive');
emailConfig.classList.add('active');
}
});
}
document.querySelectorAll('input[name="enabledNotifiers"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
const enabledNotifiers = Array.from(document.querySelectorAll('input[name="enabledNotifiers"]:checked'))
.map(cb => cb.value);
toggleNotificationConfigs(enabledNotifiers);
});
});
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const enabledNotifiers = Array.from(document.querySelectorAll('input[name="enabledNotifiers"]:checked'))
.map(cb => cb.value);
if (enabledNotifiers.length === 0) {
showToast('请至少选择一种通知方式', 'warning');
return;
}
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(),
WEBHOOK_URL: document.getElementById('webhookUrl').value.trim(),
WEBHOOK_METHOD: document.getElementById('webhookMethod').value,
WEBHOOK_HEADERS: document.getElementById('webhookHeaders').value.trim(),
WEBHOOK_TEMPLATE: document.getElementById('webhookTemplate').value.trim(),
SHOW_LUNAR: document.getElementById('showLunarGlobal').checked,
WECHATBOT_WEBHOOK: document.getElementById('wechatbotWebhook').value.trim(),
WECHATBOT_MSG_TYPE: document.getElementById('wechatbotMsgType').value,
WECHATBOT_AT_MOBILES: document.getElementById('wechatbotAtMobiles').value.trim(),
WECHATBOT_AT_ALL: document.getElementById('wechatbotAtAll').checked.toString(),
RESEND_API_KEY: document.getElementById('resendApiKey').value.trim(),
EMAIL_FROM: document.getElementById('emailFrom').value.trim(),
EMAIL_FROM_NAME: document.getElementById('emailFromName').value.trim(),
EMAIL_TO: document.getElementById('emailTo').value.trim(),
ENABLED_NOTIFIERS: enabledNotifiers,
TIMEZONE: document.getElementById('timezone').value.trim()
};
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 = '';
// 更新全局时区并重新显示时间
globalTimezone = config.TIMEZONE;
showSystemTime();
// 标记时区已更新,供其他页面检测
localStorage.setItem('timezoneUpdated', Date.now().toString());
// 如果当前在订阅列表页面,则自动刷新页面以更新时区显示
if (window.location.pathname === '/admin') {
window.location.reload();
}
} else {
showToast('配置保存失败: ' + (result.message || '未知错误'), 'error');
}
} catch (error) {
console.error('保存配置失败:', error);
showToast('保存配置失败,请稍后再试', 'error');
} finally {
submitButton.innerHTML = originalContent;
submitButton.disabled = false;
}
});
async function testNotification(type) {
const buttonId = type === 'telegram' ? 'testTelegramBtn' :
type === 'notifyx' ? 'testNotifyXBtn' :
type === 'wechatbot' ? 'testWechatBotBtn' :
type === 'email' ? 'testEmailBtn' : 'testWebhookBtn';
const button = document.getElementById(buttonId);
const originalContent = button.innerHTML;
const serviceName = type === 'telegram' ? 'Telegram' :
type === 'notifyx' ? 'NotifyX' :
type === 'wechatbot' ? '企业微信机器人' :
type === 'email' ? '邮件通知' : '企业微信应用通知';
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 if (type === 'notifyx') {
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;
}
} else if (type === 'webhook') {
config.WEBHOOK_URL = document.getElementById('webhookUrl').value.trim();
config.WEBHOOK_METHOD = document.getElementById('webhookMethod').value;
config.WEBHOOK_HEADERS = document.getElementById('webhookHeaders').value.trim();
config.WEBHOOK_TEMPLATE = document.getElementById('webhookTemplate').value.trim();
if (!config.WEBHOOK_URL) {
showToast('请先填写 企业微信应用通知 URL', 'warning');
button.innerHTML = originalContent;
button.disabled = false;
return;
}
} else if (type === 'wechatbot') {
config.WECHATBOT_WEBHOOK = document.getElementById('wechatbotWebhook').value.trim();
config.WECHATBOT_MSG_TYPE = document.getElementById('wechatbotMsgType').value;
config.WECHATBOT_AT_MOBILES = document.getElementById('wechatbotAtMobiles').value.trim();
config.WECHATBOT_AT_ALL = document.getElementById('wechatbotAtAll').checked.toString();
if (!config.WECHATBOT_WEBHOOK) {
showToast('请先填写企业微信机器人 Webhook URL', 'warning');
button.innerHTML = originalContent;
button.disabled = false;
return;
}
} else if (type === 'email') {
config.RESEND_API_KEY = document.getElementById('resendApiKey').value.trim();
config.EMAIL_FROM = document.getElementById('emailFrom').value.trim();
config.EMAIL_FROM_NAME = document.getElementById('emailFromName').value.trim();
config.EMAIL_TO = document.getElementById('emailTo').value.trim();
if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !config.EMAIL_TO) {
showToast('请先填写 Resend 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');
});
document.getElementById('testWebhookBtn').addEventListener('click', () => {
testNotification('webhook');
});
document.getElementById('testWechatBotBtn').addEventListener('click', () => {
testNotification('wechatbot');
});
document.getElementById('testEmailBtn').addEventListener('click', () => {
testNotification('email');
});
window.addEventListener('load', loadConfig);
// 全局时区配置
let globalTimezone = 'UTC';
// 实时显示系统时间和时区
async function showSystemTime() {
try {
// 获取后台配置的时区
const response = await fetch('/api/config');
const config = await response.json();
globalTimezone = config.TIMEZONE || 'UTC';
// 格式化当前时间
function formatTime(dt, tz) {
return dt.toLocaleString('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatTimezoneDisplay(tz) {
try {
// 使用更准确的时区偏移计算方法
const now = new Date();
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const parts = dtf.formatToParts(now);
const get = type => Number(parts.find(x => x.type === type).value);
const target = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
const utc = now.getTime();
const offset = Math.round((target - utc) / (1000 * 60 * 60));
// 时区中文名称映射
const timezoneNames = {
'UTC': '世界标准时间',
'Asia/Shanghai': '中国标准时间',
'Asia/Hong_Kong': '香港时间',
'Asia/Taipei': '台北时间',
'Asia/Singapore': '新加坡时间',
'Asia/Tokyo': '日本时间',
'Asia/Seoul': '韩国时间',
'America/New_York': '美国东部时间',
'America/Los_Angeles': '美国太平洋时间',
'America/Chicago': '美国中部时间',
'America/Denver': '美国山地时间',
'Europe/London': '英国时间',
'Europe/Paris': '巴黎时间',
'Europe/Berlin': '柏林时间',
'Europe/Moscow': '莫斯科时间',
'Australia/Sydney': '悉尼时间',
'Australia/Melbourne': '墨尔本时间',
'Pacific/Auckland': '奥克兰时间'
};
const offsetStr = offset >= 0 ? '+' + offset : offset;
const timezoneName = timezoneNames[tz] || tz;
return timezoneName + ' (UTC' + offsetStr + ')';
} catch (error) {
console.error('格式化时区显示失败:', error);
return tz;
}
}
function update() {
const now = new Date();
const timeStr = formatTime(now, globalTimezone);
const tzStr = formatTimezoneDisplay(globalTimezone);
const el = document.getElementById('systemTimeDisplay');
if (el) {
el.textContent = timeStr + ' ' + tzStr;
}
}
update();
// 每秒刷新
setInterval(update, 1000);
// 定期检查时区变化并重新加载订阅列表(每30秒检查一次)
setInterval(async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
const newTimezone = config.TIMEZONE || 'UTC';
if (globalTimezone !== newTimezone) {
globalTimezone = newTimezone;
console.log('时区已更新为:', globalTimezone);
// 重新加载订阅列表以更新天数计算
loadSubscriptions();
}
} catch (error) {
console.error('检查时区更新失败:', error);
}
}, 30000);
} catch (e) {
// 出错时显示本地时间
const el = document.getElementById('systemTimeDisplay');
if (el) {
el.textContent = new Date().toLocaleString();
}
}
}
showSystemTime();
</script>
</body>
</html>
`;
// 管理页面
const admin = {
async handleRequest(request, env, ctx) {
try {
const url = new URL(request.url);
const pathname = url.pathname;
console.log('[管理页面] 访问路径:', pathname);
const token = getCookieValue(request.headers.get('Cookie'), 'token');
console.log('[管理页面] Token存在:', !!token);
const config = await getConfig(env);
const user = token ? await verifyJWT(token, config.JWT_SECRET) : null;
console.log('[管理页面] 用户验证结果:', !!user);
if (!user) {
console.log('[管理页面] 用户未登录,重定向到登录页面');
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' }
});
} catch (error) {
console.error('[管理页面] 处理请求时出错:', error);
return new Response('服务器内部错误', {
status: 500,
headers: { 'Content-Type': 'text/plain; 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 || '',
WEBHOOK_URL: newConfig.WEBHOOK_URL || '',
WEBHOOK_METHOD: newConfig.WEBHOOK_METHOD || 'POST',
WEBHOOK_HEADERS: newConfig.WEBHOOK_HEADERS || '',
WEBHOOK_TEMPLATE: newConfig.WEBHOOK_TEMPLATE || '',
SHOW_LUNAR: newConfig.SHOW_LUNAR === true,
WECHATBOT_WEBHOOK: newConfig.WECHATBOT_WEBHOOK || '',
WECHATBOT_MSG_TYPE: newConfig.WECHATBOT_MSG_TYPE || 'text',
WECHATBOT_AT_MOBILES: newConfig.WECHATBOT_AT_MOBILES || '',
WECHATBOT_AT_ALL: newConfig.WECHATBOT_AT_ALL || 'false',
RESEND_API_KEY: newConfig.RESEND_API_KEY || '',
EMAIL_FROM: newConfig.EMAIL_FROM || '',
EMAIL_FROM_NAME: newConfig.EMAIL_FROM_NAME || '',
EMAIL_TO: newConfig.EMAIL_TO || '',
ENABLED_NOTIFIERS: newConfig.ENABLED_NOTIFIERS || ['notifyx'],
TIMEZONE: newConfig.TIMEZONE || config.TIMEZONE || 'UTC' // 新增时区字段
};
if (newConfig.ADMIN_PASSWORD) {
updatedConfig.ADMIN_PASSWORD = newConfig.ADMIN_PASSWORD;
}
// 确保JWT_SECRET存在且安全
if (!updatedConfig.JWT_SECRET || updatedConfig.JWT_SECRET === 'your-secret-key') {
updatedConfig.JWT_SECRET = generateRandomSecret();
console.log('[安全] 生成新的JWT密钥');
}
await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig));
return new Response(
JSON.stringify({ success: true }),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('配置保存错误:', error);
return new Response(
JSON.stringify({ success: false, message: '更新配置失败: ' + error.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发送时间: ' + formatBeijingTime();
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发送时间: ' + formatBeijingTime();
const description = '测试NotifyX通知功能';
success = await sendNotifyXNotification(title, content, description, testConfig);
message = success ? 'NotifyX通知发送成功' : 'NotifyX通知发送失败,请检查配置';
} else if (body.type === 'webhook') {
const testConfig = {
...config,
WEBHOOK_URL: body.WEBHOOK_URL,
WEBHOOK_METHOD: body.WEBHOOK_METHOD,
WEBHOOK_HEADERS: body.WEBHOOK_HEADERS,
WEBHOOK_TEMPLATE: body.WEBHOOK_TEMPLATE
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证企业微信应用通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendWebhookNotification(title, content, testConfig);
message = success ? '企业微信应用通知发送成功' : '企业微信应用通知发送失败,请检查配置';
} else if (body.type === 'wechatbot') {
const testConfig = {
...config,
WECHATBOT_WEBHOOK: body.WECHATBOT_WEBHOOK,
WECHATBOT_MSG_TYPE: body.WECHATBOT_MSG_TYPE,
WECHATBOT_AT_MOBILES: body.WECHATBOT_AT_MOBILES,
WECHATBOT_AT_ALL: body.WECHATBOT_AT_ALL
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证企业微信机器人功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendWechatBotNotification(title, content, testConfig);
message = success ? '企业微信机器人通知发送成功' : '企业微信机器人通知发送失败,请检查配置';
} else if (body.type === 'email') {
const testConfig = {
...config,
RESEND_API_KEY: body.RESEND_API_KEY,
EMAIL_FROM: body.EMAIL_FROM,
EMAIL_FROM_NAME: body.EMAIL_FROM_NAME,
EMAIL_TO: body.EMAIL_TO
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证邮件通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendEmailNotification(title, content, testConfig);
message = success ? '邮件通知发送成功' : '邮件通知发送失败,请检查配置';
}
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' }
}
);
}
}
// 处理第三方通知API
if (path.startsWith('/notify/')) {
const code = path.split('/')[2];
if (method === 'POST') {
try {
const body = await request.json();
const title = body.title || '第三方通知';
const content = body.content || '';
if (!content) {
return new Response(
JSON.stringify({ message: '缺少必填参数 content' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const config = await getConfig(env);
// 使用多渠道发送通知
await sendNotificationToAllChannels(title, content, config, '[第三方API]');
return new Response(
JSON.stringify({
message: '发送成功',
response: {
errcode: 0,
errmsg: 'ok',
msgid: 'MSGID' + Date.now()
}
}),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[第三方API] 发送通知失败:', error);
return new Response(
JSON.stringify({
message: '发送失败',
response: {
errcode: 1,
errmsg: error.message
}
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
return new Response(
JSON.stringify({ success: false, message: '未找到请求的资源' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
};
// 工具函数
function generateRandomSecret() {
// 生成一个64字符的随机密钥
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let result = '';
for (let i = 0; i < 64; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
async function getConfig(env) {
try {
if (!env.SUBSCRIPTIONS_KV) {
console.error('[配置] KV存储未绑定');
throw new Error('KV存储未绑定');
}
const data = await env.SUBSCRIPTIONS_KV.get('config');
console.log('[配置] 从KV读取配置:', data ? '成功' : '空配置');
const config = data ? JSON.parse(data) : {};
// 确保JWT_SECRET的一致性
let jwtSecret = config.JWT_SECRET;
if (!jwtSecret || jwtSecret === 'your-secret-key') {
jwtSecret = generateRandomSecret();
console.log('[配置] 生成新的JWT密钥');
// 保存新的JWT密钥
const updatedConfig = { ...config, JWT_SECRET: jwtSecret };
await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig));
}
const finalConfig = {
ADMIN_USERNAME: config.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: config.ADMIN_PASSWORD || 'password',
JWT_SECRET: jwtSecret,
TG_BOT_TOKEN: config.TG_BOT_TOKEN || '',
TG_CHAT_ID: config.TG_CHAT_ID || '',
NOTIFYX_API_KEY: config.NOTIFYX_API_KEY || '',
WEBHOOK_URL: config.WEBHOOK_URL || '',
WEBHOOK_METHOD: config.WEBHOOK_METHOD || 'POST',
WEBHOOK_HEADERS: config.WEBHOOK_HEADERS || '',
WEBHOOK_TEMPLATE: config.WEBHOOK_TEMPLATE || '',
SHOW_LUNAR: config.SHOW_LUNAR === true,
WECHATBOT_WEBHOOK: config.WECHATBOT_WEBHOOK || '',
WECHATBOT_MSG_TYPE: config.WECHATBOT_MSG_TYPE || 'text',
WECHATBOT_AT_MOBILES: config.WECHATBOT_AT_MOBILES || '',
WECHATBOT_AT_ALL: config.WECHATBOT_AT_ALL || 'false',
RESEND_API_KEY: config.RESEND_API_KEY || '',
EMAIL_FROM: config.EMAIL_FROM || '',
EMAIL_FROM_NAME: config.EMAIL_FROM_NAME || '',
EMAIL_TO: config.EMAIL_TO || '',
ENABLED_NOTIFIERS: config.ENABLED_NOTIFIERS || ['notifyx'],
TIMEZONE: config.TIMEZONE || 'UTC' // 新增时区字段
};
console.log('[配置] 最终配置用户名:', finalConfig.ADMIN_USERNAME);
return finalConfig;
} catch (error) {
console.error('[配置] 获取配置失败:', error);
const defaultJwtSecret = generateRandomSecret();
return {
ADMIN_USERNAME: 'admin',
ADMIN_PASSWORD: 'password',
JWT_SECRET: defaultJwtSecret,
TG_BOT_TOKEN: '',
TG_CHAT_ID: '',
NOTIFYX_API_KEY: '',
WEBHOOK_URL: '',
WEBHOOK_METHOD: 'POST',
WEBHOOK_HEADERS: '',
WEBHOOK_TEMPLATE: '',
SHOW_LUNAR: true,
WECHATBOT_WEBHOOK: '',
WECHATBOT_MSG_TYPE: 'text',
WECHATBOT_AT_MOBILES: '',
WECHATBOT_AT_ALL: 'false',
RESEND_API_KEY: '',
EMAIL_FROM: '',
EMAIL_FROM_NAME: '',
EMAIL_TO: '',
ENABLED_NOTIFIERS: ['notifyx'],
TIMEZONE: 'UTC' // 新增时区字段
};
}
}
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 {
if (!token || !secret) {
console.log('[JWT] Token或Secret为空');
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
console.log('[JWT] Token格式错误,部分数量:', parts.length);
return null;
}
const [headerBase64, payloadBase64, signature] = parts;
const signatureInput = headerBase64 + '.' + payloadBase64;
const expectedSignature = await CryptoJS.HmacSHA256(signatureInput, secret);
if (signature !== expectedSignature) {
console.log('[JWT] 签名验证失败');
return null;
}
const payload = JSON.parse(atob(payloadBase64));
console.log('[JWT] 验证成功,用户:', payload.username);
return payload;
} catch (error) {
console.error('[JWT] 验证过程出错:', 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);
}
// 2. 修改 createSubscription,支持 useLunar 字段
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 config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
let useLunar = !!subscription.useLunar;
if (useLunar) {
let lunar = lunarCalendar.solar2lunar(
expiryDate.getFullYear(),
expiryDate.getMonth() + 1,
expiryDate.getDate()
);
if (lunar && subscription.periodValue && subscription.periodUnit) {
// 如果到期日<=今天,自动推算到下一个周期
while (expiryDate <= currentTime) {
lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit);
const solar = lunarBiz.lunar2solar(lunar);
expiryDate = new Date(solar.year, solar.month - 1, solar.day);
}
subscription.expiryDate = expiryDate.toISOString();
}
} else {
if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
while (expiryDate < currentTime) {
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,
useLunar: useLunar, // 新增
createdAt: new Date().toISOString()
};
subscriptions.push(newSubscription);
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: newSubscription };
} catch (error) {
console.error("创建订阅异常:", error && error.stack ? error.stack : error);
return { success: false, message: error && error.message ? error.message : '创建订阅失败' };
}
}
// 3. 修改 updateSubscription,支持 useLunar 字段
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 config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
let useLunar = !!subscription.useLunar;
if (useLunar) {
let lunar = lunarCalendar.solar2lunar(
expiryDate.getFullYear(),
expiryDate.getMonth() + 1,
expiryDate.getDate()
);
if (!lunar) {
return { success: false, message: '农历日期超出支持范围(1900-2100年)' };
}
if (lunar && expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
// 新增:循环加周期,直到 expiryDate > currentTime
do {
lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit);
const solar = lunarBiz.lunar2solar(lunar);
expiryDate = new Date(solar.year, solar.month - 1, solar.day);
} while (expiryDate < currentTime);
subscription.expiryDate = expiryDate.toISOString();
}
} else {
if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
while (expiryDate < currentTime) {
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),
useLunar: useLunar, // 新增
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 showLunar = config.SHOW_LUNAR === true;
let lunarExpiryText = '';
if (showLunar) {
// 计算农历日期
const expiryDateObj = new Date(subscription.expiryDate);
const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate());
lunarExpiryText = lunarExpiry ? ` (农历: ${lunarExpiry.fullStr})` : '';
}
// 格式化到期日期(使用所选时区)
const timezone = config?.TIMEZONE || 'UTC';
const formattedExpiryDate = formatTimeInTimezone(new Date(subscription.expiryDate), timezone, 'date');
const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime');
// 获取日历类型和自动续期状态
const calendarType = subscription.useLunar ? '农历' : '公历';
const autoRenewText = subscription.autoRenew ? '是' : '否';
const commonContent = `**订阅详情**
类型: ${subscription.customType || '其他'}
日历类型: ${calendarType}
到期日期: ${formattedExpiryDate}${lunarExpiryText}
自动续期: ${autoRenewText}
备注: ${subscription.notes || '无'}
发送时间: ${currentTime}
当前时区: ${formatTimezoneDisplay(timezone)}`;
// 使用多渠道发送
await sendNotificationToAllChannels(title, commonContent, config, '[手动测试]');
return { success: true, message: '测试通知已发送到所有启用的渠道' };
} catch (error) {
console.error('[手动测试] 发送失败:', error);
return { success: false, message: '发送时发生错误: ' + error.message };
}
}
async function sendWebhookNotification(title, content, config) {
try {
if (!config.WEBHOOK_URL) {
console.error('[企业微信应用通知] 通知未配置,缺少URL');
return false;
}
console.log('[企业微信应用通知] 开始发送通知到: ' + config.WEBHOOK_URL);
let requestBody;
let headers = { 'Content-Type': 'application/json' };
// 处理自定义请求头
if (config.WEBHOOK_HEADERS) {
try {
const customHeaders = JSON.parse(config.WEBHOOK_HEADERS);
headers = { ...headers, ...customHeaders };
} catch (error) {
console.warn('[企业微信应用通知] 自定义请求头格式错误,使用默认请求头');
}
}
// 处理消息模板
if (config.WEBHOOK_TEMPLATE) {
try {
const template = JSON.parse(config.WEBHOOK_TEMPLATE);
requestBody = JSON.stringify(template)
.replace(/\{\{title\}\}/g, title)
.replace(/\{\{content\}\}/g, content);
requestBody = JSON.parse(requestBody);
} catch (error) {
console.warn('[企业微信应用通知] 消息模板格式错误,使用默认格式');
requestBody = { title, content };
}
} else {
requestBody = { title, content };
}
const response = await fetch(config.WEBHOOK_URL, {
method: config.WEBHOOK_METHOD || 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
const result = await response.text();
console.log('[企业微信应用通知] 发送结果:', response.status, result);
return response.ok;
} catch (error) {
console.error('[企业微信应用通知] 发送通知失败:', error);
return false;
}
}
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 sendWechatBotNotification(title, content, config) {
try {
if (!config.WECHATBOT_WEBHOOK) {
console.error('[企业微信机器人] 通知未配置,缺少Webhook URL');
return false;
}
console.log('[企业微信机器人] 开始发送通知到: ' + config.WECHATBOT_WEBHOOK);
// 构建消息内容
let messageData;
const msgType = config.WECHATBOT_MSG_TYPE || 'text';
if (msgType === 'markdown') {
// Markdown 消息格式
const markdownContent = `# ${title}\n\n${content}`;
messageData = {
msgtype: 'markdown',
markdown: {
content: markdownContent
}
};
} else {
// 文本消息格式 - 优化显示
const textContent = `${title}\n\n${content}`;
messageData = {
msgtype: 'text',
text: {
content: textContent
}
};
}
// 处理@功能
if (config.WECHATBOT_AT_ALL === 'true') {
// @所有人
if (msgType === 'text') {
messageData.text.mentioned_list = ['@all'];
}
} else if (config.WECHATBOT_AT_MOBILES) {
// @指定手机号
const mobiles = config.WECHATBOT_AT_MOBILES.split(',').map(m => m.trim()).filter(m => m);
if (mobiles.length > 0) {
if (msgType === 'text') {
messageData.text.mentioned_mobile_list = mobiles;
}
}
}
console.log('[企业微信机器人] 发送消息数据:', JSON.stringify(messageData, null, 2));
const response = await fetch(config.WECHATBOT_WEBHOOK, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(messageData)
});
const responseText = await response.text();
console.log('[企业微信机器人] 响应状态:', response.status);
console.log('[企业微信机器人] 响应内容:', responseText);
if (response.ok) {
try {
const result = JSON.parse(responseText);
if (result.errcode === 0) {
console.log('[企业微信机器人] 通知发送成功');
return true;
} else {
console.error('[企业微信机器人] 发送失败,错误码:', result.errcode, '错误信息:', result.errmsg);
return false;
}
} catch (parseError) {
console.error('[企业微信机器人] 解析响应失败:', parseError);
return false;
}
} else {
console.error('[企业微信机器人] HTTP请求失败,状态码:', response.status);
return false;
}
} catch (error) {
console.error('[企业微信机器人] 发送通知失败:', error);
return false;
}
}
// 优化通知内容格式
function formatNotificationContent(subscriptions, config) {
const showLunar = config.SHOW_LUNAR === true;
const timezone = config?.TIMEZONE || 'UTC';
let content = '';
for (const sub of subscriptions) {
const typeText = sub.customType || '其他';
const periodText = (sub.periodValue && sub.periodUnit) ? `(周期: ${sub.periodValue} ${ { day: '天', month: '月', year: '年' }[sub.periodUnit] || sub.periodUnit})` : '';
// 格式化到期日期(使用所选时区)
const expiryDateObj = new Date(sub.expiryDate);
const formattedExpiryDate = formatTimeInTimezone(expiryDateObj, timezone, 'date');
// 农历日期
let lunarExpiryText = '';
if (showLunar) {
const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate());
lunarExpiryText = lunarExpiry ? `\n农历日期: ${lunarExpiry.fullStr}` : '';
}
// 状态和到期时间
let statusText = '';
let statusEmoji = '';
if (sub.daysRemaining === 0) {
statusEmoji = '⚠️';
statusText = '今天到期!';
} else if (sub.daysRemaining < 0) {
statusEmoji = '🚨';
statusText = `已过期 ${Math.abs(sub.daysRemaining)} 天`;
} else {
statusEmoji = '📅';
statusText = `将在 ${sub.daysRemaining} 天后到期`;
}
// 获取日历类型和自动续期状态
const calendarType = sub.useLunar ? '农历' : '公历';
const autoRenewText = sub.autoRenew ? '是' : '否';
// 构建格式化的通知内容
const subscriptionContent = `${statusEmoji} **${sub.name}**
类型: ${typeText} ${periodText}
日历类型: ${calendarType}
到期日期: ${formattedExpiryDate}${lunarExpiryText}
自动续期: ${autoRenewText}
到期状态: ${statusText}`;
// 添加备注
let finalContent = sub.notes ?
subscriptionContent + `\n备注: ${sub.notes}` :
subscriptionContent;
content += finalContent + '\n\n';
}
// 添加发送时间和时区信息
const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime');
content += `发送时间: ${currentTime}\n当前时区: ${formatTimezoneDisplay(timezone)}`;
return content;
}
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}`;
const success = await sendTelegramNotification(telegramContent, config);
console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('webhook')) {
const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendWebhookNotification(title, webhookContent, config);
console.log(`${logPrefix} 发送企业微信应用通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('wechatbot')) {
const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendWechatBotNotification(title, wechatbotContent, config);
console.log(`${logPrefix} 发送企业微信机器人通知 ${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}`);
}
if (config.ENABLED_NOTIFIERS.includes('email')) {
const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendEmailNotification(title, emailContent, config);
console.log(`${logPrefix} 发送邮件通知 ${success ? '成功' : '失败'}`);
}
}
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 sendEmailNotification(title, content, config) {
try {
if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !config.EMAIL_TO) {
console.error('[邮件通知] 通知未配置,缺少必要参数');
return false;
}
console.log('[邮件通知] 开始发送邮件到: ' + config.EMAIL_TO);
// 生成HTML邮件内容
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px 20px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px 20px; }
.content h2 { color: #333; margin-top: 0; }
.content p { color: #666; line-height: 1.6; margin: 16px 0; }
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #666; font-size: 14px; }
.highlight { background-color: #e3f2fd; padding: 15px; border-radius: 8px; margin: 20px 0; }
.button { display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📅 ${title}</h1>
</div>
<div class="content">
<div class="highlight">
${content.replace(/\n/g, '<br>')}
</div>
<p>此邮件由订阅管理系统自动发送,请及时处理相关订阅事务。</p>
</div>
<div class="footer">
<p>订阅管理系统 | 发送时间: ${formatTimeInTimezone(new Date(), config?.TIMEZONE || 'UTC', 'datetime')}</p>
</div>
</div>
</body>
</html>`;
const fromEmail = config.EMAIL_FROM_NAME ?
`${config.EMAIL_FROM_NAME} <${config.EMAIL_FROM}>` :
config.EMAIL_FROM;
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.RESEND_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: fromEmail,
to: config.EMAIL_TO,
subject: title,
html: htmlContent,
text: content // 纯文本备用
})
});
const result = await response.json();
console.log('[邮件通知] 发送结果:', response.status, result);
if (response.ok && result.id) {
console.log('[邮件通知] 邮件发送成功,ID:', result.id);
return true;
} else {
console.error('[邮件通知] 邮件发送失败:', result);
return false;
}
} catch (error) {
console.error('[邮件通知] 发送邮件失败:', 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);
}
}
// 4. 修改定时任务 checkExpiringSubscriptions,支持农历周期自动续订和农历提醒
async function checkExpiringSubscriptions(env) {
try {
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
console.log('[定时任务] 开始检查即将到期的订阅 UTC: ' + new Date().toISOString() + ', ' + timezone + ': ' + currentTime.toLocaleString('zh-CN', {timeZone: timezone}));
const subscriptions = await getAllSubscriptions(env);
console.log('[定时任务] 共找到 ' + subscriptions.length + ' 个订阅');
const expiringSubscriptions = [];
const updatedSubscriptions = [];
let hasUpdates = false;
for (const subscription of subscriptions) {
if (subscription.isActive === false) {
console.log('[定时任务] 订阅 "' + subscription.name + '" 已停用,跳过');
continue;
}
let daysDiff;
if (subscription.useLunar) {
const expiryDate = new Date(subscription.expiryDate);
let lunar = lunarCalendar.solar2lunar(
expiryDate.getFullYear(),
expiryDate.getMonth() + 1,
expiryDate.getDate()
);
// 使用与前端一致的计算逻辑:基于时区日期的午夜时间
const solar = lunarBiz.lunar2solar(lunar);
const lunarDate = new Date(solar.year, solar.month - 1, solar.day);
const currentDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const currentParts = currentDtf.formatToParts(currentTime);
const getCurrent = type => Number(currentParts.find(x => x.type === type).value);
const currentDateInTimezone = Date.UTC(getCurrent('year'), getCurrent('month') - 1, getCurrent('day'), 0, 0, 0);
const lunarDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const lunarParts = lunarDtf.formatToParts(lunarDate);
const getLunar = type => Number(lunarParts.find(x => x.type === type).value);
const lunarDateInTimezone = Date.UTC(getLunar('year'), getLunar('month') - 1, getLunar('day'), 0, 0, 0);
daysDiff = Math.round((lunarDateInTimezone - currentDateInTimezone) / (1000 * 60 * 60 * 24));
console.log('[定时任务] 订阅 "' + subscription.name + '" 到期日期: ' + expiryDate.toISOString() + ', 农历转换后午夜时间: ' + new Date(lunarDateInTimezone).toISOString() + ', 剩余天数: ' + daysDiff);
if (daysDiff < 0 && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) {
let nextLunar = lunar;
do {
nextLunar = lunarBiz.addLunarPeriod(nextLunar, subscription.periodValue, subscription.periodUnit);
const solar = lunarBiz.lunar2solar(nextLunar);
var newExpiryDate = new Date(solar.year, solar.month - 1, solar.day);
// 使用与前端一致的计算逻辑:基于时区日期的午夜时间
const newLunarDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const newLunarParts = newLunarDtf.formatToParts(newExpiryDate);
const getNewLunar = type => Number(newLunarParts.find(x => x.type === type).value);
const newLunarDateInTimezone = Date.UTC(getNewLunar('year'), getNewLunar('month') - 1, getNewLunar('day'), 0, 0, 0);
daysDiff = Math.round((newLunarDateInTimezone - currentDateInTimezone) / (1000 * 60 * 60 * 24));
console.log('[定时任务] 订阅 "' + subscription.name + '" 更新到期日期: ' + newExpiryDate.toISOString() + ', 农历转换后: ' + newLunarDateInTimezone.toISOString() + ', 剩余天数: ' + daysDiff);
} while (daysDiff < 0);
const updatedSubscription = { ...subscription, expiryDate: newExpiryDate.toISOString() };
updatedSubscriptions.push(updatedSubscription);
hasUpdates = true;
let reminderDays = subscription.reminderDays !== undefined ? subscription.reminderDays : 7;
let shouldRemindAfterRenewal = false;
if (reminderDays === 0) {
shouldRemindAfterRenewal = daysDiff === 0;
} else {
shouldRemindAfterRenewal = daysDiff >= 0 && daysDiff <= reminderDays;
}
if (shouldRemindAfterRenewal) {
console.log('[定时任务] 订阅 "' + subscription.name + '" 在提醒范围内,将发送通知');
expiringSubscriptions.push({
...updatedSubscription,
daysRemaining: daysDiff
});
}
continue;
}
} else {
const expiryDate = new Date(subscription.expiryDate);
// 使用与前端一致的计算逻辑:基于时区日期的午夜时间
const currentDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const currentParts = currentDtf.formatToParts(currentTime);
const getCurrent = type => Number(currentParts.find(x => x.type === type).value);
const currentDateInTimezone = Date.UTC(getCurrent('year'), getCurrent('month') - 1, getCurrent('day'), 0, 0, 0);
const expiryDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const expiryParts = expiryDtf.formatToParts(expiryDate);
const getExpiry = type => Number(expiryParts.find(x => x.type === type).value);
const expiryDateInTimezone = Date.UTC(getExpiry('year'), getExpiry('month') - 1, getExpiry('day'), 0, 0, 0);
daysDiff = Math.round((expiryDateInTimezone - currentDateInTimezone) / (1000 * 60 * 60 * 24));
console.log('[定时任务] 订阅 "' + subscription.name + '" 到期日期: ' + expiryDate.toISOString() + ', 时区午夜时间: ' + new Date(expiryDateInTimezone).toISOString() + ', 剩余天数: ' + daysDiff);
if (daysDiff < 0 && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) {
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);
}
// 将新计算的到期日期转换为指定时区进行比较(使用午夜时间)
const newExpiryDtfForCompare = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const newExpiryPartsForCompare = newExpiryDtfForCompare.formatToParts(newExpiryDate);
const getNewExpiryForCompare = type => Number(newExpiryPartsForCompare.find(x => x.type === type).value);
const newExpiryDateInTimezoneForCompare = Date.UTC(getNewExpiryForCompare('year'), getNewExpiryForCompare('month') - 1, getNewExpiryForCompare('day'), 0, 0, 0);
while (newExpiryDateInTimezoneForCompare < currentDateInTimezone) {
console.log('[定时任务] 新计算的到期日期 ' + newExpiryDate.toISOString() + ' (时区转换后: ' + newExpiryDateInTimezoneForCompare.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);
}
// 更新时区转换后的时间用于下次比较(使用午夜时间)
const updatedExpiryPartsForCompare = newExpiryDtfForCompare.formatToParts(newExpiryDate);
const getUpdatedExpiryForCompare = type => Number(updatedExpiryPartsForCompare.find(x => x.type === type).value);
newExpiryDateInTimezoneForCompare = Date.UTC(getUpdatedExpiryForCompare('year'), getUpdatedExpiryForCompare('month') - 1, getUpdatedExpiryForCompare('day'), 0, 0, 0);
}
console.log('[定时任务] 订阅 "' + subscription.name + '" 更新到期日期: ' + newExpiryDate.toISOString());
const updatedSubscription = { ...subscription, expiryDate: newExpiryDate.toISOString() };
updatedSubscriptions.push(updatedSubscription);
hasUpdates = true;
// 将新的到期日期转换为指定时区的时间,然后计算天数差(使用午夜时间)
const newExpiryDtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit'
});
const newExpiryParts = newExpiryDtf.formatToParts(newExpiryDate);
const getNewExpiry = type => Number(newExpiryParts.find(x => x.type === type).value);
const newExpiryDateInTimezone = Date.UTC(getNewExpiry('year'), getNewExpiry('month') - 1, getNewExpiry('day'), 0, 0, 0);
const newDaysDiff = Math.round((newExpiryDateInTimezone - currentDateInTimezone) / (1000 * 60 * 60 * 24));
let reminderDays = subscription.reminderDays !== undefined ? subscription.reminderDays : 7;
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
});
}
continue;
}
}
const reminderDays = subscription.reminderDays !== undefined ? subscription.reminderDays : 7;
let shouldRemind = false;
if (reminderDays === 0) {
shouldRemind = daysDiff === 0;
} else {
shouldRemind = daysDiff >= 0 && daysDiff <= reminderDays;
}
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) {
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));
}
if (expiringSubscriptions.length > 0) {
// 按到期时间排序
expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining);
// 使用优化的格式化函数
const commonContent = formatNotificationContent(expiringSubscriptions, config);
const title = '订阅到期提醒';
await sendNotificationToAllChannels(title, commonContent, config, '[定时任务]');
}
} 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('');
});
}
};
function getCurrentTime(config) {
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: timezone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
return {
date: currentTime,
localString: formatter.format(currentTime),
isoString: currentTime.toISOString()
};
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 添加调试页面
if (url.pathname === '/debug') {
try {
const config = await getConfig(env);
const debugInfo = {
timestamp: new Date().toISOString(), // 使用UTC时间戳
pathname: url.pathname,
kvBinding: !!env.SUBSCRIPTIONS_KV,
configExists: !!config,
adminUsername: config.ADMIN_USERNAME,
hasJwtSecret: !!config.JWT_SECRET,
jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0
};
return new Response(`
<!DOCTYPE html>
<html>
<head>
<title>调试信息</title>
<style>
body { font-family: monospace; padding: 20px; background: #f5f5f5; }
.info { background: white; padding: 15px; margin: 10px 0; border-radius: 5px; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h1>系统调试信息</h1>
<div class="info">
<h3>基本信息</h3>
<p>时间: ${debugInfo.timestamp}</p>
<p>路径: ${debugInfo.pathname}</p>
<p class="${debugInfo.kvBinding ? 'success' : 'error'}">KV绑定: ${debugInfo.kvBinding ? '✓' : '✗'}</p>
</div>
<div class="info">
<h3>配置信息</h3>
<p class="${debugInfo.configExists ? 'success' : 'error'}">配置存在: ${debugInfo.configExists ? '✓' : '✗'}</p>
<p>管理员用户名: ${debugInfo.adminUsername}</p>
<p class="${debugInfo.hasJwtSecret ? 'success' : 'error'}">JWT密钥: ${debugInfo.hasJwtSecret ? '✓' : '✗'} (长度: ${debugInfo.jwtSecretLength})</p>
</div>
<div class="info">
<h3>解决方案</h3>
<p>1. 确保KV命名空间已正确绑定为 SUBSCRIPTIONS_KV</p>
<p>2. 尝试访问 <a href="/">/</a> 进行登录</p>
<p>3. 如果仍有问题,请检查Cloudflare Workers日志</p>
</div>
</body>
</html>`, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
} catch (error) {
return new Response(`调试页面错误: ${error.message}`, {
status: 500,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
}
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) {
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
console.log('[Workers] 定时任务触发 UTC:', new Date().toISOString(), timezone + ':', currentTime.toLocaleString('zh-CN', {timeZone: timezone}));
await checkExpiringSubscriptions(env);
}
};
开始使用:
1️⃣ 一键部署”
Fork仓库,然后点击自己仓库里的部署按钮,等待部署完成,注意,KV名称修改为
SUBSCRIPTIONS_KV2️⃣ 首次登录“
- 访问部署后的域名
- 默认用户名:
admin- 默认密码:
password3️⃣ 开始使用”
- 修改默认密码(进入系统配置)
- 配置通知渠道(选择一个或多个)
- 添加订阅,设置提醒
- 享受智能提醒!
通知渠道配置:
Telegram:
- Bot Token: 从 @BotFather 获取
- Chat ID: 从 @userinfobot 获取
NotifyX:
- API Key: 从 NotifyX官网 获取
邮件通知 (Resend)
- API Key: 从 Resend官方教程 获取
- 发件人邮箱: 必须是已在Resend验证的域名邮箱
- 收件人邮箱: 接收通知的邮箱地址
- 支持HTML格式的美观邮件模板
企业微信应用通知:
- 推送 URL: 从 企业微信应用通知平台 获取
- 支持自定义请求头和消息模板
- 企业微信机器人
- 推送 URL: 参考官方文档获取
- 系统默认每天早上8点自动检查即将到期的订阅
注意!!!在开始之前,需在Cloudfalre控制左侧面板里,依次点击:存储和数据库 – KV – 创建 – 创建KV空间,空间名称和变量都填写:SUBSCRIPTIONS_KV 保存后再部署, 并给worker绑定上键值对,以及设置定时执行时间! 注意:这里的名称一定是:SUBSCRIPTIONS_KV 否则 SubsTracker 添加订阅将无法保存,作者官方下方也有部署代码,大家感兴趣也去作者下方观看教程…
© 版权声明
THE END


![图片[5]-SubsTracker – 订阅管理与提醒系统,再也不怕忘记续费-米渡资源网](https://www.micdu.com/wp-content/uploads/2025/09/20250827232007332-68747470733a2f2f696d672e77616e6777616e6769742e636f6d2f66696c652f313735313934323537383130385f696d6167652e706e67.webp)






暂无评论内容