From 06d952b492e125b24cbe59fbae4127145ae3be62 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Tue, 19 May 2026 11:14:03 +0800 Subject: [PATCH 01/44] =?UTF-8?q?fix(#166):=20=E4=BF=AE=E5=A4=8D=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=BB=98=E8=AE=A4=E6=97=A5=E6=9C=9F=E4=BD=BF=E7=94=A8?= =?UTF-8?q?UTC=E8=80=8C=E9=9D=9E=E6=9C=AC=E5=9C=B0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加订阅时默认开始日期改用本地日期 - 续订表单默认付款日期改用本地日期 - 编辑支付记录时日期显示改用本地日期 - 自动计算到期日期改用本地日期格式化 原因:toISOString() 返回 UTC 时间,UTC+8 用户在 0:00-8:00 之间会得到前一天的日期 --- src/views/adminPage.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/views/adminPage.html b/src/views/adminPage.html index 01fa589c..59ee29e4 100644 --- a/src/views/adminPage.html +++ b/src/views/adminPage.html @@ -1692,7 +1692,8 @@

添加新订阅 function showEditPaymentModal(subscription, payment) { const paymentDate = new Date(payment.date); - const formattedDate = paymentDate.toISOString().split('T')[0]; + const formattedDate = paymentDate.getFullYear() + '-' + String(paymentDate.getMonth() + 1).padStart(2, '0') + '-' + String(paymentDate.getDate()).padStart(2, '0'); const modalHtml = `
@@ -2293,7 +2294,8 @@

document.getElementById('subscriptionId').value = ''; clearFieldErrors(); - const today = new Date().toISOString().split('T')[0]; // 前端使用本地时间 + const now = new Date(); + const today = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); document.getElementById('startDate').value = today; document.getElementById('category').value = ''; document.getElementById('reminderValue').value = '7'; @@ -2953,7 +2955,7 @@

expiry.setFullYear(solar.year); expiry.setMonth(solar.month - 1); expiry.setDate(solar.day); - document.getElementById('expiryDate').value = expiry.toISOString().split('T')[0]; + document.getElementById('expiryDate').value = expiry.getFullYear() + '-' + String(expiry.getMonth() + 1).padStart(2, '0') + '-' + String(expiry.getDate()).padStart(2, '0'); debugLog('start:', start); debugLog('nextLunar:', nextLunar); debugLog('expiry:', expiry); @@ -2976,7 +2978,7 @@

} else if (periodUnit === 'year') { expiry.setFullYear(start.getFullYear() + periodValue); } - document.getElementById('expiryDate').value = expiry.toISOString().split('T')[0]; + document.getElementById('expiryDate').value = expiry.getFullYear() + '-' + String(expiry.getMonth() + 1).padStart(2, '0') + '-' + String(expiry.getDate()).padStart(2, '0'); debugLog('start:', start); debugLog('expiry:', expiry); debugLog('expiryDate:', document.getElementById('expiryDate').value); From d5452336b65c34fe666206158572d5b869010ab1 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Tue, 19 May 2026 11:15:42 +0800 Subject: [PATCH 02/44] =?UTF-8?q?fix(#166):=20=E8=B0=83=E5=BA=A6=E5=99=A8?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=94=A8=E6=88=B7=E9=85=8D=E7=BD=AE=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=E8=AE=A1=E7=AE=97=E5=88=B0=E6=9C=9F=E5=A4=A9=E6=95=B0?= =?UTF-8?q?=E5=92=8C=E9=80=9A=E7=9F=A5=E6=97=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - todayMidnight 改为基于 config.TIMEZONE 计算 - currentHour 改为使用 getTimezoneDateParts 获取时区对应小时 - NOTIFICATION_HOURS 现在按用户配置时区解释 原因:之前硬编码 UTC,导致 UTC+8 用户设置'到期当天提醒'时, 在北京时间 0:00(UTC 16:00)触发时 daysDiff 仍为 1 而非 0 --- src/services/scheduler.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/scheduler.js b/src/services/scheduler.js index c5d2edd2..1ac30fe4 100644 --- a/src/services/scheduler.js +++ b/src/services/scheduler.js @@ -1,6 +1,6 @@ import { getConfig } from '../data/config.js'; import { getAllSubscriptions } from '../data/subscriptions.js'; -import { getCurrentTimeInTimezone, MS_PER_HOUR, MS_PER_DAY, getTimezoneMidnightTimestamp } from '../core/time.js'; +import { getCurrentTimeInTimezone, MS_PER_HOUR, MS_PER_DAY, getTimezoneMidnightTimestamp, getTimezoneDateParts } from '../core/time.js'; import { formatNotificationContent, shouldTriggerReminder } from './notify/reminder.js'; import { sendNotificationToAllChannels } from './notify/index.js'; import { lunarCalendar, lunarBiz } from '../core/lunar.js'; @@ -41,9 +41,9 @@ async function dedupeNotifications(env, subscriptions, bucketKey) { async function checkExpiringSubscriptions(env) { try { const config = await getConfig(env); - const timezone = 'UTC'; - const currentTime = getCurrentTimeInTimezone('UTC'); - const todayMidnight = getTimezoneMidnightTimestamp(currentTime, 'UTC'); + const timezone = config.TIMEZONE || 'UTC'; + const currentTime = getCurrentTimeInTimezone(timezone); + const todayMidnight = getTimezoneMidnightTimestamp(currentTime, timezone); const subscriptions = await getAllSubscriptions(env); const expiringSubscriptions = []; @@ -53,7 +53,7 @@ async function checkExpiringSubscriptions(env) { const normalizedNotificationHours = Array.isArray(config.NOTIFICATION_HOURS) ? config.NOTIFICATION_HOURS.map(h => String(h).padStart(2, '0')) : []; - const currentHour = String(currentTime.getHours()).padStart(2, '0'); + const currentHour = String(getTimezoneDateParts(currentTime, timezone).hour).padStart(2, '0'); const shouldNotifyThisHour = normalizedNotificationHours.includes('*') || normalizedNotificationHours.includes('ALL') || From 55ff9cd0671861789958b7534008c7c734acb1e5 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Tue, 19 May 2026 11:16:25 +0800 Subject: [PATCH 03/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20deletePayment?= =?UTF-8?q?Record=20=E4=B8=AD=E6=8E=92=E5=BA=8F=E6=AF=94=E8=BE=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=BC=95=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sortedByPeriodEnd 排序中 dateB 错误引用了 a.periodEnd, 应为 b.periodEnd。此 bug 导致删除支付记录后到期日期回退不正确。 --- src/data/subscriptions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/subscriptions.js b/src/data/subscriptions.js index dabaa473..b618bb75 100644 --- a/src/data/subscriptions.js +++ b/src/data/subscriptions.js @@ -368,7 +368,7 @@ async function deletePaymentRecord(subscriptionId, paymentId, env) { if (paymentHistory.length > 0) { const sortedByPeriodEnd = [...paymentHistory].sort((a, b) => { const dateA = a.periodEnd ? new Date(a.periodEnd) : new Date(0); - const dateB = a.periodEnd ? new Date(a.periodEnd) : new Date(0); + const dateB = b.periodEnd ? new Date(b.periodEnd) : new Date(0); return dateB - dateA; }); From e1112bb1148e364bfbbd777166ff1d15d26aaa57 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Tue, 19 May 2026 11:18:40 +0800 Subject: [PATCH 04/44] =?UTF-8?q?fix(#166):=20=E4=BF=AE=E5=A4=8D=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=88=97=E8=A1=A8=E5=88=B0=E6=9C=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=9A=84=E6=97=B6=E5=8C=BA=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diffMs/diffHours 改为基于用户时区计算,而非直接用 UTC 时间戳差值 - 状态判断增加 diffMs < 0 条件,到期日当天过午夜也显示'已过期' 原因:expiryDate 存储为 UTC 午夜,直接用 getTime() 差值会导致 UTC+8 用户在到期日当天凌晨看到'约7小时后到期'而非'已过期' --- src/views/adminPage.html | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/views/adminPage.html b/src/views/adminPage.html index 59ee29e4..026019ec 100644 --- a/src/views/adminPage.html +++ b/src/views/adminPage.html @@ -1347,6 +1347,16 @@

添加新订阅 Number(currentParts.find(x => x.type === type).value); const currentDateInTimezone = Date.UTC(getCurrent('year'), getCurrent('month') - 1, getCurrent('day'), 0, 0, 0); + // 获取当前时间在时区中的精确时间戳(用于小时级对比) + const currentTimeDtf = new Intl.DateTimeFormat('en-US', { + timeZone: globalTimezone, hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + const currentFullParts = currentTimeDtf.formatToParts(currentTime); + const getFullCurrent = type => Number(currentFullParts.find(x => x.type === type).value); + const currentTimestamp = Date.UTC(getFullCurrent('year'), getFullCurrent('month') - 1, getFullCurrent('day'), getFullCurrent('hour'), getFullCurrent('minute'), getFullCurrent('second')); + const displayDtf = new Intl.DateTimeFormat('zh-CN', { timeZone: globalTimezone, year: 'numeric', @@ -1373,7 +1383,7 @@

添加新订阅添加新订阅 Date: Tue, 19 May 2026 11:19:34 +0800 Subject: [PATCH 05/44] =?UTF-8?q?fix(#169):=20=E5=85=BC=E5=AE=B9=20Bark=20?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=9C=8D=E5=8A=A1=E5=99=A8=20URL=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 bark-worker 等自定义服务器的完整 URL 格式 (如 https://user:pass@server/deviceKey/)。 当 BARK_SERVER 路径不为空时,直接 POST 到该 URL; 否则走标准 Bark API (serverUrl + /push + device_key)。 --- src/services/notify/bark.js | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/services/notify/bark.js b/src/services/notify/bark.js index f0578587..7d026039 100644 --- a/src/services/notify/bark.js +++ b/src/services/notify/bark.js @@ -1,19 +1,33 @@ async function sendBarkNotification(title, content, config) { try { - if (!config.BARK_DEVICE_KEY) { - console.error('[Bark] 通知未配置,缺少设备Key'); + if (!config.BARK_DEVICE_KEY && !config.BARK_SERVER) { + console.error('[Bark] 通知未配置,缺少设备Key或服务器地址'); return false; } - console.log('[Bark] 开始发送通知到设备: ' + config.BARK_DEVICE_KEY); + const serverUrl = (config.BARK_SERVER || 'https://api.day.app').replace(/\/+$/, ''); - const serverUrl = config.BARK_SERVER || 'https://api.day.app'; - const url = serverUrl + '/push'; - const payload = { - title: title, - body: content, - device_key: config.BARK_DEVICE_KEY - }; + // 判断是否为自定义完整 URL(路径中包含设备Key,如 bark-worker 格式) + let url; + let payload; + const parsedPath = new URL(serverUrl).pathname; + const isCustomUrl = parsedPath && parsedPath !== '/'; + + if (isCustomUrl) { + // 自定义服务器:直接 POST 到完整 URL + url = serverUrl; + payload = { title, body: content }; + console.log('[Bark] 使用自定义服务器URL发送通知'); + } else { + // 标准 Bark API + if (!config.BARK_DEVICE_KEY) { + console.error('[Bark] 通知未配置,缺少设备Key'); + return false; + } + url = serverUrl + '/push'; + payload = { title, body: content, device_key: config.BARK_DEVICE_KEY }; + console.log('[Bark] 开始发送通知到设备: ' + config.BARK_DEVICE_KEY); + } if (config.BARK_IS_ARCHIVE === 'true') { payload.isArchive = 1; From 008df6e5c52954480ab800dc194991fc3c55f58f Mon Sep 17 00:00:00 2001 From: wangwangit Date: Tue, 19 May 2026 11:54:08 +0800 Subject: [PATCH 06/44] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20apiFetch=20w?= =?UTF-8?q?rapper=EF=BC=8C401=20=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminPage/configPage/dashboardPage 添加 apiFetch 封装 - 所有 API 调用替换为 apiFetch,401 时提示并跳转登录页 - theme-resources.js 中兼容性检测 apiFetch 可用性 解决认证过期后用户看到'未知错误'而非跳转登录的问题 --- src/views/adminPage.html | 41 +++++++++++++++++++++++------------- src/views/configPage.html | 21 +++++++++++++----- src/views/dashboardPage.html | 12 ++++++++++- src/views/theme-resources.js | 2 +- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/views/adminPage.html b/src/views/adminPage.html index 026019ec..4ab87602 100644 --- a/src/views/adminPage.html +++ b/src/views/adminPage.html @@ -705,6 +705,17 @@

添加新订阅 + * const r = await ApiClient.get('/api/notification-logs'); + * + * 所有方法都返回解析后的 JSON;HTTP 非 2xx 会抛出含 status / body 的 Error。 + * 自动带 Cookie(凭着站内 SameSite=Strict 的 token)。 + */ +(function (root) { + 'use strict'; + + async function request(method, url, body) { + /** @type {RequestInit} */ + const init = { + method, + credentials: 'same-origin', + headers: { Accept: 'application/json' } + }; + if (body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + let data = null; + try { + data = await res.json(); + } catch { + // 非 JSON 响应,保留 null + } + if (!res.ok) { + const err = new Error((data && data.message) || ('HTTP ' + res.status)); + // @ts-ignore + err.status = res.status; + // @ts-ignore + err.body = data; + throw err; + } + return data; + } + + /** + * 简易查询字符串构造(None / undefined 字段过滤掉)。 + * + * @param {Record} params + * @returns {string} + */ + function qs(params) { + if (!params) return ''; + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue; + usp.append(k, String(v)); + } + const s = usp.toString(); + return s ? '?' + s : ''; + } + + root.ApiClient = { + get: (url, params) => request('GET', url + qs(params)), + post: (url, body) => request('POST', url, body), + put: (url, body) => request('PUT', url, body), + delete: (url) => request('DELETE', url), + qs + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/wrangler.toml b/wrangler.toml index 914dc1d2..b96c391a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,6 @@ name = "subscription-manager" main = "src/index.js" -compatibility_date = "2024-01-01" +compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [env.production] @@ -11,6 +11,13 @@ name = "subscription-manager-staging" # KV 命名空间配置(默认模板不预设固定 ID,运行 npm run setup 自动生成) +# 静态资源(v3 起新增):托管 public/ 目录下的 JS/CSS/HTML 给浏览器 +# 使用方式:浏览器访问 /js/lib/api-client.js 等同于 public/js/lib/api-client.js +# 不命中静态资源的请求由 Worker fetch handler 处理(默认行为) +[assets] +directory = "./public" +binding = "ASSETS" + # 定时任务配置 - 每小时检查一次(UTC) [triggers] crons = ["0 * * * *"] From 3397d34c15d1a8b38c2c6f59e2070f2b6bb6d741 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Sun, 24 May 2026 18:24:17 +0800 Subject: [PATCH 24/44] =?UTF-8?q?feat(ui):=20=E8=AE=A2=E9=98=85=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=8A=A0=E5=85=A5=E5=A4=9A=E6=8F=90=E9=86=92=E8=A7=84?= =?UTF-8?q?=E5=88=99=E7=BC=96=E8=BE=91=E5=99=A8=EF=BC=88Task=2010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adminPage.html: - 替换原"提醒提前量"单行输入为"提醒规则"区块 - 顶部按钮:[应用预设(7/3/1/0)] [添加规则] - 每行:启用复选框、类型(到期前/到期当天/到期后)、数值、单位(天/小时) - after_expiry 类型展开:每 N 小时重复,直到 [续费/手动确认/不停止] - 隐藏原 reminderUnit/reminderValue 作为 v2 兜底字段保留 - validateForm 不再强制 reminderValue(已隐藏) JS 模块(位于 之前): - ReminderRulesEditor (setRules / setPresets / getRules / clear) - 新增订阅 → 自动渲染 4 条预设 - 编辑订阅 → 轮询监听 subscriptionId 字段变化,拉取 /api/.../reminders 渲染 - 全局 fetch 劫持:POST/PUT 订阅时自动附加 reminderRules 到 body - 编辑模式 (PUT) 完成后调用 PUT /api/subscriptions/:id/reminders 同步替换规则 后端: - v3-routes.js 新增 PUT /api/subscriptions/:id/reminders 整体替换接口 170 条测试全绿;wrangler dry-run 540 KiB / gzip 113 KiB。 Refs Task 10 of refactor/v3-product-grade plan. --- src/api/handlers/v3-routes.js | 14 ++ src/views/adminPage.html | 329 +++++++++++++++++++++++++++++++--- 2 files changed, 320 insertions(+), 23 deletions(-) diff --git a/src/api/handlers/v3-routes.js b/src/api/handlers/v3-routes.js index 8ced5dd9..ce6b558e 100644 --- a/src/api/handlers/v3-routes.js +++ b/src/api/handlers/v3-routes.js @@ -93,6 +93,20 @@ async function handleReminderRoute(request, env, method, subId, ruleId) { return json({ success: true, rule }); } + // PUT /subscriptions/:id/reminders(不带 ruleId)→ 整体替换规则列表 + if (method === 'PUT' && !ruleId) { + let body; + try { + body = await request.json(); + } catch { + return json({ success: false, message: '请求体不是合法 JSON' }, 400); + } + const rules = Array.isArray(body && body.rules) ? body.rules : []; + await remindersRepo.replaceForSubscription(env, subId, rules); + const saved = await remindersRepo.listForSubscription(env, subId); + return json({ success: true, rules: saved }); + } + // PUT /subscriptions/:id/reminders/:ruleId if (method === 'PUT' && ruleId) { let body; diff --git a/src/views/adminPage.html b/src/views/adminPage.html index 04fbf2df..a79448d7 100644 --- a/src/views/adminPage.html +++ b/src/views/adminPage.html @@ -659,30 +659,40 @@

添加新订阅

-
-
- -
-
- -
-
- -
-
-
-

- 0 = 仅在到期时提醒; 选择"小时"需要将 Worker 定时任务调整为小时级执行 -

+ +
+
+ +
+ +
+
+
+ +
+

+ 类型说明:到期前 N 天/小时 触发;到期当天;到期后每 X 小时重复(直到续费/手动确认)。 +

+ + + +
+ +
+
- +