From daedba2ef2a8e281c6f7fbafc8a5c132f6cd30de Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:37:41 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=20`GM?= =?UTF-8?q?=5FaddElement("tagName")`=20=E9=94=99=E8=AF=AF=20(#1120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b8d0f0ce7..98ff8d4b3 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -706,7 +706,7 @@ export default class GMApi extends GM_Base { parentNodeId = id; } else { parentNodeId = null; - attrs = tagName as Record; + attrs = (tagName || {}) as Record; tagName = parentNode as string; } if (typeof tagName !== "string") throw new Error("The parameter 'tagName' of GM_addElement shall be a string."); From 510be97d6d28dc9932c22cdb4233157556664666 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:57:54 +0900 Subject: [PATCH 02/11] =?UTF-8?q?Cron=20nexTime=20=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/sandbox/runtime.ts | 15 +- src/locales/de-DE/translation.json | 8 + src/locales/en-US/translation.json | 8 + src/locales/ja-JP/translation.json | 8 + src/locales/ru-RU/translation.json | 8 + src/locales/vi-VN/translation.json | 8 + src/locales/zh-CN/translation.json | 8 + src/locales/zh-TW/translation.json | 8 + src/pages/install/App.tsx | 4 +- .../options/routes/ScriptList/ScriptCard.tsx | 4 +- .../options/routes/ScriptList/ScriptTable.tsx | 4 +- src/pkg/utils/cron.ts | 178 ++++++++++++++---- src/pkg/utils/script.ts | 4 +- src/pkg/utils/utils.test.ts | 161 +++++++++++++--- src/pkg/utils/utils.ts | 21 +++ 15 files changed, 366 insertions(+), 81 deletions(-) diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 222fe2e66..679024d57 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -14,7 +14,7 @@ import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; import type { ValueUpdateDataEncoded } from "../content/types"; -import { getStorageName, getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; +import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; import { parseUserConfig } from "@App/pkg/utils/yaml"; @@ -256,7 +256,7 @@ export class Runtime { flag = last.getMonth() !== now.getMonth(); break; case 5: // 每周 - flag = this.getWeek(last) !== this.getWeek(now); + flag = getISOWeek(last) !== getISOWeek(now); break; default: } @@ -270,17 +270,6 @@ export class Runtime { }; } - // 获取本周是第几周 - getWeek(date: Date) { - const nowDate = new Date(date); - const firstDay = new Date(date); - firstDay.setMonth(0); // 设置1月 - firstDay.setDate(1); // 设置1号 - const diffDays = Math.ceil((nowDate.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000)); - const week = Math.ceil(diffDays / 7); - return week === 0 ? 1 : week; - } - // 停止计时器 stopCronJob(uuid: string) { const list = this.cronJob.get(uuid); diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 0e6a0f11a..fa79e4afa 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Skript erhält vollständigen Zugriff auf die folgenden Adressen", "script_requires": "Skript referenziert die folgenden externen Ressourcen", "cookie_warning": "Achtung: Dieses Skript beantragt Cookie-Operationsberechtigung. Dies ist eine gefährliche Berechtigung, bitte stellen Sie die Sicherheit des Skripts sicher.", + "cron_oncetype": { + "minute": "{{next}} (jede Minute ausgeführt)", + "hour": "{{next}} (jede Stunde ausgeführt)", + "day": "{{next}} (jeden Tag ausgeführt)", + "month": "{{next}} (jeden Monat ausgeführt)", + "week": "{{next}} (jede Woche ausgeführt)" + }, + "cron_invalid_expr": "Ungültiger Cron-Ausdruck", "scheduled_script_description_title": "Dies ist ein geplantes Skript. Wenn aktiviert, wird es zu bestimmten Zeiten automatisch ausgeführt und kann im Panel manuell gesteuert werden.", "scheduled_script_description_description_expr": "Geplante Aufgaben-Ausdruck", "scheduled_script_description_description_next": "Letzte Ausführungszeit:", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 4375b315d..170104a46 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Script will have full access to the following URLs", "script_requires": "Script requires the following external resources", "cookie_warning": "Please note, this script requests access to Cookie permissions, which is a dangerous permission. Please verify the security of the script.", + "cron_oncetype": { + "minute": "{{next}} (runs every minute)", + "hour": "{{next}} (runs every hour)", + "day": "{{next}} (runs every day)", + "month": "{{next}} (runs every month)", + "week": "{{next}} (runs every week)" + }, + "cron_invalid_expr": "Invalid cron expression", "scheduled_script_description_title": "This is a scheduled script, which will automatically run at a specific time once enabled and can be manually controlled in the panel.", "scheduled_script_description_description_expr": "Scheduled task expression:", "scheduled_script_description_description_next": "Most recent run time:", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 9b0513a7c..5954e4b7a 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "スクリプトは以下のアドレスへの完全なアクセス権限を取得します", "script_requires": "スクリプトは以下の外部リソースを参照しています", "cookie_warning": "注意:このスクリプトはCookieの操作権限をリクエストします。これは危険な権限ですので、スクリプトの安全性を確認してください。", + "cron_oncetype": { + "minute": "{{next}}(毎分実行)", + "hour": "{{next}}(毎時間実行)", + "day": "{{next}}(毎日実行)", + "month": "{{next}}(毎月実行)", + "week": "{{next}}(毎週実行)" + }, + "cron_invalid_expr": "不正な cron 式です", "scheduled_script_description_title": "これはスケジュールスクリプトです。有効にすると特定の時間に自動実行され、手動操作も可能です。", "scheduled_script_description_description_expr": "スケジュールタスク表現:", "scheduled_script_description_description_next": "最近の実行時間:", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 351d6e030..75ce52780 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Скрипт получит полный доступ к следующим адресам", "script_requires": "Скрипт ссылается на следующие внешние ресурсы", "cookie_warning": "Обратите внимание, что этот скрипт запрашивает разрешения на операции с Cookie. Это опасное разрешение, пожалуйста, убедитесь в безопасности скрипта.", + "cron_oncetype": { + "minute": "{{next}} (выполняется каждую минуту)", + "hour": "{{next}} (выполняется каждый час)", + "day": "{{next}} (выполняется каждый день)", + "month": "{{next}} (выполняется каждый месяц)", + "week": "{{next}} (выполняется каждую неделю)" + }, + "cron_invalid_expr": "Неверное выражение cron", "scheduled_script_description_title": "Это запланированный скрипт. После включения он будет автоматически выполняться в определенное время и может управляться вручную с панели.", "scheduled_script_description_description_expr": "Выражение планировщика", "scheduled_script_description_description_next": "Последнее время выполнения:", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index 2777c47ef..256b474ac 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Script sẽ có toàn quyền truy cập vào các url sau", "script_requires": "Script yêu cầu các tài nguyên bên ngoài sau", "cookie_warning": "Xin lưu ý, script này yêu cầu quyền truy cập cookie, đây là một quyền nguy hiểm. Vui lòng xác minh tính bảo mật của script.", + "cron_oncetype": { + "minute": "{{next}} (chạy mỗi phút)", + "hour": "{{next}} (chạy mỗi giờ)", + "day": "{{next}} (chạy mỗi ngày)", + "month": "{{next}} (chạy mỗi tháng)", + "week": "{{next}} (chạy mỗi tuần)" + }, + "cron_invalid_expr": "Biểu thức cron không hợp lệ", "scheduled_script_description_title": "Đây là script hẹn giờ, sẽ tự động chạy vào một thời điểm cụ thể sau khi được bật và có thể được điều khiển thủ công trong bảng điều khiển.", "scheduled_script_description_description_expr": "Biểu thức tác vụ hẹn giờ:", "scheduled_script_description_description_next": "Thời gian chạy gần nhất:", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index edaa61b29..c69199c81 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "脚本将获得以下地址的完整访问权限", "script_requires": "脚本引用了下列外部资源", "cookie_warning": "请注意,本脚本会请求 Cookie 的操作权限,这是一个危险的权限,请确认脚本的安全性。", + "cron_oncetype": { + "minute": "{{next}} (每分钟运行一次)", + "hour": "{{next}} (每小时运行一次)", + "day": "{{next}} (每天运行一次)", + "month": "{{next}} (每月运行一次)", + "week": "{{next}} (每星期运行一次)" + }, + "cron_invalid_expr": "错误的定时表达式", "scheduled_script_description_title": "这是一个定时脚本,启用后将在特定时间自动运行,并可在面板中手动控制。", "scheduled_script_description_description_expr": "定时任务表达式:", "scheduled_script_description_description_next": "最近一次运行时间:", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index bdf475128..9d58702ba 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "腳本將獲得以下網址的完整存取權限", "script_requires": "腳本引用了以下外部資源", "cookie_warning": "請注意,此腳本會要求 Cookie 的操作權限,這是一項危險的權限,請確認腳本的安全性。", + "cron_oncetype": { + "minute": "{{next}} (每分鐘執行一次)", + "hour": "{{next}} (每小時執行一次)", + "day": "{{next}} (每天執行一次)", + "month": "{{next}} (每月執行一次)", + "week": "{{next}} (每星期執行一次)" + }, + "cron_invalid_expr": "錯誤的排程表達式", "scheduled_script_description_title": "這是一個排程腳本,啟用後將在特定時間自動執行,並可在控制面板中手動控制。", "scheduled_script_description_description_expr": "排程任務表達式:", "scheduled_script_description_description_next": "最近一次執行時間:", diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 5770bf2b8..31d6aba05 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -22,7 +22,7 @@ import { i18nDescription, i18nName } from "@App/locales/locales"; import { useTranslation } from "react-i18next"; import { createScriptInfo, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; import { parseMetadata, prepareScriptByCode, prepareSubscribeByCode } from "@App/pkg/utils/script"; -import { nextTime } from "@App/pkg/utils/cron"; +import { nextTimeDisplay } from "@App/pkg/utils/cron"; import { scriptClient, subscribeClient } from "../store/features/script"; import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/file-tracker"; import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; @@ -382,7 +382,7 @@ function App() { {t("scheduled_script_description_description_expr")} {metadataLive.crontab[0]} {t("scheduled_script_description_description_next")} - {nextTime(metadataLive.crontab[0])} + {nextTimeDisplay(metadataLive.crontab[0])} ); } else if (metadataLive.background) { diff --git a/src/pages/options/routes/ScriptList/ScriptCard.tsx b/src/pages/options/routes/ScriptList/ScriptCard.tsx index c79675706..65d7a54f6 100644 --- a/src/pages/options/routes/ScriptList/ScriptCard.tsx +++ b/src/pages/options/routes/ScriptList/ScriptCard.tsx @@ -13,7 +13,7 @@ import { import type { Script, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_RUN_STATUS_RUNNING, SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; import { requestEnableScript } from "@App/pages/store/features/script"; -import { nextTime } from "@App/pkg/utils/cron"; +import { nextTimeDisplay } from "@App/pkg/utils/cron"; import { i18nName } from "@App/locales/locales"; import { hashColor, ScriptIcons } from "../utils"; import { getCombinedMeta } from "@App/app/service/service_worker/utils"; @@ -207,7 +207,7 @@ export const ScriptCardItem = React.memo( content={ item.type === SCRIPT_TYPE_BACKGROUND ? t("background_script_tooltip") - : `${t("scheduled_script_tooltip")} ${nextTime(item.metadata!.crontab![0])}` + : `${t("scheduled_script_tooltip")} ${nextTimeDisplay(item.metadata!.crontab![0])}` } > diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index a757d516e..9ceec85de 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -1,45 +1,151 @@ import { CronTime } from "cron"; -import dayjs from "dayjs"; +import { t } from "@App/locales/locales"; // 计算下次执行时间,支持 once 关键字表示每分钟/每小时/每天/每月/每星期执行一次 -export function nextTime(crontab: string, date?: Date): string { - let oncePos = 0; - if (crontab.includes("once")) { - const vals = crontab.split(" "); - vals.forEach((val, index) => { - if (val === "once") { - oncePos = index; - } - }); - if (vals.length === 5) { - oncePos++; - } +// https://github.com/kelektiv/node-cron + +// ### 支持以下两个表达式 +// minute hour dayOfMonth month dayOfWeek +// second minute hour dayOfMonth month dayOfWeek +// ### 支持以下数值 +// `*` Asterisks: Any value +// `1-3,5` Ranges: Ranges and individual values +// `*/2` Steps: Every two units +// `once` 任何时刻的单次执行 + +/* ### 数值范围 + field allowed values + ----- -------------- + second 0-59 + minute 0-59 + hour 0-23 + day of month 1-31 + month 1-12 (or names, see below) + day of week 0-7 (0 or 7 is Sunday, or use names) +*/ + +// 使用 cron 内部的 DateTime consturctor +const DateTime = new CronTime("* * * * *").sendAt().constructor; + +/** + * once 在不同 cron 位置上的含义映射 + * key 为 once 所在的 cron 位(1 ~ 5,不含秒) + * + * 例: + * - "* once * * * *" → 每小时执行一次 + * - "* * once * * *" → 每天执行一次 + */ +const ONCE_MAP = { + 1: { unit: "minute", format: "yyyy-MM-dd HH:mm:ss", label: "minute" }, + 2: { unit: "hour", format: "yyyy-MM-dd HH:mm:ss", label: "hour" }, + 3: { unit: "day", format: "yyyy-MM-dd", label: "day" }, + 4: { unit: "month", format: "yyyy-MM", label: "month" }, + 5: { unit: "week", format: "yyyy-MM-dd", label: "week" }, +} as const; + +type NextTimeResult = { + /** 下一次触发时间(已格式化) */ + next: string; + /** once 类型,用于国际化展示 */ + once: string; +}; + +/** + * 对外展示用: + * - 如果是 once cron,返回类似“下次在 xx 执行一次” + * - 否则直接返回下一次执行时间 + */ +export const nextTimeDisplay = (crontab: string, date = new Date()): string => { + const res = nextTimeInfo(crontab, date); + if (res.once) { + return t(`cron_oncetype.${res.once}`, { next: res.next }); + } else { + return res.next; } +}; + +/** + * 解析 cron 表达式,计算下一次执行时间 + * 支持自定义 once 关键字(表示“在某个周期内只执行一次”) + */ +export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult => { + const parts = crontab.trim().split(" "); + + /** + * 兼容 5 位 / 6 位 cron: + * - 5 位:分 时 日 月 周 + * - 6 位:秒 分 时 日 月 周 + */ + const lenOffset = parts.length === 5 ? 1 : 0; + + // 非法长度直接判错 + if (parts.length + lenOffset !== 6) { + throw new Error(t("cron_invalid_expr")); + } + + /** + * once 必须是一个完整字段 + * 防止类似 "* once123 * * *" 这种误写 + */ + const onceIndex = parts.indexOf("once"); + if (onceIndex === -1 && crontab.includes("once")) { + throw new Error(t("cron_invalid_expr")); + } + + /** + * once 在 6 位 cron 中的实际位置 + * (5 位 cron 需要整体向后偏移一位) + */ + const oncePos = onceIndex !== -1 ? onceIndex + lenOffset : -1; + let cron: CronTime; try { - cron = new CronTime(crontab.replace(/once/g, "*")); + // 将 once 替换为 *,用于标准 cron 解析 + cron = new CronTime(crontab.replace("once", "*")); } catch { - throw new Error("错误的定时表达式"); + /** + * 不支持多个 once + * 例如:"* once once * *" + */ + throw new Error(t("cron_invalid_expr")); } - let datetime = dayjs(date || new Date()); - if (oncePos === 2) { - datetime = datetime.set("minute", 0).subtract(1, "minute").set("second", 0); - } - const nextdate = cron.getNextDateFrom(datetime.toDate()); - if (oncePos) { - switch (oncePos) { - case 1: // 每分钟 - return nextdate.toFormat("yyyy-MM-dd HH:mm:ss 每分钟运行一次"); - case 2: // 每小时 - return nextdate.plus({ hour: 1 }).toFormat("yyyy-MM-dd HH:mm:ss 每小时运行一次"); - case 3: // 每天 - return nextdate.plus({ day: 1 }).toFormat("yyyy-MM-dd 每天运行一次"); - case 4: // 每月 - return nextdate.plus({ month: 1 }).toFormat("yyyy-MM 每月运行一次"); - case 5: // 每星期 - return nextdate.plus({ week: 1 }).toFormat("yyyy-MM-dd 每星期运行一次"); - } - throw new Error("错误表达式"); + + let luxonDate = (DateTime as any).fromJSDate(date); + let format = "yyyy-MM-dd HH:mm:ss"; + let onceLabel = ""; + + /** + * 如果存在 once: + * 核心思路: + * 👉 直接跳到「下一个周期的起始时间」 + * 👉 再从该时间点开始计算 cron 的下一次命中 + */ + if (oncePos >= 1 && oncePos <= 5) { + const cfg = ONCE_MAP[oncePos as keyof typeof ONCE_MAP]; + onceLabel = cfg.label; + format = cfg.format; + + /** + * 例如: + * 当前时间:2026-01-02 10:23 + * once 在 hour 位 + * + * → 先跳到 11:00:00 + */ + luxonDate = luxonDate.plus({ [cfg.unit]: 1 }).startOf(cfg.unit as any); + + /** + * 再减去 1ms: + * 这样 getNextDateFrom 才能 + * 命中「正好等于周期起点」的 cron + */ + luxonDate = luxonDate.minus({ milliseconds: 1 }); } - return nextdate.toFormat("yyyy-MM-dd HH:mm:ss"); -} + + const next = cron.getNextDateFrom(luxonDate); + + return { + next: next.toFormat(format), + once: onceLabel, + }; +}; diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index b498976e0..671411987 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -12,7 +12,7 @@ import { } from "@App/app/repo/scripts"; import type { Subscribe } from "@App/app/repo/subscribe"; import { SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; -import { nextTime } from "./cron"; +import { nextTimeDisplay } from "./cron"; import { parseUserConfig } from "./yaml"; import { t as i18n_t } from "@App/locales/locales"; @@ -95,7 +95,7 @@ export async function prepareScriptByCode( if (metadata.crontab !== undefined) { type = SCRIPT_TYPE_CRONTAB; try { - nextTime(metadata.crontab[0]); + nextTimeDisplay(metadata.crontab[0]); } catch { throw new Error(i18n_t("error_cron_invalid", { expr: metadata.crontab[0] })); } diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 8ecd3c7f1..4b207f502 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -9,9 +9,7 @@ import { toCamelCase, } from "./utils"; import { ltever, versionCompare } from "@App/pkg/utils/semver"; -import { nextTime } from "./cron"; -import dayjs from "dayjs"; - +import { nextTimeDisplay, nextTimeInfo } from "./cron"; describe.concurrent("aNow", () => { // aNow >= Date.now(); it.sequential("aNow is greater than or equal to Date.now()", () => { @@ -36,40 +34,155 @@ describe.concurrent("aNow", () => { }); }); -describe.concurrent("nextTime", () => { - const date = new Date(1737275111000); +describe.concurrent("nextTimeInfo1", () => { + const date = new Date("2025-12-17T11:47:17.629"); // 2025-12-17 11:47:17.629 (本地时区) + // 让程序先执行一下,避免超时问题 beforeAll(() => { - nextTime("* * * * *"); - dayjs(date); + nextTimeDisplay("* * * * *"); }); - it.sequential("每分钟表达式", () => { - expect(nextTime("* * * * *", date)).toEqual(dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:00")); + + it.sequential("标准Cron表达式", () => { + // 2025-12-17 11:47:17.629 下一个秒执行点是 2025-12-17 11:48:18 + // 2025-12-17 11:47:17.629 下一个分钟执行点是 2025-12-17 11:48:00 + [ + ["* * * * * *", { next: "2025-12-17 11:47:18", once: "" }], + ["* * * * *", { next: "2025-12-17 11:48:00", once: "" }], + ["* 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "" }], + ["* 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); + it.sequential("每分钟一次表达式", () => { - expect(nextTime("once * * * *", date)).toEqual( - dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:00 每分钟运行一次") - ); - expect(nextTime("10 once * * * *", date)).toEqual( - dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:10 每分钟运行一次") - ); + // 假设 2025-12-17 11:47:17.629 已运行了,这分钟不再运行 + // 下一次可以执行的时间是 2025-12-17 11:48:00 + [ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["45 once * * * *", { next: "2025-12-17 11:48:45", once: "minute" }], + ["once 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "minute" }], + ["once 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "minute" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); + it.sequential("每小时一次表达式", () => { - expect(nextTime("* once * * *", date)).toEqual( - dayjs(date).add(1, "hour").format("YYYY-MM-DD HH:00:00 每小时运行一次") - ); - expect(nextTime("10 once * * *", date)).toEqual( - dayjs(date).add(1, "hour").format("YYYY-MM-DD HH:10:00 每小时运行一次") - ); + // 假设 2025-12-17 11:47:17.629 已运行了,这小时不再运行 + // 下一次可以执行的时间是 2025-12-17 12:00:00 + [ + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2025-12-17 12:10:45", once: "hour" }], + ["1-3,5 once * * *", { next: "2025-12-17 12:01:00", once: "hour" }], + ["3-8/2 once * * *", { next: "2025-12-17 12:03:00", once: "hour" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); + it.sequential("每天一次表达式", () => { - expect(nextTime("* * once * *", date)).toEqual(dayjs(date).add(1, "day").format("YYYY-MM-DD 每天运行一次")); + // 假设 2025-12-17 11:47:17.629 已运行了,这一天不再运行 + // 下一次可以执行的时间是 2025-12-18 00:00:00 + [ + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once * *", { next: "2025-12-18", once: "day" }], + ["45 * * once * *", { next: "2025-12-18", once: "day" }], + ["33,44 */7 * once * *", { next: "2025-12-18", once: "day" }], + ["* * once * 3,6", { next: "2025-12-20", once: "day" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); + it.sequential("每月一次表达式", () => { - expect(nextTime("* * * once *", date)).toEqual(dayjs(date).add(1, "month").format("YYYY-MM 每月运行一次")); + // 假设 2025-12-17 11:47:17.629 已运行了,这个月份不再运行 + // 下一次可以执行的时间是 2026-01-01 00:00:00 + [ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ["33,44 */7 * * once *", { next: "2026-01", once: "month" }], + ["* * * once 3,6", { next: "2026-01", once: "month" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + + it.sequential("每星期一次表达式", () => { + // 假设 2025-12-17 11:47:17.629 已运行了,这个星期不再运行 + // 下一次可以执行的时间是 2025-12-22 00:00:00 + [ + ["* * * * once", { next: "2025-12-22", once: "week" }], + ["* * * * * once", { next: "2025-12-22", once: "week" }], + ["45 * * * * once", { next: "2025-12-22", once: "week" }], + ["33,44 */7 * * * once", { next: "2025-12-22", once: "week" }], + ["* * 5 * once", { next: "2026-01-05", once: "week" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); +}); + +describe.concurrent("nextTimeInfo2", () => { + const date = new Date("2025-12-31T23:59:59.999"); // 2025-12-31 23:59:59.999(本地时区) + + // 让程序先执行一下,避免超时问题 + beforeAll(() => { + nextTimeDisplay("* * * * *"); }); + + it.sequential("标准 Cron 表达式", () => { + // 2025-12-31 23:59:59.999 下一秒执行点是 2026-01-01 00:00:00 + // 下一分钟执行点也是 2026-01-01 00:00:00 + [ + ["* * * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ["* * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + + it.sequential("每分钟一次表达式", () => { + // 假设 2025-12-31 23:59:59.999 这一分钟已运行 + // 下一次可执行时间是 2026-01-01 00:00:00 + [ + ["once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["* once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["45 once * * * *", { next: "2026-01-01 00:00:45", once: "minute" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + + it.sequential("每小时一次表达式", () => { + // 假设当前小时已运行 + // 下一次可执行时间是 2026-01-01 00:00:00 + [ + ["* once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["* * once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2026-01-01 00:10:45", once: "hour" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + + it.sequential("每天一次表达式", () => { + // 假设 2025-12-31 这一天已运行 + // 下一次可执行时间是 2026-01-01 00:00:00 + [ + ["* * once * *", { next: "2026-01-01", once: "day" }], + ["* * * once * *", { next: "2026-01-01", once: "day" }], + ["45 * * once * *", { next: "2026-01-01", once: "day" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + + it.sequential("每月一次表达式", () => { + // 假设 2025-12 月已运行 + // 下一次可执行时间是 2026-01-01 00:00:00 + [ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + it.sequential("每星期一次表达式", () => { - expect(nextTime("* * * * once", date)).toEqual(dayjs(date).add(1, "week").format("YYYY-MM-DD 每星期运行一次")); + // 2025-12-31 是星期三 + // 假设本周已运行,下一周开始是 2026-01-05(周一) + [ + ["* * * * once", { next: "2026-01-05", once: "week" }], + ["* * * * * once", { next: "2026-01-05", once: "week" }], + ["45 * * * * once", { next: "2026-01-05", once: "week" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); }); diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 1980ba0f0..8e72be47d 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -517,3 +517,24 @@ export const normalizeResponseHeaders = (headersString: string) => { }); return out.substring(0, out.length - 2); // 去掉最后的 \r\n }; + +// 获取本周是第几周 +// 遵循 ISO 8601, 一月四日为Week 1,星期一为新一周 +// 能应对每年开始和结束(不会因为踏入新一年而重新计算) +// 见 https://wikipedia.org/wiki/ISO_week_date +// 中文說明 https://juejin.cn/post/6921245139855736846 +export const getISOWeek = (date: Date): number => { + // 使用传入日期的年月日创建 UTC 日期对象,忽略本地时间部分,避免时区影响 + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + + // 将日期调整到本周的星期四(ISO 8601 规定:周数以星期四所在周为准) + // 计算方式:当前日期 + 4 − 当前星期几(星期一 = 1,星期日 = 7) + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + + // 获取该星期四所在年份的第一天(UTC) + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + + // 计算从年初到该星期四的天数差 + // 再换算为周数,并向上取整,得到 ISO 周数 + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +}; From 8203c2177d7beb02a4696e3ec874e3dd52a64ceb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:26:22 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20once=20=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/sandbox/runtime.ts | 20 +++----------- src/pkg/utils/cron.ts | 44 ++++++++++++++++-------------- src/pkg/utils/utils.test.ts | 24 ++++++++++++++++ 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 679024d57..d1755c305 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -19,6 +19,7 @@ import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; import { parseUserConfig } from "@App/pkg/utils/yaml"; import { decodeRValue } from "@App/pkg/utils/message_value"; +import { extraCronExpr } from "@App/pkg/utils/cron"; export class Runtime { cronJob: Map> = new Map(); @@ -189,22 +190,9 @@ export class Runtime { let flag = false; const cronJobList: Array = []; script.metadata.crontab.forEach((val) => { - let oncePos = 0; - let crontab = val; - if (crontab.includes("once")) { - const vals = crontab.split(" "); - vals.forEach((item, index) => { - if (item === "once") { - oncePos = index; - } - }); - if (vals.length === 5) { - oncePos += 1; - } - crontab = crontab.replace(/once/g, "*"); - } + const { cronExpr, oncePos } = extraCronExpr(val); try { - const cron = new CronJob(crontab, this.crontabExec(script, oncePos)); + const cron = new CronJob(cronExpr, this.crontabExec(script, oncePos)); cron.start(); cronJobList.push(cron); } catch (e) { @@ -231,7 +219,7 @@ export class Runtime { } crontabExec(script: ScriptLoadInfo, oncePos: number) { - if (oncePos) { + if (oncePos >= 1) { return () => { // 没有最后一次执行时间表示之前都没执行过,直接执行 if (!script.lastruntime) { diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index 9ceec85de..d9339c971 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -64,13 +64,13 @@ export const nextTimeDisplay = (crontab: string, date = new Date()): string => { } }; -/** - * 解析 cron 表达式,计算下一次执行时间 - * 支持自定义 once 关键字(表示“在某个周期内只执行一次”) - */ -export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult => { +export const extraCronExpr = ( + crontab: string +): { + oncePos: number; + cronExpr: string; +} => { const parts = crontab.trim().split(" "); - /** * 兼容 5 位 / 6 位 cron: * - 5 位:分 时 日 月 周 @@ -83,25 +83,29 @@ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult throw new Error(t("cron_invalid_expr")); } - /** - * once 必须是一个完整字段 - * 防止类似 "* once123 * * *" 这种误写 - */ - const onceIndex = parts.indexOf("once"); - if (onceIndex === -1 && crontab.includes("once")) { - throw new Error(t("cron_invalid_expr")); + let oncePos = -1; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.startsWith("once")) { + oncePos = i + lenOffset; // once 在 6 位 cron 中的实际位置 (5 位 cron 需要整体向后偏移一位) + parts[i] = part.slice(5, -1) || "*"; + break; + } } + return { cronExpr: parts.join(" "), oncePos }; +}; - /** - * once 在 6 位 cron 中的实际位置 - * (5 位 cron 需要整体向后偏移一位) - */ - const oncePos = onceIndex !== -1 ? onceIndex + lenOffset : -1; +/** + * 解析 cron 表达式,计算下一次执行时间 + * 支持自定义 once 关键字(表示“在某个周期内只执行一次”) + */ +export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult => { + const { cronExpr, oncePos } = extraCronExpr(crontab); let cron: CronTime; try { - // 将 once 替换为 *,用于标准 cron 解析 - cron = new CronTime(crontab.replace("once", "*")); + // 将 once 替换,用于标准 cron 解析 + cron = new CronTime(cronExpr); } catch { /** * 不支持多个 once diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 4b207f502..7baec685f 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -53,6 +53,30 @@ describe.concurrent("nextTimeInfo1", () => { ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); }); + it.sequential("once表达式", () => { + // 2025-12-17 11:47:17.629 下一个秒执行点是 2025-12-17 11:48:18 + // 2025-12-17 11:47:17.629 下一个分钟执行点是 2025-12-17 11:48:00 + [ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once", { next: "2025-12-22", once: "week" }], + + ["once(*) * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once(*) * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once(*) * *", { next: "2025-12-18", once: "day" }], + ["* * * once(*) *", { next: "2026-01", once: "month" }], + ["* * * * once(*)", { next: "2025-12-22", once: "week" }], + + ["once(5-7) * * * *", { next: "2025-12-17 12:05:00", once: "minute" }], + ["* once(5-7) * * *", { next: "2025-12-18 05:00:00", once: "hour" }], + ["* * once(5-7) * *", { next: "2026-01-05", once: "day" }], + ["* * * once(5-7) *", { next: "2026-05", once: "month" }], + ["* * * * once(5-7)", { next: "2025-12-26", once: "week" }], + ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + }); + it.sequential("每分钟一次表达式", () => { // 假设 2025-12-17 11:47:17.629 已运行了,这分钟不再运行 // 下一次可以执行的时间是 2025-12-17 11:48:00 From 73d03d332455ef3571f0d2c0264d6a2239c21a49 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:52:33 +0900 Subject: [PATCH 04/11] =?UTF-8?q?crontabExec=20=E5=BC=95=E5=85=A5=20timeDi?= =?UTF-8?q?ff=20=E4=BF=AE=E6=AD=A3=E6=89=A7=E8=A1=8C=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/sandbox/runtime.ts | 56 +++++++++++++++--------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index d1755c305..ddb4c7223 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -21,6 +21,10 @@ import { parseUserConfig } from "@App/pkg/utils/yaml"; import { decodeRValue } from "@App/pkg/utils/message_value"; import { extraCronExpr } from "@App/pkg/utils/cron"; +const utime_1min = 60 * 1000; +const utime_1hr = 60 * 60 * 1000; +const utime_1day = 24 * 60 * 60 * 1000; + export class Runtime { cronJob: Map> = new Map(); @@ -222,35 +226,31 @@ export class Runtime { if (oncePos >= 1) { return () => { // 没有最后一次执行时间表示之前都没执行过,直接执行 - if (!script.lastruntime) { - this.execScript(script); - return; - } - const now = new Date(); - const last = new Date(script.lastruntime); - let flag = false; - // 根据once所在的位置去判断执行 - switch (oncePos) { - case 1: // 每分钟 - flag = last.getMinutes() !== now.getMinutes(); - break; - case 2: // 每小时 - flag = last.getHours() !== now.getHours(); - break; - case 3: // 每天 - flag = last.getDay() !== now.getDay(); - break; - case 4: // 每月 - flag = last.getMonth() !== now.getMonth(); - break; - case 5: // 每周 - flag = getISOWeek(last) !== getISOWeek(now); - break; - default: - } - if (flag) { - this.execScript(script); + if (script.lastruntime) { + const now = new Date(); + const last = new Date(script.lastruntime); + // 根据once所在的位置去判断执行 + const timeDiff = now.getTime() - last.getTime(); + switch (oncePos) { + case 1: // 每分钟 + if (timeDiff < 2 * utime_1min && last.getMinutes() === now.getMinutes()) return; + break; + case 2: // 每小时 + if (timeDiff < 2 * utime_1hr && last.getHours() === now.getHours()) return; + break; + case 3: // 每天 + if (timeDiff < 2 * utime_1day && last.getDay() === now.getDay()) return; + break; + case 4: // 每月 + if (timeDiff < 62 * utime_1day && last.getMonth() === now.getMonth()) return; + break; + case 5: // 每周 + if (timeDiff < 14 * utime_1day && getISOWeek(last) === getISOWeek(now)) return; + break; + default: + } } + this.execScript(script); }; } return () => { From b1189fbe8c849439a93b2dfc15532183a491709e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 02:00:23 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=B2=99=E7=9B=92=20i1?= =?UTF-8?q?8n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/offscreen/script.ts | 5 ++++- src/app/service/sandbox/client.ts | 4 ++++ src/app/service/sandbox/runtime.ts | 9 ++++++++- src/app/service/service_worker/runtime.ts | 6 ++++++ src/locales/locales.ts | 15 ++++++++++----- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/app/service/offscreen/script.ts b/src/app/service/offscreen/script.ts index 2d94aaaaa..04c52fd1e 100644 --- a/src/app/service/offscreen/script.ts +++ b/src/app/service/offscreen/script.ts @@ -10,7 +10,7 @@ import { SCRIPT_TYPE_CRONTAB, SCRIPT_TYPE_NORMAL, } from "@App/app/repo/scripts"; -import { disableScript, enableScript, runScript, stopScript } from "../sandbox/client"; +import { disableScript, enableScript, runScript, setSandboxLanguage, stopScript } from "../sandbox/client"; import { type Group } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; import type { TDeleteScript, TInstallScript, TEnableScript } from "../queue"; @@ -40,6 +40,9 @@ export class ScriptService { } async init() { + this.messageQueue.subscribe("setSandboxLanguage", async (lang) => { + setSandboxLanguage(this.windowMessage, lang); + }); this.messageQueue.subscribe("enableScripts", async (data) => { for (const { uuid, enable } of data) { const script = await this.scriptClient.info(uuid); diff --git a/src/app/service/sandbox/client.ts b/src/app/service/sandbox/client.ts index e11d3686a..bd5ea13b6 100644 --- a/src/app/service/sandbox/client.ts +++ b/src/app/service/sandbox/client.ts @@ -2,6 +2,10 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import { sendMessage } from "@Packages/message/client"; import { type WindowMessage } from "@Packages/message/window_message"; +export function setSandboxLanguage(msg: WindowMessage, lang: string) { + return sendMessage(msg, "sandbox/setSandboxLanguage", lang); +} + export function enableScript(msg: WindowMessage, data: ScriptRunResource) { return sendMessage(msg, "sandbox/enableScript", data); } diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index ddb4c7223..010c690e7 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -20,6 +20,7 @@ import { CATRetryError } from "../content/exec_warp"; import { parseUserConfig } from "@App/pkg/utils/yaml"; import { decodeRValue } from "@App/pkg/utils/message_value"; import { extraCronExpr } from "@App/pkg/utils/cron"; +import { changeLanguage, initLanguage, t } from "@App/locales/locales"; const utime_1min = 60 * 1000; const utime_1hr = 60 * 60 * 1000; @@ -186,7 +187,7 @@ export class Runtime { crontabScript(script: ScriptLoadInfo) { // 执行定时脚本 运行表达式 if (!script.metadata.crontab) { - throw new Error(script.name + " - 错误的crontab表达式"); + throw new Error(script.name + " - " + t("cron_invalid_expr")); } // 如果有nextruntime,则加入重试队列 this.joinRetryList(script); @@ -327,6 +328,10 @@ export class Runtime { } } + setSandboxLanguage(lang: string) { + changeLanguage(lang); + } + init() { this.api.on("enableScript", this.enableScript.bind(this)); this.api.on("disableScript", this.disableScript.bind(this)); @@ -335,5 +340,7 @@ export class Runtime { this.api.on("runtime/valueUpdate", this.valueUpdate.bind(this)); this.api.on("runtime/emitEvent", this.emitEvent.bind(this)); + this.api.on("setSandboxLanguage", this.setSandboxLanguage.bind(this)); + initLanguage(); } } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index a918ff726..ec4e41d23 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -455,6 +455,12 @@ export class RuntimeService { this.mq.publish("enableScripts", res); } }); + this.systemConfig.getLanguage().then((lng: string) => { + this.mq.publish("setSandboxLanguage", lng); + }); + this.systemConfig.addListener("language", (lng) => { + this.mq.publish("setSandboxLanguage", lng); + }); }); // 监听脚本值变更 diff --git a/src/locales/locales.ts b/src/locales/locales.ts index 49c62da46..10eeb2fe6 100644 --- a/src/locales/locales.ts +++ b/src/locales/locales.ts @@ -37,12 +37,10 @@ export const initLocalesPromise = new Promise((resolve) => { initLocalesResolve = resolve; }); -export function initLocales(systemConfig: SystemConfig) { - const uiLanguage = chrome.i18n.getUILanguage(); - const defaultLanguage = globalThis.localStorage ? localStorage["language"] || uiLanguage : uiLanguage; +export function initLanguage(lng: string = "en-US"): void { i18n.use(initReactI18next).init({ fallbackLng: "en-US", - lng: defaultLanguage, // 优先使用localStorage中的语言设置 + lng: lng, // 优先使用localStorage中的语言设置 interpolation: { escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape }, @@ -59,9 +57,16 @@ export function initLocales(systemConfig: SystemConfig) { }); // 先根据默认语言设置路径 - if (!defaultLanguage.startsWith("zh-")) { + if (!lng.startsWith("zh-")) { localePath = "/en"; } +} + +export function initLocales(systemConfig: SystemConfig) { + const uiLanguage = chrome.i18n.getUILanguage(); + const defaultLanguage = globalThis.localStorage ? localStorage["language"] || uiLanguage : uiLanguage; + + initLanguage(defaultLanguage); const changeLanguageCallback = (lng: string) => { if (!lng.startsWith("zh-")) { From 3311f3c2d589c8c9cad5330cab95f3d0a81529df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 02:19:09 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/cron.ts | 147 ++++++++++++++++++++++++++---------------- 1 file changed, 92 insertions(+), 55 deletions(-) diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index d9339c971..2e4609dad 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -1,39 +1,60 @@ import { CronTime } from "cron"; import { t } from "@App/locales/locales"; -// 计算下次执行时间,支持 once 关键字表示每分钟/每小时/每天/每月/每星期执行一次 -// https://github.com/kelektiv/node-cron - -// ### 支持以下两个表达式 -// minute hour dayOfMonth month dayOfWeek -// second minute hour dayOfMonth month dayOfWeek -// ### 支持以下数值 -// `*` Asterisks: Any value -// `1-3,5` Ranges: Ranges and individual values -// `*/2` Steps: Every two units -// `once` 任何时刻的单次执行 - -/* ### 数值范围 - field allowed values - ----- -------------- - second 0-59 - minute 0-59 - hour 0-23 - day of month 1-31 - month 1-12 (or names, see below) - day of week 0-7 (0 or 7 is Sunday, or use names) -*/ - -// 使用 cron 内部的 DateTime consturctor +// ===================================== Cron 工具库说明 ===================================== +// +// 本模块用于解析 cron 表达式并计算下一次执行时间, +// 在标准 cron 语法基础上扩展支持 `once` 关键字。 +// +// 参考文档: +// https://github.com/kelektiv/node-cron +// https://docs.scriptcat.org/docs/dev/background/#%E5%AE%9A%E6%97%B6%E8%84%9A%E6%9C%AC +// +// 在线工具测试 cron 表达式: +// https://crontab.guru/ +// +// ─────────────────────────── Cron 表达式格式 ─────────────────────────── +// +// 支持以下两种 cron 表达式: +// - 5 位格式:分 时 日 月 周 +// - 6 位格式:秒 分 时 日 月 周 +// +// ─────────────────────────── 字段取值规则 ─────────────────────────── +// +// 支持以下取值写法: +// - `*` :任意值 +// - `1-3,5` :范围或离散值 +// - `*/2` :步长(每隔 N 个单位) +// - `once` +// - `once(*)` +// - `once(...)`: +// 表示在某个周期内仅执行一次(ScriptCat 扩展语法) +// +// ─────────────────────────── 字段取值范围 ─────────────────────────── +// +// 字段 | 允许值 +// ------ | ------------------------------------------------ +// 秒 | 0 - 59 +// 分 | 0 - 59 +// 时 | 0 - 23 +// 日 | 1 - 31 +// 月 | 1 - 12(或英文月份名,详见 cron 文档) +// 周 | 0 - 7(0 或 7 表示星期日,也可使用英文名称) +// +// ============================================================================================ + +// 使用 cron 内部的 DateTime 构造函数 +// 等价于:import { DateTime } from "luxon" const DateTime = new CronTime("* * * * *").sendAt().constructor; /** - * once 在不同 cron 位置上的含义映射 - * key 为 once 所在的 cron 位(1 ~ 5,不含秒) + * once 在不同 cron 位置上的语义映射表。 * - * 例: - * - "* once * * * *" → 每小时执行一次 - * - "* * once * * *" → 每天执行一次 + * key 表示 once 所在的 cron 位(1 ~ 5,不包含秒位)。 + * + * 示例: + * - "* once * * * *" → 每小时执行一次 + * - "* * once * * *" → 每天执行一次 */ const ONCE_MAP = { 1: { unit: "minute", format: "yyyy-MM-dd HH:mm:ss", label: "minute" }, @@ -46,24 +67,28 @@ const ONCE_MAP = { type NextTimeResult = { /** 下一次触发时间(已格式化) */ next: string; - /** once 类型,用于国际化展示 */ + /** once 类型标识,用于国际化展示 */ once: string; }; /** - * 对外展示用: - * - 如果是 once cron,返回类似“下次在 xx 执行一次” - * - 否则直接返回下一次执行时间 + * 对外展示用方法。 + * + * - 若为 once cron,返回「下次在 xx 执行一次」的国际化文案 + * - 否则直接返回下一次执行时间字符串 */ export const nextTimeDisplay = (crontab: string, date = new Date()): string => { const res = nextTimeInfo(crontab, date); - if (res.once) { - return t(`cron_oncetype.${res.once}`, { next: res.next }); - } else { - return res.next; - } + return res.once ? t(`cron_oncetype.${res.once}`, { next: res.next }) : res.next; }; +/** + * 解析 cron 表达式,提取 once 信息并转换为标准 cron 表达式。 + * + * @returns + * - oncePos :once 在 6 位 cron 表达式中的实际位置(不存在则为 -1) + * - cronExpr:用于标准 cron 解析的表达式 + */ export const extraCronExpr = ( crontab: string ): { @@ -71,45 +96,56 @@ export const extraCronExpr = ( cronExpr: string; } => { const parts = crontab.trim().split(" "); + /** - * 兼容 5 位 / 6 位 cron: + * 兼容 5 位 / 6 位 cron 表达式: * - 5 位:分 时 日 月 周 * - 6 位:秒 分 时 日 月 周 */ const lenOffset = parts.length === 5 ? 1 : 0; - // 非法长度直接判错 + // 长度不合法,直接判定为非法表达式 if (parts.length + lenOffset !== 6) { throw new Error(t("cron_invalid_expr")); } let oncePos = -1; + for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part.startsWith("once")) { - oncePos = i + lenOffset; // once 在 6 位 cron 中的实际位置 (5 位 cron 需要整体向后偏移一位) + // once 在 6 位 cron 中的真实位置 + // 5 位 cron 需要整体向后偏移一位 + oncePos = i + lenOffset; parts[i] = part.slice(5, -1) || "*"; break; } } + return { cronExpr: parts.join(" "), oncePos }; }; /** - * 解析 cron 表达式,计算下一次执行时间 - * 支持自定义 once 关键字(表示“在某个周期内只执行一次”) + * 解析 cron 表达式并计算下一次执行时间。 + * + * 支持自定义 once 关键字,用于表示在对应周期内仅执行一次: + * - minute:每分钟一次 + * - hour :每小时一次 + * - day :每天一次 + * - month :每月一次 + * - week :每周一次 */ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult => { const { cronExpr, oncePos } = extraCronExpr(crontab); let cron: CronTime; try { - // 将 once 替换,用于标准 cron 解析 + // 使用标准 cron 表达式进行解析 cron = new CronTime(cronExpr); } catch { /** * 不支持多个 once - * 例如:"* once once * *" + * 示例:"* once once * *" */ throw new Error(t("cron_invalid_expr")); } @@ -119,10 +155,11 @@ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult let onceLabel = ""; /** - * 如果存在 once: - * 核心思路: - * 👉 直接跳到「下一个周期的起始时间」 - * 👉 再从该时间点开始计算 cron 的下一次命中 + * 若存在 once: + * + * 处理思路: + * 1. 先跳转到下一个周期的起始时间 + * 2. 再从该时间点开始计算 cron 的下一次命中 */ if (oncePos >= 1 && oncePos <= 5) { const cfg = ONCE_MAP[oncePos as keyof typeof ONCE_MAP]; @@ -130,18 +167,18 @@ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult format = cfg.format; /** - * 例如: + * 示例: * 当前时间:2026-01-02 10:23 - * once 在 hour 位 + * once 位于 hour * - * → 先跳到 11:00:00 + * → 跳转到 11:00:00 */ luxonDate = luxonDate.plus({ [cfg.unit]: 1 }).startOf(cfg.unit as any); /** - * 再减去 1ms: - * 这样 getNextDateFrom 才能 - * 命中「正好等于周期起点」的 cron + * 再回退 1 毫秒, + * 以确保 getNextDateFrom 能命中 + * 「等于周期起点」的 cron 时间 */ luxonDate = luxonDate.minus({ milliseconds: 1 }); } From dc07958927fe7e5b75a5f22510295d6eae6b5b7f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 02:34:58 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/cron.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index 2e4609dad..ea565fd68 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -11,29 +11,32 @@ import { t } from "@App/locales/locales"; // https://docs.scriptcat.org/docs/dev/background/#%E5%AE%9A%E6%97%B6%E8%84%9A%E6%9C%AC // // 在线工具测试 cron 表达式: -// https://crontab.guru/ +// https://crontab.guru/ (英文,标准5位格式) +// https://tool.lu/crontab/ (中文,标准5位及扩展6位格式) // -// ─────────────────────────── Cron 表达式格式 ─────────────────────────── +// ────────────────────────────────── Cron 表达式格式 ────────────────────────────────── // // 支持以下两种 cron 表达式: -// - 5 位格式:分 时 日 月 周 -// - 6 位格式:秒 分 时 日 月 周 +// - 标准 5 位格式:分 时 日 月 周 +// - 扩展 6 位格式:秒 分 时 日 月 周 // -// ─────────────────────────── 字段取值规则 ─────────────────────────── +// 注:6位扩展格式会使脚本每秒执行,浏览器JavaScript环境无法精准每秒执行,而且对CPU负担大,并不推荐 +// +// ────────────────────────────────── 字段取值规则 ────────────────────────────────── // // 支持以下取值写法: // - `*` :任意值 -// - `1-3,5` :范围或离散值 -// - `*/2` :步长(每隔 N 个单位) +// - `1-3,5` :范围或离散值 +// - `*/2` :步长(每隔 N 个单位) // - `once` // - `once(*)` // - `once(...)`: // 表示在某个周期内仅执行一次(ScriptCat 扩展语法) // -// ─────────────────────────── 字段取值范围 ─────────────────────────── +// ────────────────────────────────── 字段取值范围 ────────────────────────────────── // // 字段 | 允许值 -// ------ | ------------------------------------------------ +// ------ | ------------------------------------------ // 秒 | 0 - 59 // 分 | 0 - 59 // 时 | 0 - 23 @@ -50,11 +53,11 @@ const DateTime = new CronTime("* * * * *").sendAt().constructor; /** * once 在不同 cron 位置上的语义映射表。 * - * key 表示 once 所在的 cron 位(1 ~ 5,不包含秒位)。 + * key 表示 once 所在的 cron 位(1 ~ 5,忽略秒位)。 * * 示例: - * - "* once * * * *" → 每小时执行一次 - * - "* * once * * *" → 每天执行一次 + * - "* once * * *" → 每小时执行一次 + * - "* * once * *" → 每天执行一次 */ const ONCE_MAP = { 1: { unit: "minute", format: "yyyy-MM-dd HH:mm:ss", label: "minute" }, From f9ba9009ae95bfab3ac375e4eddec1f1e79eebd4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 03:06:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/utils.test.ts | 305 ++++++++++++++++++------------------ 1 file changed, 151 insertions(+), 154 deletions(-) diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 7baec685f..b1c5763e5 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -10,6 +10,7 @@ import { } from "./utils"; import { ltever, versionCompare } from "@App/pkg/utils/semver"; import { nextTimeDisplay, nextTimeInfo } from "./cron"; + describe.concurrent("aNow", () => { // aNow >= Date.now(); it.sequential("aNow is greater than or equal to Date.now()", () => { @@ -34,6 +35,28 @@ describe.concurrent("aNow", () => { }); }); +const assertNextTimeInfo = (expr: string, date: Date, expected: any) => { + const actual = nextTimeInfo(expr, date); + + // 1) 失败时讯息包含 expr / expected / actual + // 2) 用 soft,方便一次看到多笔失败(可选) + expect + .soft( + actual, + [ + "", + "", + `expr: ${expr}`, + `date: ${date.toISOString()}`, + `expected: ${JSON.stringify(expected)}`, + `actual: ${JSON.stringify(actual)}`, + "", + "", + ].join("\n") + ) + .toEqual(expected); +}; + describe.concurrent("nextTimeInfo1", () => { const date = new Date("2025-12-17T11:47:17.629"); // 2025-12-17 11:47:17.629 (本地时区) @@ -42,101 +65,87 @@ describe.concurrent("nextTimeInfo1", () => { nextTimeDisplay("* * * * *"); }); - it.sequential("标准Cron表达式", () => { - // 2025-12-17 11:47:17.629 下一个秒执行点是 2025-12-17 11:48:18 - // 2025-12-17 11:47:17.629 下一个分钟执行点是 2025-12-17 11:48:00 - [ - ["* * * * * *", { next: "2025-12-17 11:47:18", once: "" }], - ["* * * * *", { next: "2025-12-17 11:48:00", once: "" }], - ["* 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "" }], - ["* 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("once表达式", () => { - // 2025-12-17 11:47:17.629 下一个秒执行点是 2025-12-17 11:48:18 - // 2025-12-17 11:47:17.629 下一个分钟执行点是 2025-12-17 11:48:00 - [ - ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], - ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], - ["* * once * *", { next: "2025-12-18", once: "day" }], - ["* * * once *", { next: "2026-01", once: "month" }], - ["* * * * once", { next: "2025-12-22", once: "week" }], - - ["once(*) * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], - ["* once(*) * * *", { next: "2025-12-17 12:00:00", once: "hour" }], - ["* * once(*) * *", { next: "2025-12-18", once: "day" }], - ["* * * once(*) *", { next: "2026-01", once: "month" }], - ["* * * * once(*)", { next: "2025-12-22", once: "week" }], - - ["once(5-7) * * * *", { next: "2025-12-17 12:05:00", once: "minute" }], - ["* once(5-7) * * *", { next: "2025-12-18 05:00:00", once: "hour" }], - ["* * once(5-7) * *", { next: "2026-01-05", once: "day" }], - ["* * * once(5-7) *", { next: "2026-05", once: "month" }], - ["* * * * once(5-7)", { next: "2025-12-26", once: "week" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每分钟一次表达式", () => { - // 假设 2025-12-17 11:47:17.629 已运行了,这分钟不再运行 - // 下一次可以执行的时间是 2025-12-17 11:48:00 - [ - ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], - ["* once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], - ["45 once * * * *", { next: "2025-12-17 11:48:45", once: "minute" }], - ["once 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "minute" }], - ["once 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "minute" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每小时一次表达式", () => { - // 假设 2025-12-17 11:47:17.629 已运行了,这小时不再运行 - // 下一次可以执行的时间是 2025-12-17 12:00:00 - [ - ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], - ["* * once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], - ["10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], - ["* 10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], - ["45 10 once * * *", { next: "2025-12-17 12:10:45", once: "hour" }], - ["1-3,5 once * * *", { next: "2025-12-17 12:01:00", once: "hour" }], - ["3-8/2 once * * *", { next: "2025-12-17 12:03:00", once: "hour" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每天一次表达式", () => { - // 假设 2025-12-17 11:47:17.629 已运行了,这一天不再运行 - // 下一次可以执行的时间是 2025-12-18 00:00:00 - [ - ["* * once * *", { next: "2025-12-18", once: "day" }], - ["* * * once * *", { next: "2025-12-18", once: "day" }], - ["45 * * once * *", { next: "2025-12-18", once: "day" }], - ["33,44 */7 * once * *", { next: "2025-12-18", once: "day" }], - ["* * once * 3,6", { next: "2025-12-20", once: "day" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每月一次表达式", () => { - // 假设 2025-12-17 11:47:17.629 已运行了,这个月份不再运行 - // 下一次可以执行的时间是 2026-01-01 00:00:00 - [ - ["* * * once *", { next: "2026-01", once: "month" }], - ["* * * * once *", { next: "2026-01", once: "month" }], - ["45 * * * once *", { next: "2026-01", once: "month" }], - ["33,44 */7 * * once *", { next: "2026-01", once: "month" }], - ["* * * once 3,6", { next: "2026-01", once: "month" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每星期一次表达式", () => { - // 假设 2025-12-17 11:47:17.629 已运行了,这个星期不再运行 - // 下一次可以执行的时间是 2025-12-22 00:00:00 - [ - ["* * * * once", { next: "2025-12-22", once: "week" }], - ["* * * * * once", { next: "2025-12-22", once: "week" }], - ["45 * * * * once", { next: "2025-12-22", once: "week" }], - ["33,44 */7 * * * once", { next: "2025-12-22", once: "week" }], - ["* * 5 * once", { next: "2026-01-05", once: "week" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + it.concurrent.each([ + ["* * * * * *", { next: "2025-12-17 11:47:18", once: "" }], + ["* * * * *", { next: "2025-12-17 11:48:00", once: "" }], + ["* 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "" }], + ["* 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "" }], + ])("标准Cron表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once", { next: "2025-12-22", once: "week" }], + + ["once(*) * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once(*) * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once(*) * *", { next: "2025-12-18", once: "day" }], + ["* * * once(*) *", { next: "2026-01", once: "month" }], + ["* * * * once(*)", { next: "2025-12-22", once: "week" }], + + ["once(5-7) * * * *", { next: "2025-12-17 12:05:00", once: "minute" }], + ["* once(5-7) * * *", { next: "2025-12-18 05:00:00", once: "hour" }], + ["* * once(5-7) * *", { next: "2026-01-05", once: "day" }], + ["* * * once(5-7) *", { next: "2026-05", once: "month" }], + ["* * * * once(5-7)", { next: "2025-12-26", once: "week" }], + ])("once表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["45 once * * * *", { next: "2025-12-17 11:48:45", once: "minute" }], + ["once 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "minute" }], + ["once 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "minute" }], + ])("每分钟一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2025-12-17 12:10:45", once: "hour" }], + ["1-3,5 once * * *", { next: "2025-12-17 12:01:00", once: "hour" }], + ["3-8/2 once * * *", { next: "2025-12-17 12:03:00", once: "hour" }], + ])("每小时一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once * *", { next: "2025-12-18", once: "day" }], + ["45 * * once * *", { next: "2025-12-18", once: "day" }], + ["33,44 */7 * once * *", { next: "2025-12-18", once: "day" }], + ["* * once * 3,6", { next: "2025-12-20", once: "day" }], + ])("每天一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ["33,44 */7 * * once *", { next: "2026-01", once: "month" }], + ["* * * once 3,6", { next: "2026-01", once: "month" }], + ])("每月一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * * once", { next: "2025-12-22", once: "week" }], + ["* * * * * once", { next: "2025-12-22", once: "week" }], + ["45 * * * * once", { next: "2025-12-22", once: "week" }], + ["33,44 */7 * * * once", { next: "2025-12-22", once: "week" }], + ["* * 5 * once", { next: "2026-01-05", once: "week" }], + ])("每星期一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); }); }); @@ -148,65 +157,53 @@ describe.concurrent("nextTimeInfo2", () => { nextTimeDisplay("* * * * *"); }); - it.sequential("标准 Cron 表达式", () => { - // 2025-12-31 23:59:59.999 下一秒执行点是 2026-01-01 00:00:00 - // 下一分钟执行点也是 2026-01-01 00:00:00 - [ - ["* * * * * *", { next: "2026-01-01 00:00:00", once: "" }], - ["* * * * *", { next: "2026-01-01 00:00:00", once: "" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每分钟一次表达式", () => { - // 假设 2025-12-31 23:59:59.999 这一分钟已运行 - // 下一次可执行时间是 2026-01-01 00:00:00 - [ - ["once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], - ["* once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], - ["45 once * * * *", { next: "2026-01-01 00:00:45", once: "minute" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每小时一次表达式", () => { - // 假设当前小时已运行 - // 下一次可执行时间是 2026-01-01 00:00:00 - [ - ["* once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], - ["* * once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], - ["10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], - ["* 10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], - ["45 10 once * * *", { next: "2026-01-01 00:10:45", once: "hour" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每天一次表达式", () => { - // 假设 2025-12-31 这一天已运行 - // 下一次可执行时间是 2026-01-01 00:00:00 - [ - ["* * once * *", { next: "2026-01-01", once: "day" }], - ["* * * once * *", { next: "2026-01-01", once: "day" }], - ["45 * * once * *", { next: "2026-01-01", once: "day" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每月一次表达式", () => { - // 假设 2025-12 月已运行 - // 下一次可执行时间是 2026-01-01 00:00:00 - [ - ["* * * once *", { next: "2026-01", once: "month" }], - ["* * * * once *", { next: "2026-01", once: "month" }], - ["45 * * * once *", { next: "2026-01", once: "month" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); - }); - - it.sequential("每星期一次表达式", () => { - // 2025-12-31 是星期三 - // 假设本周已运行,下一周开始是 2026-01-05(周一) - [ - ["* * * * once", { next: "2026-01-05", once: "week" }], - ["* * * * * once", { next: "2026-01-05", once: "week" }], - ["45 * * * * once", { next: "2026-01-05", once: "week" }], - ].forEach(([expr, expected]) => expect(nextTimeInfo(expr as string, date)).toEqual(expected)); + it.concurrent.each([ + ["* * * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ["* * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ])("标准 Cron 表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["* once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["45 once * * * *", { next: "2026-01-01 00:00:45", once: "minute" }], + ])("每分钟一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["* * once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2026-01-01 00:10:45", once: "hour" }], + ])("每小时一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * once * *", { next: "2026-01-01", once: "day" }], + ["* * * once * *", { next: "2026-01-01", once: "day" }], + ["45 * * once * *", { next: "2026-01-01", once: "day" }], + ])("每天一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ])("每月一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * * once", { next: "2026-01-05", once: "week" }], + ["* * * * * once", { next: "2026-01-05", once: "week" }], + ["45 * * * * once", { next: "2026-01-05", once: "week" }], + ])("每星期一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); }); }); From 77f05ee22092b47e22bbf9c3176dcc46df7be23a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 03:32:38 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E4=BF=AE=E8=A8=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/cron.ts | 15 ++++++++++----- src/pkg/utils/utils.test.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index ea565fd68..41d8aac68 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -49,6 +49,7 @@ import { t } from "@App/locales/locales"; // 使用 cron 内部的 DateTime 构造函数 // 等价于:import { DateTime } from "luxon" const DateTime = new CronTime("* * * * *").sendAt().constructor; +type LuxonDate = ReturnType[0]; /** * once 在不同 cron 位置上的语义映射表。 @@ -68,8 +69,10 @@ const ONCE_MAP = { } as const; type NextTimeResult = { - /** 下一次触发时间(已格式化) */ - next: string; + /** 下一次触发时间 */ + next: LuxonDate; + /** 时间格式 */ + format: string; /** once 类型标识,用于国际化展示 */ once: string; }; @@ -82,7 +85,8 @@ type NextTimeResult = { */ export const nextTimeDisplay = (crontab: string, date = new Date()): string => { const res = nextTimeInfo(crontab, date); - return res.once ? t(`cron_oncetype.${res.once}`, { next: res.next }) : res.next; + const nextTimeFormatted = res.next.toFormat(res.format); + return res.once ? t(`cron_oncetype.${res.once}`, { next: nextTimeFormatted }) : nextTimeFormatted; }; /** @@ -153,7 +157,7 @@ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult throw new Error(t("cron_invalid_expr")); } - let luxonDate = (DateTime as any).fromJSDate(date); + let luxonDate = (DateTime as any).fromJSDate(date) as LuxonDate; let format = "yyyy-MM-dd HH:mm:ss"; let onceLabel = ""; @@ -189,7 +193,8 @@ export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult const next = cron.getNextDateFrom(luxonDate); return { - next: next.toFormat(format), + next: next, + format: format, once: onceLabel, }; }; diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index b1c5763e5..0fb5d0cf7 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -37,19 +37,23 @@ describe.concurrent("aNow", () => { const assertNextTimeInfo = (expr: string, date: Date, expected: any) => { const actual = nextTimeInfo(expr, date); + const result = { + next: actual.next.toFormat(actual.format), + once: actual.once, + }; // 1) 失败时讯息包含 expr / expected / actual // 2) 用 soft,方便一次看到多笔失败(可选) expect .soft( - actual, + result, [ "", "", `expr: ${expr}`, `date: ${date.toISOString()}`, `expected: ${JSON.stringify(expected)}`, - `actual: ${JSON.stringify(actual)}`, + `actual: ${JSON.stringify(result)}`, "", "", ].join("\n") From 27acf740e2f70bebf91ad8e0a07268b1aedbb7d6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 03:35:57 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/cron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index 41d8aac68..4cf8cc5c3 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -93,7 +93,7 @@ export const nextTimeDisplay = (crontab: string, date = new Date()): string => { * 解析 cron 表达式,提取 once 信息并转换为标准 cron 表达式。 * * @returns - * - oncePos :once 在 6 位 cron 表达式中的实际位置(不存在则为 -1) + * - oncePos :once 在 6 位 cron 表达式中的实际位置(不存在则为 -1) * - cronExpr:用于标准 cron 解析的表达式 */ export const extraCronExpr = ( From fc37aa6c4384f6971fa3db9533c688b2884fec47 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 3 Jan 2026 03:56:43 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=E5=8D=87=E7=BA=A7=20cron=20=E8=87=B3=204?= =?UTF-8?q?.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- pnpm-lock.yaml | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index e7f801d19..3794b9b44 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "cron": "^3.2.1", + "cron": "^4.4.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "dexie": "^4.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 356299cba..fd9b867f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@18.3.1) cron: - specifier: ^3.2.1 - version: 3.2.1 + specifier: ^4.4.0 + version: 4.4.0 crypto-js: specifier: ^4.2.0 version: 4.2.0 @@ -1146,8 +1146,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/luxon@3.4.2': - resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1839,8 +1839,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cron@3.2.1: - resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} @@ -2894,8 +2895,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - luxon@3.5.0: - resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} lz-string@1.5.0: @@ -5174,7 +5175,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/luxon@3.4.2': {} + '@types/luxon@3.7.1': {} '@types/mime@1.3.5': {} @@ -6078,10 +6079,10 @@ snapshots: create-require@1.1.1: {} - cron@3.2.1: + cron@4.4.0: dependencies: - '@types/luxon': 3.4.2 - luxon: 3.5.0 + '@types/luxon': 3.7.1 + luxon: 3.7.2 cross-env@10.1.0: dependencies: @@ -7282,7 +7283,7 @@ snapshots: lru-cache@10.4.3: {} - luxon@3.5.0: {} + luxon@3.7.2: {} lz-string@1.5.0: {}