@mzzsfy/dsh-cron-board 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  - **环境变量**:集中管理,值打码展示,多值(同名展开笛卡尔积,组合数超出 20 截断并告警),dotenv 文本导入(预览/追加/覆盖)与导出
11
11
  - **会话任务**:投递即终态(crontab 语义,执行状态归会话本身);fresh 每次新建会话;pinned 固定会话(忙时跳过,会话被删除或归档自动重建并回写,首跑自动绑定);按 workdir 自动挂载宿主分组,界面分组内可见;执行预设可选(Agent Preset,缺省跟随宿主默认,dsh-im 同构)
12
12
  - **调度**:croner 解析,停机期间错过不补跑(misfire 即跳过),孤儿运行恢复,全局与任务级并发闸门;shell 任务超时收尾(会话任务无超时语义);单次运行开关(runOnce,任意一次真实运行入队后自动停用任务)
13
+ - **时区**:cron 表达式支持内联时区后缀 `T±N`(整数小时,如 `0 9 * * *T+8` 表示东八区 9 点,范围 -12 至 +14,`T+0` 即 UTC);构建器提供时区行写入后缀,新建任务默认固化设备时区偏移;无后缀表达式按宿主进程系统时区调度(crontab 惯例),摘要与运行记录按任务所用时区展示;固定偏移不随夏令时变化,半时区设备的设备偏移就近取整
13
14
  - **日志**:每任务每运行独立日志文件,面板查看与清空,按任务保留条数裁剪(按文件修改时间)
14
15
 
15
16
  ## 路由
@@ -51,3 +52,7 @@ node --test "test/*.test.mjs" # 测试
51
52
  ```
52
53
 
53
54
  注意:插件新增进入 profile bundles(`dsh plugin add`)后需 dsh 重启一次完成首装载;此后工作副本改动经 dev-link HMR 热重载。
55
+
56
+ ## dsh 版本兼容
57
+
58
+ 三版本全部通过:cron 看板渲染与启停、与侧边栏联动(侧栏标签页形态)、激活 live。0.1.6 新侧栏标签页系统下联动开关双态均验证通过。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
3
  "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.4.1",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/api.mjs CHANGED
@@ -281,6 +281,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
281
281
  session: sessionState
282
282
  ? { ready: Boolean(sessionState.ready), disabled: Boolean(sessionState.disabled) }
283
283
  : { ready: false, disabled: false },
284
+ // 宿主进程时区偏移小时数:无后缀任务的展示时区(调度与展示同源,与浏览器无关)
285
+ serverTzOffset: -new Date().getTimezoneOffset() / 60,
284
286
  ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false },
285
287
  })
286
288
  },
package/src/client.js CHANGED
@@ -34,10 +34,18 @@ function relativeTime(timestamp, now) {
34
34
  return Math.round(abs / DAY_MS) + ' 天' + suffix
35
35
  }
36
36
 
37
- function formatDateTime(timestamp) {
37
+ function formatDateTime(timestamp, timezoneOffset) {
38
38
  if (typeof timestamp !== 'number' || !(timestamp > 0)) return '-'
39
- const date = new Date(timestamp)
40
- return date.getFullYear() + '-' + PAD2(date.getMonth() + 1) + '-' + PAD2(date.getDate()) + ' ' + PAD2(date.getHours()) + ':' + PAD2(date.getMinutes())
39
+ // 按调度所用时区(任务显式后缀或宿主默认)平移后取 UTC 分量,与浏览器时区无关;
40
+ // 偏移缺失(状态未加载)瞬态回退浏览器时区
41
+ const offset = typeof timezoneOffset === 'number' ? timezoneOffset : -new Date().getTimezoneOffset() / 60
42
+ const shifted = new Date(timestamp + offset * HOUR_MS)
43
+ return shifted.getUTCFullYear() + '-' + PAD2(shifted.getUTCMonth() + 1) + '-' + PAD2(shifted.getUTCDate()) + ' ' + PAD2(shifted.getUTCHours()) + ':' + PAD2(shifted.getUTCMinutes())
44
+ }
45
+
46
+ // 浏览器所在设备的时区偏移(整小时就近取整):仅用于新建任务时固化初始时区后缀
47
+ function deviceTzOffset() {
48
+ return Math.round(-new Date().getTimezoneOffset() / 60)
41
49
  }
42
50
 
43
51
  function formatDuration(ms) {
@@ -61,6 +69,9 @@ const SB_WEEKDAY_LABELS = { '0': '日', '1': '一', '2': '二', '3': '三', '4':
61
69
  const SB_MINUTE_MAX = 59
62
70
  const SB_HOUR_MAX = 23
63
71
  const SB_INTERVAL_MIN_MINUTES = 1
72
+ const SB_TZ_SUFFIX_MIN = -12
73
+ const SB_TZ_SUFFIX_MAX = 14
74
+ const SB_TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
64
75
 
65
76
  function createScheduleState(expression) {
66
77
  return {
@@ -70,6 +81,7 @@ function createScheduleState(expression) {
70
81
  weekdays: ['1'],
71
82
  intervalMinutes: 30,
72
83
  expression,
84
+ timezone: null,
73
85
  }
74
86
  }
75
87
 
@@ -79,11 +91,21 @@ const sbClampInt = (raw, min, max, fallback) => {
79
91
  return value
80
92
  }
81
93
 
82
- // cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜
94
+ // cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
95
+ // 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
83
96
  function parseSchedule(expression) {
84
97
  const raw = String(expression).trim()
85
- const parts = raw.split(/\s+/)
86
- const state = createScheduleState(raw)
98
+ let split
99
+ try {
100
+ split = sbSplitScheduleTz(raw)
101
+ } catch {
102
+ const state = createScheduleState(raw)
103
+ state.freq = 'custom'
104
+ return state
105
+ }
106
+ const state = createScheduleState(split.base)
107
+ state.timezone = split.timezoneOffset
108
+ const parts = split.base.split(/\s+/)
87
109
  if (parts.length !== 5) { state.freq = 'custom'; return state }
88
110
  const [minute, hour, day, month, weekday] = parts
89
111
  if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
@@ -111,8 +133,19 @@ function parseSchedule(expression) {
111
133
  return state
112
134
  }
113
135
 
114
- // 构建器状态 → cron;custom 直接返回手工表达式
136
+ // 偏移小时数 → 后缀符号值:+8 / -5 / +0
137
+ function sbFormatTzOffset(offset) {
138
+ return (offset >= 0 ? '+' : '') + offset
139
+ }
140
+
141
+ // 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
115
142
  function buildSchedule(state) {
143
+ const base = sbBuildBase(state)
144
+ if (state.timezone == null) return base
145
+ return base + 'T' + sbFormatTzOffset(state.timezone)
146
+ }
147
+
148
+ function sbBuildBase(state) {
116
149
  if (state.freq === 'custom') return String(state.expression).trim()
117
150
  const minute = String(state.minute)
118
151
  const hour = String(state.hour)
@@ -122,8 +155,30 @@ function buildSchedule(state) {
122
155
  if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
123
156
  return String(state.expression).trim()
124
157
  }
158
+
159
+ // 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
160
+ // 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
161
+ function sbSplitScheduleTz(schedule) {
162
+ const text = String(schedule).trim()
163
+ const match = text.match(SB_TZ_SUFFIX_PATTERN)
164
+ if (!match) return { base: text, timezoneOffset: null }
165
+ const offset = Number(match[1] + match[2])
166
+ if (offset < SB_TZ_SUFFIX_MIN || offset > SB_TZ_SUFFIX_MAX) {
167
+ throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + SB_TZ_SUFFIX_MIN + ' 至 ' + SB_TZ_SUFFIX_MAX + ')')
168
+ }
169
+ return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
170
+ }
125
171
  /* SBUILD-END */
126
172
 
173
+ // 任务表达式的时区偏移(小时);无后缀返回 null;脏表达式(后缀非法)兜底 null 不炸渲染
174
+ function scheduleTzOffset(schedule) {
175
+ try {
176
+ return sbSplitScheduleTz(schedule).timezoneOffset
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+
127
182
  const API_PREFIX = '/api/cron-board/'
128
183
  const REFRESH_INTERVAL_MS = 30 * SECOND_MS
129
184
  const TABS = [
@@ -355,6 +410,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
355
410
  if (existing) return
356
411
  const style = document.createElement('style')
357
412
  style.id = 'dsh-cron-board-style'
413
+ // 官方契约:插件自注样式必须自带 data-plugin,否则任意后续插件材质化时
414
+ // claimStyles 会把它归属给该插件,其 HMR 重建即整批误删本插件样式
415
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-cron-board')
358
416
  style.textContent = STYLE
359
417
  document.head.appendChild(style)
360
418
  }
@@ -432,8 +490,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
432
490
  { value: 'custom', label: '自定义' },
433
491
  ]
434
492
 
435
- // 调度构建器面板:频率分段 + 上下文选择器 + 表达式芯片 + 即将执行 chips
436
- function ScheduleBuilder({ scheduleState, onStateChange, preview }) {
493
+ // 调度构建器面板:频率分段 + 上下文选择器 + 时区行 + 表达式芯片 + 即将执行 chips
494
+ function ScheduleBuilder({ scheduleState, onStateChange, preview, previewError, serverTzOffset }) {
437
495
  const state = scheduleState
438
496
  const setFreq = (freq) => onStateChange({ ...state, freq })
439
497
  const patchClock = (field, raw) => {
@@ -447,6 +505,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
447
505
  if (days.length === 0) return
448
506
  onStateChange({ ...state, weekdays: days })
449
507
  }
508
+ const setTz = (raw) => onStateChange({ ...state, timezone: raw === 'host' ? null : Number(raw) })
509
+ const displayTzOffset = state.timezone == null ? serverTzOffset : state.timezone
450
510
  const clockRow = (h('div', { className: 'cb-bline' },
451
511
  h('span', { className: 'cb-blabel' }, '执行时间'),
452
512
  h('input', { type: 'number', className: 'cb-time', min: 0, max: SB_HOUR_MAX, value: state.hour, onChange: (e) => patchClock('hour', e.target.value) }),
@@ -483,14 +543,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
483
543
  ? null
484
544
  : h('button', { type: 'button', className: 'cb-linkbtn', onClick: () => setFreq('custom') }, '自定义'),
485
545
  state.freq === 'custom' ? null : h('span', { className: 'cb-hint' }, '由上方选择自动生成')),
546
+ h('div', { className: 'cb-bline' },
547
+ h('span', { className: 'cb-blabel' }, '时区'),
548
+ h('select', { className: 'cb-select', value: state.timezone == null ? 'host' : String(state.timezone), onChange: (e) => setTz(e.target.value) },
549
+ h('option', { value: 'host' }, '宿主默认' + (typeof serverTzOffset === 'number' ? '(UTC' + sbFormatTzOffset(serverTzOffset) + ')' : '')),
550
+ Array.from({ length: SB_TZ_SUFFIX_MAX - SB_TZ_SUFFIX_MIN + 1 }, (_, i) => SB_TZ_SUFFIX_MIN + i).map((offset) => h('option', { key: offset, value: String(offset) }, 'UTC' + sbFormatTzOffset(offset)))),
551
+ h('span', { className: 'cb-hint' }, '固定偏移不随夏令时变化;写入表达式后缀 T±N')),
486
552
  h('div', { className: 'cb-bline' },
487
553
  h('span', { className: 'cb-blabel' }, '即将执行'),
488
554
  preview
489
- ? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at))))
490
- : h('span', { className: 'cb-hint' }, '表达式非法或计算中')))
555
+ ? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at, displayTzOffset))))
556
+ : h('span', { className: 'cb-hint' }, previewError || '表达式非法或计算中')))
491
557
  }
492
558
 
493
- function JobForm({ job, onDone, wsModel }) {
559
+ function JobForm({ job, onDone, wsModel, serverTzOffset }) {
494
560
  // 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
495
561
  const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
496
562
  useEffect(() => {
@@ -500,7 +566,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
500
566
  return wsModel.subscribe(update)
501
567
  }, [wsModel])
502
568
  const editing = Boolean(job && job.id)
503
- const initialSchedule = job ? job.schedule : '0 9 * * *'
569
+ // 构建器初始状态:新建任务把设备时区偏移固化为显式后缀,
570
+ // 宿主环境时区异常(如 UTC)时任务仍按设备预期时刻触发;编辑存量保持原表达式语义
571
+ const [scheduleState, setScheduleState] = useState(() => {
572
+ const parsed = parseSchedule(job ? job.schedule : '0 9 * * *')
573
+ if (!editing) parsed.timezone = deviceTzOffset()
574
+ return parsed
575
+ })
576
+ const initialSchedule = editing ? job.schedule : buildSchedule(scheduleState)
504
577
  const [form, setForm] = useState(() => ({
505
578
  name: job ? job.name : '',
506
579
  kind: job ? job.kind : 'shell',
@@ -518,15 +591,24 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
518
591
  agentPreset: job && job.session ? job.session.agentPreset : '',
519
592
  },
520
593
  }))
521
- // 构建器状态:由既有表达式反解初始化;结构化变更写回 form.schedule
522
- const [scheduleState, setScheduleState] = useState(() => parseSchedule(initialSchedule))
523
594
  const [preview, setPreview] = useState(null)
595
+ const [previewError, setPreviewError] = useState(null)
524
596
  const [error, setError] = useState(null)
525
597
  const [saving, setSaving] = useState(false)
526
598
  const [presetCatalog, setPresetCatalog] = useState({ defaultId: '', items: [] })
527
599
  const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
528
600
  const setSession = (patch) => setForm((prev) => ({ ...prev, session: { ...prev.session, ...patch } }))
529
601
  const applyScheduleState = (next) => {
602
+ // custom 频率下表达式携带合法后缀时以表达式为准(剥离归一并回填时区);
603
+ // 无后缀保留调用方传入的时区(select 与频率切换不丢配置);非法后缀原文保留且时区置空
604
+ if (next.freq === 'custom') {
605
+ try {
606
+ const split = sbSplitScheduleTz(next.expression)
607
+ if (split.timezoneOffset != null) next = { ...next, expression: split.base, timezone: split.timezoneOffset }
608
+ } catch {
609
+ next = { ...next, timezone: null }
610
+ }
611
+ }
530
612
  setScheduleState(next)
531
613
  set({ schedule: buildSchedule(next) })
532
614
  }
@@ -542,9 +624,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
542
624
 
543
625
  useEffect(() => {
544
626
  let alive = true
627
+ // 表达式变更即清空旧错误:防抖窗口内不残留上一次失败文案
628
+ setPreviewError(null)
545
629
  const timer = setTimeout(async () => {
546
630
  const outcome = await request('POST', 'cron/preview', { schedule: form.schedule })
547
- if (alive) setPreview(outcome.ok ? outcome.data : null)
631
+ if (!alive) return
632
+ setPreview(outcome.ok ? outcome.data : null)
633
+ setPreviewError(outcome.ok ? null : outcome.error)
548
634
  }, 300)
549
635
  return () => { alive = false; clearTimeout(timer) }
550
636
  }, [form.schedule])
@@ -572,7 +658,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
572
658
  ariaLabel: '类型',
573
659
  }))),
574
660
  h(Section, { title: '调度计划' },
575
- h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview })),
661
+ h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview, previewError, serverTzOffset })),
576
662
  form.kind === 'shell'
577
663
  ? h(Section, { title: '运行配置(shell)' },
578
664
  h(Field, { label: '命令', hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
@@ -736,7 +822,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
736
822
  onConfirm: () => deleteJob(pendingDelete),
737
823
  onCancel: () => setPendingDelete(null),
738
824
  }) : null,
739
- formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, onDone: () => { setFormJob(null); reload() } }) : null)
825
+ formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, serverTzOffset: status ? status.serverTzOffset : undefined, onDone: () => { setFormJob(null); reload() } }) : null)
740
826
  }
741
827
 
742
828
  // —— 环境变量 Tab ——
@@ -871,11 +957,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
871
957
  }
872
958
 
873
959
  // —— 日志 Tab ——
874
- function LogsTab({ jobs, reload, reloadFlag }) {
960
+ function LogsTab({ jobs, reload, reloadFlag, serverTzOffset }) {
875
961
  const [jobId, setJobId] = useState(jobs.length > 0 ? jobs[0].id : null)
876
962
  const [runs, setRuns] = useState([])
877
963
  const [logText, setLogText] = useState(null)
878
964
  const [logRunId, setLogRunId] = useState(null)
965
+ // 展示时区随所选任务:显式后缀用其偏移,无后缀用宿主默认(调度与展示同源)
966
+ const selectedJob = jobs.find((job) => job.id === jobId)
967
+ const displayTzOffset = scheduleTzOffset(selectedJob ? selectedJob.schedule : null) ?? (typeof serverTzOffset === 'number' ? serverTzOffset : undefined)
879
968
 
880
969
  useEffect(() => {
881
970
  let alive = true
@@ -909,7 +998,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
909
998
  h('span', { className: 'cb-dot', style: { '--cb-status': STATUS_TONE_COLOR[meta.tone] } }),
910
999
  h('span', { className: 'cb-meta' }, meta.label),
911
1000
  h('span', { className: 'cb-badge' }, TRIGGER_META[run.trigger] ? TRIGGER_META[run.trigger].label : run.trigger),
912
- h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt)),
1001
+ h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt, displayTzOffset)),
913
1002
  typeof run.durationMs === 'number' ? h('span', { className: 'cb-meta' }, formatDuration(run.durationMs)) : null,
914
1003
  run.message ? h('span', { className: 'cb-meta' }, run.message) : null,
915
1004
  h('div', { className: 'cb-actions' },
@@ -969,7 +1058,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
969
1058
  (status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
970
1059
  tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, applyJobPatch, wsModel }) : null,
971
1060
  tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload, applyEnvPatch }) : null,
972
- tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
1061
+ tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag, serverTzOffset: status ? status.serverTzOffset : undefined }) : null)
973
1062
  }
974
1063
 
975
1064
  // —— 主页面挂载:宿主官方全局面板契约(main keyed 插槽 + sidebar.panellist 侧栏入口)——
package/src/cron.mjs CHANGED
@@ -1,25 +1,42 @@
1
1
  // cron 解析薄封装:croner 承担表达式解析与触发点计算(5/6 段通吃、时区、DST)。
2
- // 非法表达式以业务错误抛出,文案中文原样透传给路由层。
2
+ // 表达式支持内联时区后缀 T±N(语法归 schedule-builder):显式后缀按固定偏移,
3
+ // 无后缀按宿主系统时区(crontab 惯例,与 croner 原生行为一致)。
3
4
 
4
5
  import { Cron } from 'croner'
5
6
 
7
+ import { splitScheduleTz, formatTzOffset } from './schedule-builder.mjs'
8
+
9
+ // 固定偏移 → croner 时区名(Etc/GMT 为 POSIX 反转:UTC+8 即 Etc/GMT-8)
10
+ function etcGmtName(timezoneOffset) {
11
+ if (timezoneOffset === 0) return 'UTC'
12
+ return timezoneOffset > 0 ? 'Etc/GMT-' + timezoneOffset : 'Etc/GMT+' + (-timezoneOffset)
13
+ }
14
+
15
+ // croner 构造选项:无后缀不指定时区(宿主系统时区)
16
+ function cronOptions(timezoneOffset) {
17
+ return timezoneOffset === null ? {} : { timezone: etcGmtName(timezoneOffset) }
18
+ }
19
+
6
20
  // 求自 from 起下一次触发点(ms epoch);表达式无未来触发点时返回 null
7
21
  export function nextRunAtOf(schedule, from = new Date()) {
8
- const job = new Cron(schedule)
22
+ const { base, timezoneOffset } = splitScheduleTz(schedule)
23
+ const job = new Cron(base, cronOptions(timezoneOffset))
9
24
  const next = job.nextRun(from)
10
25
  return next ? next.getTime() : null
11
26
  }
12
27
 
13
28
  // 求自 from 起下 N 次触发点(编辑表单实时预览用)
14
29
  export function nextRunsOf(schedule, count, from = new Date()) {
15
- const job = new Cron(schedule)
30
+ const { base, timezoneOffset } = splitScheduleTz(schedule)
31
+ const job = new Cron(base, cronOptions(timezoneOffset))
16
32
  return job.nextRuns(count, from).map((date) => date.getTime())
17
33
  }
18
34
 
19
- // 表达式合法性校验:合法返回 null,非法抛业务错误
35
+ // 表达式合法性校验:合法返回 null,非法抛业务错误(后缀与表达式分别报因)
20
36
  export function assertValidSchedule(schedule) {
37
+ const { base } = splitScheduleTz(schedule)
21
38
  try {
22
- new Cron(schedule)
39
+ new Cron(base)
23
40
  } catch (error) {
24
41
  throw new Error('cron 表达式不合法: ' + schedule)
25
42
  }
@@ -28,23 +45,32 @@ export function assertValidSchedule(schedule) {
28
45
  const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
29
46
  const PAD2 = (value) => String(value).padStart(2, '0')
30
47
 
31
- // 人话摘要(轻量规则覆盖常用形态,兜底回原表达式)
32
- export function summarizeCron(schedule) {
33
- const parts = String(schedule).trim().split(/\s+/)
34
- if (parts.length !== 5) return schedule
48
+ // 时区尾注:显式后缀标注自身,无后缀标注宿主默认(hostOffset 可显式传入以便测试)
49
+ function tzNote(timezoneOffset, hostOffset) {
50
+ return timezoneOffset === null
51
+ ? '(宿主 UTC' + formatTzOffset(hostOffset) + ')'
52
+ : '(UTC' + formatTzOffset(timezoneOffset) + ')'
53
+ }
54
+
55
+ // 人话摘要(轻量规则覆盖常用形态,兜底回原表达式),一律携带时区尾注
56
+ export function summarizeCron(schedule, hostOffset = -new Date().getTimezoneOffset() / 60) {
57
+ const { base, timezoneOffset } = splitScheduleTz(schedule)
58
+ const note = tzNote(timezoneOffset, hostOffset)
59
+ const parts = base.trim().split(/\s+/)
60
+ if (parts.length !== 5) return base + note
35
61
  const [minute, hour, day, month, weekday] = parts
36
62
  const clock = (h, m) => PAD2(h) + ':' + PAD2(m)
37
63
  const dayFree = day === '*' && month === '*' && weekday === '*'
38
- if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时'
39
- if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)'
40
- if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟'
41
- if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return schedule
64
+ if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时' + note
65
+ if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)' + note
66
+ if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟' + note
67
+ if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return base + note
42
68
  if (month !== '*' || day !== '*') {
43
- if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute)
44
- return schedule
69
+ if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute) + note
70
+ return base + note
45
71
  }
46
- if (weekday === '1-5') return '工作日 ' + clock(hour, minute)
47
- if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute)
48
- if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute)
49
- return schedule
72
+ if (weekday === '1-5') return '工作日 ' + clock(hour, minute) + note
73
+ if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute) + note
74
+ if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute) + note
75
+ return base + note
50
76
  }
@@ -10,7 +10,8 @@ export const MINUTE_MAX = 59
10
10
  export const HOUR_MAX = 23
11
11
  export const INTERVAL_MIN_MINUTES = 1
12
12
 
13
- // 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置)
13
+ // 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置);
14
+ // timezone 为内联时区后缀的偏移小时数,null 表示无后缀(按宿主系统时区)
14
15
  export function createScheduleState(expression) {
15
16
  return {
16
17
  freq: 'daily',
@@ -19,6 +20,7 @@ export function createScheduleState(expression) {
19
20
  weekdays: ['1'],
20
21
  intervalMinutes: 30,
21
22
  expression,
23
+ timezone: null,
22
24
  }
23
25
  }
24
26
 
@@ -28,11 +30,21 @@ const clampInt = (raw, min, max, fallback) => {
28
30
  return value
29
31
  }
30
32
 
31
- // cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜
33
+ // cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
34
+ // 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
32
35
  export function parseSchedule(expression) {
33
36
  const raw = String(expression).trim()
34
- const parts = raw.split(/\s+/)
35
- const state = createScheduleState(raw)
37
+ let split
38
+ try {
39
+ split = splitScheduleTz(raw)
40
+ } catch {
41
+ const state = createScheduleState(raw)
42
+ state.freq = 'custom'
43
+ return state
44
+ }
45
+ const state = createScheduleState(split.base)
46
+ state.timezone = split.timezoneOffset
47
+ const parts = split.base.split(/\s+/)
36
48
  if (parts.length !== 5) { state.freq = 'custom'; return state }
37
49
  const [minute, hour, day, month, weekday] = parts
38
50
  if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
@@ -60,8 +72,19 @@ export function parseSchedule(expression) {
60
72
  return state
61
73
  }
62
74
 
63
- // 构建器状态 → cron;custom 直接返回手工表达式
75
+ // 偏移小时数 → 后缀符号值:+8 / -5 / +0
76
+ export function formatTzOffset(offset) {
77
+ return (offset >= 0 ? '+' : '') + offset
78
+ }
79
+
80
+ // 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
64
81
  export function buildSchedule(state) {
82
+ const base = buildBase(state)
83
+ if (state.timezone == null) return base
84
+ return base + 'T' + formatTzOffset(state.timezone)
85
+ }
86
+
87
+ function buildBase(state) {
65
88
  if (state.freq === 'custom') return String(state.expression).trim()
66
89
  const minute = String(state.minute)
67
90
  const hour = String(state.hour)
@@ -71,3 +94,23 @@ export function buildSchedule(state) {
71
94
  if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
72
95
  return String(state.expression).trim()
73
96
  }
97
+
98
+ // —— 内联时区后缀:表达式形态的一部分,构建器读写,cron.mjs 桥接 croner 时复用 ——
99
+
100
+ export const TZ_SUFFIX_MIN = -12
101
+ export const TZ_SUFFIX_MAX = 14
102
+ // T±N 整数小时,允许与表达式空格分隔,大小写均可
103
+ const TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
104
+
105
+ // 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
106
+ // 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
107
+ export function splitScheduleTz(schedule) {
108
+ const text = String(schedule).trim()
109
+ const match = text.match(TZ_SUFFIX_PATTERN)
110
+ if (!match) return { base: text, timezoneOffset: null }
111
+ const offset = Number(match[1] + match[2])
112
+ if (offset < TZ_SUFFIX_MIN || offset > TZ_SUFFIX_MAX) {
113
+ throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + TZ_SUFFIX_MIN + ' 至 ' + TZ_SUFFIX_MAX + ')')
114
+ }
115
+ return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
116
+ }
@@ -0,0 +1,25 @@
1
+ // client.js 注册 id 与样式标记守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败;
2
+ // 样式注入缺 data-plugin 会被宿主 claimStyles 误归属给后续材质化插件,其 HMR 重建即整批误删。
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
11
+
12
+ test('client.js 注册 id 为完整包名', () => {
13
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
14
+ const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
15
+ assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
16
+ assert.equal(match[1], name)
17
+ })
18
+
19
+ test('client.js 样式注入自带 data-plugin', () => {
20
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
21
+ const injections = source.split("createElement('style')").length - 1
22
+ assert.ok(injections > 0, 'client.js 无样式注入点')
23
+ const marked = source.split(`setAttribute('data-plugin', '${name}')`).length - 1
24
+ assert.equal(marked, injections, '每个样式注入点都必须自带 data-plugin(值=注册包名),防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
25
+ })
@@ -0,0 +1,98 @@
1
+ // cron 解析封装测试:内联时区后缀 T±N 的触发点计算、校验与人话摘要(BDD feat-timezone S1)。
2
+ // 后缀语义:显式后缀按固定偏移;无后缀按宿主系统时区(与 croner 原生行为一致)。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { Cron } from 'croner'
7
+
8
+ import { nextRunAtOf, nextRunsOf, assertValidSchedule, summarizeCron } from '../src/cron.mjs'
9
+ import { buildSchedule } from '../src/schedule-builder.mjs'
10
+
11
+ // 固定起点:2026-09-17T12:00:00Z(北京 20:00),之后的 9:00 触发点跨日,断言无歧义
12
+ const FROM = new Date('2026-09-17T12:00:00Z')
13
+
14
+ test('cron:后缀 T+8 按东八区计算触发点', () => {
15
+ // Given 表达式 0 9 * * *T+8
16
+ // When 求自固定起点起的下一次触发
17
+ const at = nextRunAtOf('0 9 * * *T+8', FROM)
18
+ // Then 触发点为北京 09-18 09:00,即 01:00Z
19
+ assert.equal(at, Date.UTC(2026, 8, 18, 1, 0, 0))
20
+ })
21
+
22
+ test('cron:后缀 T+0 与 T-5 按固定偏移计算,允许后缀前空格', () => {
23
+ // Then T+0 即 UTC 触发点 09:00Z
24
+ assert.equal(nextRunAtOf('0 9 * * *T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
25
+ // Then 空格分隔等价紧贴
26
+ assert.equal(nextRunAtOf('0 9 * * * T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
27
+ // Then T-5 即 UTC-5 的 9:00,起点(12:00Z)之后的下一次为当日 14:00Z
28
+ assert.equal(nextRunAtOf('0 9 * * *T-5', FROM), Date.UTC(2026, 8, 17, 14, 0, 0))
29
+ })
30
+
31
+ test('cron:后缀 T+8 下三次触发连续推进', () => {
32
+ // Given 显式东八区,取 3 个触发点
33
+ const runs = nextRunsOf('0 9 * * *T+8', 3, FROM)
34
+ // Then 为连续三天北京 09:00(01:00Z)
35
+ assert.deepEqual(runs, [
36
+ Date.UTC(2026, 8, 18, 1, 0, 0),
37
+ Date.UTC(2026, 8, 19, 1, 0, 0),
38
+ Date.UTC(2026, 8, 20, 1, 0, 0),
39
+ ])
40
+ })
41
+
42
+ test('cron:无后缀与 croner 原生系统时区行为一致', () => {
43
+ // Given 无后缀表达式
44
+ // When 求下一次触发
45
+ const at = nextRunAtOf('30 8 * * *', FROM)
46
+ // Then 与 croner 原生(不传 timezone)一致,零回归
47
+ const native = new Cron('30 8 * * *').nextRun(FROM).getTime()
48
+ assert.equal(at, native)
49
+ })
50
+
51
+ test('cron:后缀解析边界——负零、前导零、小写、空格归一', () => {
52
+ // Then 负零等价 +0(UTC)
53
+ assert.equal(nextRunAtOf('0 9 * * *T-0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
54
+ // Then 前导零等价单位数
55
+ assert.equal(nextRunAtOf('0 9 * * *T+08', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
56
+ // Then 小写 t 同样识别
57
+ assert.equal(nextRunAtOf('0 9 * * *t+8', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
58
+ // Then 空格分隔反解后回写归一为紧贴形态
59
+ const state = { freq: 'daily', hour: 9, minute: 0, weekdays: ['1'], intervalMinutes: 30, expression: '0 9 * * *', timezone: 8 }
60
+ assert.equal(buildSchedule(state), '0 9 * * *T+8')
61
+ // Then 负零写侧归一为 +0(不输出 T-0)
62
+ assert.equal(buildSchedule({ ...state, timezone: -0 }), '0 9 * * *T+0')
63
+ })
64
+
65
+ test('cron:nextRunAtOf 对非法后缀直接抛业务错误', () => {
66
+ assert.throws(() => nextRunAtOf('0 9 * * *T+15', FROM), /时区后缀不合法/)
67
+ })
68
+
69
+ test('cron:非法后缀抛业务错误,坏表达式抛表达式错误', () => {
70
+ // Then 后缀超界(-12..14 之外)报后缀不合法
71
+ assert.throws(() => assertValidSchedule('0 9 * * *T+15'), /时区后缀不合法: T\+15/)
72
+ assert.throws(() => assertValidSchedule('0 9 * * *T-13'), /时区后缀不合法/)
73
+ // Then 表达式本身坏报表达式不合法(文案含原文)
74
+ assert.throws(() => assertValidSchedule('not-a-cronT+8'), /cron 表达式不合法: not-a-cronT\+8/)
75
+ // Then 合法表达式不抛
76
+ assert.doesNotThrow(() => assertValidSchedule('0 9 * * *T+8'))
77
+ assert.doesNotThrow(() => assertValidSchedule('0 9 * * *'))
78
+ })
79
+
80
+ test('cron:摘要显式后缀尾注 (UTC+N)', () => {
81
+ assert.equal(summarizeCron('0 9 * * *T+8'), '每天 09:00(UTC+8)')
82
+ assert.equal(summarizeCron('30 8 * * 1-5 T+0'), '工作日 08:30(UTC+0)')
83
+ assert.equal(summarizeCron('0 22 * * 6T-5'), '每周六 22:00(UTC-5)')
84
+ })
85
+
86
+ test('cron:摘要无后缀尾注显示宿主偏移,显式传参可测', () => {
87
+ // Given 宿主偏移显式传 0
88
+ assert.equal(summarizeCron('0 9 * * *', 0), '每天 09:00(宿主 UTC+0)')
89
+ // Given 宿主偏移显式传 5.5(半时区宿主)
90
+ assert.equal(summarizeCron('0 9 * * *', 5.5), '每天 09:00(宿主 UTC+5.5)')
91
+ })
92
+
93
+ test('cron:摘要 custom 与非 5 段兜底同样携带时区尾注', () => {
94
+ // Given 指定日月(非构建器形态)带后缀
95
+ assert.equal(summarizeCron('0 0 1 1 *T+8'), '0 0 1 1 *(UTC+8)')
96
+ // Given 非法表达式(不构成摘要)带宿主偏移传参
97
+ assert.equal(summarizeCron('not-a-cron', 0), 'not-a-cron(宿主 UTC+0)')
98
+ })
@@ -36,14 +36,21 @@ async function makeExecutor(t, { jobsBroken = false, logLines = [] } = {}) {
36
36
 
37
37
  const JOB_BASE = { name: 't', kind: 'shell', command: 'echo hi', schedule: '* * * * *', enabled: true, timeoutMs: 60 * 1000 }
38
38
 
39
- async function dispatchAndSettle(executor, store, jobId) {
40
- const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
41
- // 等执行链收尾:pump 后单元在后台,轮询至终态或超时
42
- for (let i = 0; i < 200; i++) {
43
- const row = store.runs.get(runId)
44
- if (row && row.status !== 'queued' && row.status !== 'running') return runId
39
+ // 轮询至断言目标状态可见:执行链在 runs 终态后仍有任务卡回填/留痕等后台落定动作,单次读取存在竞态窗口
40
+ async function waitFor(predicate, timeoutMs = 2 * 1000) {
41
+ const deadline = Date.now() + timeoutMs
42
+ for (;;) {
43
+ if (predicate()) return
44
+ if (Date.now() > deadline) throw new Error('waitFor 超时')
45
45
  await new Promise((resolve) => setTimeout(resolve, 10))
46
46
  }
47
+ }
48
+
49
+ async function dispatchAndSettle(executor, store, jobId) {
50
+ const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
51
+ // 等执行链完整落定:runs 终态内存生效早于写链持久化,任务卡回填在其后才可见,以回填为收尾信号
52
+ // 信号为任务级(同名任务任一单元回填即满足),非特定 run 级;当前调用点无依赖特定 run 落定的断言
53
+ await waitFor(() => store.jobs.get(jobId)?.lastStatus !== undefined)
47
54
  return runId
48
55
  }
49
56
 
@@ -87,12 +94,8 @@ test('executor:pinned 自愈回写 session.pinnedSessionId 且清除顶层孤立
87
94
  readMaxConcurrent: () => 2,
88
95
  })
89
96
  const [runId] = await executorWithPinned.dispatch({ ...JOB_BASE, kind: 'session', session: { mode: 'pinned', pinnedSessionId: '' }, id: 'j-pin' }, 'manual')
90
- for (let i = 0; i < 200; i++) {
91
- const row = store.runs.get(runId)
92
- if (row && row.status !== 'queued' && row.status !== 'running') break
93
- await new Promise((resolve) => setTimeout(resolve, 10))
94
- }
95
- // Then 绑定写入 session 子对象,顶层无孤立残留
97
+ // Then 绑定写入 session 子对象,顶层无孤立残留;回填晚于 runs 终态,以回填可见为落定信号
98
+ await waitFor(() => store.jobs.get('j-pin')?.session?.pinnedSessionId === 's-new')
96
99
  const job = store.jobs.get('j-pin')
97
100
  assert.equal(job.session.pinnedSessionId, 's-new')
98
101
  assert.equal(job.pinnedSessionId, undefined)
@@ -114,11 +117,8 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
114
117
  })
115
118
  await store.jobs.create({ ...JOB_BASE, id: 'j2' })
116
119
  const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j2' }, 'manual')
117
- for (let i = 0; i < 200; i++) {
118
- const row = store.runs.get(runId)
119
- if (row && row.status === 'fail') break
120
- await new Promise((resolve) => setTimeout(resolve, 10))
121
- }
120
+ // catch 路径先补 runs 终态再回填任务卡,以回填可见为收尾信号
121
+ await waitFor(() => store.jobs.get('j2')?.lastStatus === 'fail')
122
122
  const row = store.runs.get(runId)
123
123
  assert.equal(row.status, 'fail')
124
124
  assert.match(row.message, /会话服务爆炸/)
@@ -129,7 +129,9 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
129
129
  test('executor:运行终态已落库而任务卡回填失败,历史不被覆写', async (t) => {
130
130
  const { store, executor, logLines } = await makeExecutor(t, { jobsBroken: true })
131
131
  await store.jobs.create({ ...JOB_BASE, id: 'j3' })
132
- const runId = await dispatchAndSettle(executor, store, 'j3')
132
+ const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j3' }, 'manual')
133
+ // 任务卡回填被拒必留痕:留痕集合即 catch 路径收尾信号(此场景 lastStatus 永不可见)
134
+ await waitFor(() => logLines.length === 1)
133
135
  // Then 事实成功保留,不被 catch 覆写为 fail;回填缺口留痕可观测
134
136
  assert.equal(store.runs.get(runId).status, 'success')
135
137
  assert.equal(logLines.length, 1)
@@ -143,7 +145,8 @@ test('executor:运行终态补写失败经 logSystem 留痕', async (t) => {
143
145
  const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j4' }, 'manual')
144
146
  // dispatch 完成(记录已落库、runUnit 已入队)后再让 runs 集合损坏:终态写入必失败
145
147
  store.runs.update = async () => { throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失') }
146
- await new Promise((resolve) => setTimeout(resolve, 200))
148
+ // 补写失败必留痕:以留痕可见替代固定等待,消除时序脆弱
149
+ await waitFor(() => logLines.length === 1)
147
150
  // Then 补写失败恰留痕一次,含 runId 与原始错误
148
151
  assert.equal(logLines.length, 1)
149
152
  assert.ok(logLines[0].includes(runId))
@@ -203,7 +203,8 @@ test('index:tick 到期任务被调度执行(端到端装配)', async (t) => {
203
203
  const dir = await mkdtemp(join(tmpdir(), 'cron-board-idx-'))
204
204
  t.after(async () => {
205
205
  delete process.env.DSH_CRON_BOARD_DATA_DIR
206
- await rm(dir, { recursive: true, force: true })
206
+ // 运行终态可见后执行链仍有后台落盘(任务卡回填/日志写),与目录删除竞态,有界重试消解
207
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
207
208
  })
208
209
  process.env.DSH_CRON_BOARD_DATA_DIR = dir
209
210
  const { ctx, routes, intervals } = makeFullCtx()
@@ -465,9 +465,9 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
465
465
  const { api } = await makeApi(t)
466
466
  // When 预览「每天 08:30」
467
467
  const ok = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *' })
468
- // Then 摘要含每天与时刻,三次触发时间戳齐备
468
+ // Then 摘要含每天与时刻并带宿主时区尾注,三次触发时间戳齐备
469
469
  assert.equal(ok.status, 200)
470
- assert.equal(ok.payload.summary, '每天 08:30')
470
+ assert.match(ok.payload.summary, /^每天 08:30\(宿主 UTC[+-]\d/)
471
471
  assert.equal(ok.payload.nextAt.length, 3)
472
472
  assert.ok(ok.payload.nextAt.every((value) => typeof value === 'number' && value > Date.now()))
473
473
  // When 非法表达式
@@ -477,6 +477,33 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
477
477
  assert.match(bad.payload.error, /cron/i)
478
478
  })
479
479
 
480
+ test('jobs 与 preview 路由:时区后缀表达式的调度与校验', async (t) => {
481
+ const { api } = await makeApi(t)
482
+ // Given 带 T+8 后缀的任务
483
+ const created = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz', kind: 'shell', command: 'x', schedule: '0 9 * * *T+8' })
484
+ assert.equal(created.status, 200)
485
+ // Then 默认触发点按东八区 9:00(01:00Z),与宿主自身时区无关
486
+ assert.equal(new Date(created.payload.nextRunAt).toISOString().slice(11, 16), '01:00')
487
+ // When 非法后缀创建任务
488
+ const bad = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz2', kind: 'shell', command: 'x', schedule: '0 9 * * *T+15' })
489
+ // Then 400 且报后缀不合法
490
+ assert.equal(bad.status, 400)
491
+ assert.match(bad.payload.error, /时区后缀不合法/)
492
+ // When 带后缀预览
493
+ const preview = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *T+8' })
494
+ // Then 摘要尾注与触发点均为东八区语义
495
+ assert.equal(preview.status, 200)
496
+ assert.equal(preview.payload.summary, '每天 08:30(UTC+8)')
497
+ assert.equal(new Date(preview.payload.nextAt[0]).toISOString().slice(11, 16), '00:30')
498
+ })
499
+
500
+ test('status 路由:暴露宿主时区偏移 serverTzOffset', async (t) => {
501
+ const { api } = await makeApi(t)
502
+ const res = await call(api, 'GET', '/api/cron-board/status')
503
+ assert.equal(res.status, 200)
504
+ assert.equal(typeof res.payload.serverTzOffset, 'number')
505
+ })
506
+
480
507
  test('runs 路由:无参数查全量,带 jobId 查单任务', async (t) => {
481
508
  // Given 两个任务各有一条运行记录
482
509
  const { store, api } = await makeApi(t)
@@ -102,6 +102,54 @@ test('字段越界数值反解归 custom,不猜修正', () => {
102
102
  assert.equal(parseSchedule('* 9 * * *').freq, 'custom')
103
103
  })
104
104
 
105
+ test('时区后缀:反解与回写 T+N', () => {
106
+ const state = parseSchedule('0 9 * * *T+8')
107
+ assert.equal(state.freq, 'daily')
108
+ assert.equal(state.hour, 9)
109
+ assert.equal(state.timezone, 8)
110
+ assert.equal(buildSchedule(state), '0 9 * * *T+8')
111
+ })
112
+
113
+ test('时区后缀:无后缀反解 timezone 为 null,回写不附加', () => {
114
+ const state = parseSchedule('30 8 * * *')
115
+ assert.equal(state.timezone, null)
116
+ assert.equal(buildSchedule(state), '30 8 * * *')
117
+ })
118
+
119
+ test('时区后缀:interval 与工作日形态同样携带', () => {
120
+ const interval = parseSchedule('*/30 * * * *T+0')
121
+ assert.equal(interval.freq, 'interval')
122
+ assert.equal(interval.timezone, 0)
123
+ assert.equal(buildSchedule(interval), '*/30 * * * *T+0')
124
+ const weekdays = parseSchedule('0 9 * * 1-5T-5')
125
+ assert.equal(weekdays.freq, 'weekdays')
126
+ assert.equal(weekdays.timezone, -5)
127
+ assert.equal(buildSchedule(weekdays), '0 9 * * 1-5T-5')
128
+ })
129
+
130
+ test('时区后缀:非法后缀反解 custom 且原文保留,不抛', () => {
131
+ const state = parseSchedule('0 9 * * *T+15')
132
+ assert.equal(state.freq, 'custom')
133
+ assert.equal(state.expression, '0 9 * * *T+15')
134
+ assert.equal(state.timezone, null)
135
+ })
136
+
137
+ test('时区后缀:custom 合法后缀剥离存储,回写统一附加', () => {
138
+ const state = parseSchedule('0 9 * * * 2026T+8')
139
+ assert.equal(state.freq, 'custom')
140
+ assert.equal(state.expression, '0 9 * * * 2026')
141
+ assert.equal(state.timezone, 8)
142
+ assert.equal(buildSchedule(state), '0 9 * * * 2026T+8')
143
+ })
144
+
145
+ test('时区后缀:createScheduleState 默认无后缀,置空回写不带后缀', () => {
146
+ const state = createScheduleState('')
147
+ assert.equal(state.timezone, null)
148
+ state.freq = 'daily'
149
+ state.hour = 2
150
+ assert.equal(buildSchedule(state), '0 2 * * *')
151
+ })
152
+
105
153
  test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
106
154
  // Given client 半区无模块系统,构建器逻辑必须内联(经典 script)
107
155
  // When 从两侧源码提取函数体文本(SBUILD 标记段 vs schedule-builder.mjs 导出体)
@@ -115,7 +163,7 @@ test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
115
163
  const core = readFileSync(join(pkgRoot, 'src', 'schedule-builder.mjs'), 'utf8')
116
164
  // 逐函数对比:剥离 export 关键词后,核心源码的每个可执行行都应出现在 client 镜像段
117
165
  const coreLines = core.split('\n')
118
- .map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bclampInt\b/g, 'sbClampInt').trim())
166
+ .map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bTZ_SUFFIX_MIN\b/g, 'SB_TZ_SUFFIX_MIN').replace(/\bTZ_SUFFIX_MAX\b/g, 'SB_TZ_SUFFIX_MAX').replace(/\bTZ_SUFFIX_PATTERN\b/g, 'SB_TZ_SUFFIX_PATTERN').replace(/\bsplitScheduleTz\b/g, 'sbSplitScheduleTz').replace(/\bformatTzOffset\b/g, 'sbFormatTzOffset').replace(/\bbuildBase\b/g, 'sbBuildBase').replace(/\bclampInt\b/g, 'sbClampInt').trim())
119
167
  .filter((line) => line !== '' && !line.startsWith('//'))
120
168
  for (const line of coreLines) {
121
169
  assert.ok(block.includes(line), 'client 镜像段缺少核心行: ' + line)