@mzzsfy/dsh-cron-board 0.2.1 → 0.3.1

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/src/client.js CHANGED
@@ -15,7 +15,6 @@ const STATUS_META = {
15
15
  const TRIGGER_META = { cron: { label: '定时' }, manual: { label: '手动' } }
16
16
  const KIND_LABELS = { shell: '脚本', session: '会话' }
17
17
  const MODE_LABELS = { fresh: '每次新建', pinned: '固定会话' }
18
- const ONMISS_LABELS = { skip: '跳过', defer: '顺延' }
19
18
  const STATUS_TONE_COLOR = { ok: '#22a06b', bad: '#e5484d', warn: '#f5a524', mute: '#8b8d98', run: '#3b82f6' }
20
19
  const SECOND_MS = 1000
21
20
  const MINUTE_MS = 60 * SECOND_MS
@@ -53,6 +52,78 @@ function statusMeta(status) {
53
52
  }
54
53
  /* LOGIC-END */
55
54
 
55
+ /* SBUILD-BEGIN */
56
+ // 调度构建器纯函数:与 src/schedule-builder.mjs 逐字镜像(parity 测试锁定),client 无模块系统只能内联。
57
+ // 仅覆盖构建器可表达的形态,反解不出即 custom(表达式原样手输);触发点计算归 croner(经 cron/preview 接口)。
58
+ const SB_FREQS = ['daily', 'weekly', 'weekdays', 'interval', 'custom']
59
+ const SB_WEEKDAY_ORDER = ['1', '2', '3', '4', '5', '6', '0']
60
+ const SB_WEEKDAY_LABELS = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六' }
61
+ const SB_MINUTE_MAX = 59
62
+ const SB_HOUR_MAX = 23
63
+ const SB_INTERVAL_MIN_MINUTES = 1
64
+
65
+ function createScheduleState(expression) {
66
+ return {
67
+ freq: 'daily',
68
+ hour: 9,
69
+ minute: 0,
70
+ weekdays: ['1'],
71
+ intervalMinutes: 30,
72
+ expression,
73
+ }
74
+ }
75
+
76
+ const sbClampInt = (raw, min, max, fallback) => {
77
+ const value = Number(raw)
78
+ if (!Number.isInteger(value) || value < min || value > max) return fallback
79
+ return value
80
+ }
81
+
82
+ // cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜
83
+ function parseSchedule(expression) {
84
+ const raw = String(expression).trim()
85
+ const parts = raw.split(/\s+/)
86
+ const state = createScheduleState(raw)
87
+ if (parts.length !== 5) { state.freq = 'custom'; return state }
88
+ const [minute, hour, day, month, weekday] = parts
89
+ if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
90
+ if (minute === '*' && hour === '*') { state.freq = 'custom'; return state }
91
+ if (/^\*\/\d+$/.test(minute) && hour === '*') {
92
+ state.freq = 'interval'
93
+ state.intervalMinutes = sbClampInt(minute.slice(2), SB_INTERVAL_MIN_MINUTES, SB_MINUTE_MAX, 0)
94
+ if (state.intervalMinutes === 0) state.freq = 'custom'
95
+ return state
96
+ }
97
+ const hourNum = sbClampInt(hour, 0, SB_HOUR_MAX, NaN)
98
+ const minuteNum = sbClampInt(minute, 0, SB_MINUTE_MAX, NaN)
99
+ if (!Number.isInteger(hourNum) || !Number.isInteger(minuteNum)) { state.freq = 'custom'; return state }
100
+ state.hour = hourNum
101
+ state.minute = minuteNum
102
+ if (weekday === '*') { state.freq = 'daily'; return state }
103
+ if (weekday === '1-5') { state.freq = 'weekdays'; return state }
104
+ const days = weekday.split(',')
105
+ if (days.every((d) => /^\d$/.test(d) && d in SB_WEEKDAY_LABELS)) {
106
+ state.freq = 'weekly'
107
+ state.weekdays = SB_WEEKDAY_ORDER.filter((d) => days.includes(d))
108
+ return state
109
+ }
110
+ state.freq = 'custom'
111
+ return state
112
+ }
113
+
114
+ // 构建器状态 → cron;custom 直接返回手工表达式
115
+ function buildSchedule(state) {
116
+ if (state.freq === 'custom') return String(state.expression).trim()
117
+ const minute = String(state.minute)
118
+ const hour = String(state.hour)
119
+ if (state.freq === 'daily') return minute + ' ' + hour + ' * * *'
120
+ if (state.freq === 'weekdays') return minute + ' ' + hour + ' * * 1-5'
121
+ if (state.freq === 'weekly') return minute + ' ' + hour + ' * * ' + [...state.weekdays].sort((a, b) => Number(a) - Number(b)).join(',')
122
+ if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
123
+ return String(state.expression).trim()
124
+ }
125
+ /* SBUILD-END */
126
+
56
127
  const API_PREFIX = '/api/cron-board/'
57
128
  const REFRESH_INTERVAL_MS = 30 * SECOND_MS
58
129
  const TABS = [
@@ -92,9 +163,9 @@ body{
92
163
  --cb-mask:var(--dsw-alias-bg-mask-1,rgba(31,35,41,.5));
93
164
  --cb-shadow:0 1px 2px rgb(31 35 41 / 4%);
94
165
  --cb-radius-sm:6px;--cb-radius-md:8px;--cb-radius-lg:14px;
95
- --cb-space-1:4px;--cb-space-2:6px;--cb-space-3:8px;--cb-space-4:12px;--cb-space-5:16px;--cb-space-6:24px;
166
+ --cb-space-1:4px;--cb-space-2:6px;--cb-space-3:8px;--cb-space-4:10px;--cb-space-5:12px;--cb-space-6:18px;
96
167
  --cb-font-xs:12px;--cb-font-sm:12px;--cb-font-md:13px;--cb-font-lg:15px;
97
- --cb-control-h:34px;
168
+ --cb-control-h:30px;
98
169
  }
99
170
  .cb-panel{display:flex;flex-direction:column;gap:var(--cb-space-5);min-width:0;max-width:880px;color:var(--cb-text);container-type:inline-size}
100
171
  .cb-toolbar{display:flex;align-items:center;gap:var(--cb-space-3);flex-wrap:wrap}
@@ -159,39 +230,77 @@ body{
159
230
  .cb-code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:var(--cb-font-sm);color:var(--cb-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
160
231
  .cb-mask{color:var(--cb-text-sub)}
161
232
  .cb-modal-mask{position:fixed;inset:0;background:var(--cb-mask);display:flex;align-items:center;justify-content:center;z-index:60;animation:cb-fade-in .16s ease-out}
162
- .cb-modal{background:var(--cb-bg);color:var(--cb-text);border:1px solid var(--cb-border);border-radius:var(--cb-radius-lg);padding:var(--cb-space-6);max-width:720px;width:min(720px,92vw);max-height:84vh;overflow:auto;display:flex;flex-direction:column;gap:var(--cb-space-4);box-shadow:0 12px 40px rgb(31 35 41 / 18%);animation:cb-pop-in .2s ease-out}
233
+ .cb-modal{background:var(--cb-bg);color:var(--cb-text);border-radius:var(--cb-radius-lg);padding:var(--cb-space-6);max-width:640px;width:min(640px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 12px 40px rgb(31 35 41 / 18%);animation:cb-pop-in .2s ease-out}
163
234
  @keyframes cb-fade-in{from{opacity:0}to{opacity:1}}
164
235
  @keyframes cb-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
165
236
  @media (prefers-reduced-motion: reduce){.cb-modal-mask,.cb-modal{animation:none}.cb-card,.cb-row,.cb-button,.cb-pill,.cb-icon,.cb-switch__track,.cb-pc,.cb-pc__chevron{transition:none}}
166
- .cb-modal-head{display:flex;align-items:center;justify-content:space-between;gap:var(--cb-space-3);padding-bottom:var(--cb-space-3);border-bottom:1px solid var(--cb-border)}
237
+ .cb-modal-head{display:flex;align-items:center;justify-content:space-between;gap:var(--cb-space-3);padding-bottom:var(--cb-space-2);border-bottom:1px solid var(--cb-border)}
167
238
  .cb-modal-title{font-size:16px;font-weight:600}
168
- .cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-5)}
169
- .cb-section{display:flex;flex-direction:column;gap:var(--cb-space-3)}
239
+ .cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-4)}
240
+ .cb-section{display:flex;flex-direction:column;gap:var(--cb-space-2)}
170
241
  .cb-section-title{font-size:var(--cb-font-sm);font-weight:600;color:var(--cb-text);display:flex;align-items:center;gap:var(--cb-space-2)}
171
242
  .cb-section-title::before{content:'';width:3px;height:12px;border-radius:2px;background:var(--cb-accent)}
172
- .cb-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--cb-space-4) var(--cb-space-5)}
173
- .cb-field{display:flex;flex-direction:column;gap:var(--cb-space-2);font-size:var(--cb-font-sm);min-width:0;color:var(--cb-text-sub)}
243
+ .cb-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--cb-space-3) var(--cb-space-4)}
244
+ .cb-field{display:flex;flex-direction:column;gap:4px;font-size:var(--cb-font-sm);min-width:0;color:var(--cb-text-sub)}
174
245
  .cb-field--wide{grid-column:1 / -1}
175
- .cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea,.cb-field select{min-height:var(--cb-control-h);border:1px solid var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:5px 10px;font:inherit;font-size:var(--cb-font-md);background:var(--cb-bg);color:var(--cb-text);min-width:0;transition:border-color .15s,box-shadow .15s}
246
+ .cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea,.cb-field select{min-height:30px;border:1px solid var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:4px 10px;font:inherit;font-size:var(--cb-font-md);background:var(--cb-bg);color:var(--cb-text);min-width:0;transition:border-color .15s,box-shadow .15s}
176
247
  .cb-field input:focus,.cb-field textarea:focus,.cb-field select:focus{outline:none;border-color:var(--cb-accent);box-shadow:0 0 0 3px var(--cb-accent-wash)}
177
- .cb-field textarea{min-height:72px;resize:vertical;font-family:inherit}
248
+ .cb-field textarea{min-height:64px;resize:vertical;font-family:inherit}
178
249
  .cb-hint{font-size:var(--cb-font-xs);color:var(--cb-text-dim)}
179
- .cb-error{font-size:var(--cb-font-sm);color:var(--cb-danger);background:var(--cb-bg-sub);border-left:3px solid var(--cb-danger);border-radius:var(--cb-radius-sm);padding:var(--cb-space-2) var(--cb-space-3)}
180
250
  .cb-preview{font-size:var(--cb-font-sm);border:1px dashed var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:var(--cb-space-2) var(--cb-space-3);color:var(--cb-text-sub);background:var(--cb-accent-wash);border-color:color-mix(in srgb, var(--cb-accent) 35%, transparent);color:var(--cb-text)}
251
+ /* —— 表单 v2:统一分段组件 + 调度构建器(设计稿 .superdesign/shots/7、8 锁定形态)—— */
252
+ /* seg:类型/会话模式/调度频率共用同一形态,禁止另起多套选择样式 */
253
+ .cb-seg{display:inline-flex;gap:2px;background:var(--cb-bg-muted);border-radius:8px;padding:2px;width:fit-content}
254
+ .cb-seg__opt{border:none;background:transparent;border-radius:6px;padding:5px 11px;font:inherit;font-size:var(--cb-font-sm);font-weight:520;color:var(--cb-text-sub);cursor:pointer;line-height:1.2;white-space:nowrap;transition:background .15s,color .15s}
255
+ .cb-seg__opt:hover{color:var(--cb-text)}
256
+ .cb-seg__opt--on{background:var(--cb-accent);color:#fff;font-weight:560}
257
+ .cb-seg__opt--on:hover{color:#fff}
258
+ .cb-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--cb-accent) 70%, white);outline-offset:1px}
259
+ /* 调度构建器:极浅洗底面板,收纳频率/上下文/表达式/预览 */
260
+ .cb-builder{background:var(--cb-bg-sub);border:1px solid var(--cb-border);border-radius:10px;padding:var(--cb-space-5);display:flex;flex-direction:column;gap:10px}
261
+ .cb-bline{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
262
+ .cb-blabel{font-size:var(--cb-font-sm);color:var(--cb-text-sub);flex:none}
263
+ .cb-time{width:44px;text-align:center;font-variant-numeric:tabular-nums}
264
+ .cb-colon{color:var(--cb-text-dim);font-size:var(--cb-font-sm)}
265
+ .cb-expr{font-family:ui-monospace,SFMono-Regular,monospace;font-size:var(--cb-font-sm);background:var(--cb-bg);border:1px solid var(--cb-border);border-radius:6px;padding:4px 9px;color:var(--cb-text);display:inline-flex;align-items:center;min-width:0}
266
+ .cb-expr input{border:none;background:transparent;font:inherit;color:var(--cb-text);padding:0;width:9em}
267
+ .cb-expr input:focus{outline:none}
268
+ .cb-linkbtn{border:none;background:transparent;color:var(--cb-accent);font:inherit;font-size:var(--cb-font-sm);font-weight:520;cursor:pointer;padding:0}
269
+ .cb-linkbtn:hover{color:var(--cb-accent-deep)}
270
+ .cb-runs{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
271
+ .cb-run{display:inline-flex;align-items:center;gap:5px;font-size:var(--cb-font-xs);color:var(--cb-text-sub);background:var(--cb-bg);border:1px solid var(--cb-border);border-radius:999px;padding:3px 9px;white-space:nowrap}
272
+ .cb-run::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--cb-accent);flex:none}
273
+ .cb-run + .cb-run::before{background:var(--cb-text-dim)}
274
+ /* 紧凑行组:名称+类型 / 策略单行 */
275
+ .cb-formrow{display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap}
276
+ .cb-formrow > .cb-field{flex:1;min-width:180px}
277
+ .cb-formrow > .cb-field--fit{flex:0 0 auto;min-width:0;width:min(300px, 42%)}
278
+ /* 运行策略单行:标题+开关+内联数字段同一行 */
279
+ .cb-inline{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
280
+ .cb-inlinesection{font-size:var(--cb-font-sm);font-weight:600;color:var(--cb-text);display:inline-flex;align-items:center;gap:6px}
281
+ .cb-inlinesection::before{content:'';width:3px;height:11px;border-radius:2px;background:var(--cb-accent)}
282
+ .cb-if{display:inline-flex;align-items:center;gap:6px}
283
+ .cb-if > .cb-field{flex-direction:row;align-items:center;gap:6px}
284
+ .cb-if input[type="number"]{width:104px}
181
285
  .cb-log{font-family:ui-monospace,SFMono-Regular,monospace;font-size:var(--cb-font-sm);white-space:pre-wrap;word-break:break-all;background:var(--cb-bg-sub);border-radius:var(--cb-radius-md);padding:var(--cb-space-3);max-height:320px;overflow:auto}
182
286
  .cb-empty{font-size:var(--cb-font-md);color:var(--cb-text-dim);padding:var(--cb-space-6) 0;text-align:center;display:flex;flex-direction:column;align-items:center;gap:var(--cb-space-3)}
183
- .cb-select{min-height:var(--cb-control-h);border:1px solid var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:4px 8px;font:inherit;font-size:var(--cb-font-md);background:var(--cb-bg);color:var(--cb-text)}
287
+ .cb-select{min-height:30px;border:1px solid var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:4px 8px;font:inherit;font-size:var(--cb-font-md);background:var(--cb-bg);color:var(--cb-text)}
184
288
  .cb-confirm-text{font-size:var(--cb-font-md);color:var(--cb-text);line-height:1.5}
185
289
  .cb-log::-webkit-scrollbar,.cb-modal::-webkit-scrollbar{width:8px;height:8px}
186
290
  .cb-log::-webkit-scrollbar-thumb,.cb-modal::-webkit-scrollbar-thumb{background:var(--cb-border-strong);border-radius:999px}
187
291
  .cb-log::-webkit-scrollbar-track,.cb-modal::-webkit-scrollbar-track{background:transparent}
188
- /* 容器查询:better-sidebar tab 与主视图宽度差异大,跟随面板自身宽度而非视口 */
292
+ .cb-error{font-size:var(--cb-font-sm);color:var(--cb-danger);background:var(--cb-bg-sub);border-left:3px solid var(--cb-danger);border-radius:var(--cb-radius-sm);padding:var(--cb-space-2) var(--cb-space-3)}
293
+ /* 容器查询:better-sidebar tab 与主视图宽度差异大,跟随面板自身宽度而非视口。
294
+ 模态不受面板容器宽度连累:mask 固定定位于视口,断点用视口媒体查询独立判定 */
189
295
  @container (max-width: 560px){
190
- .cb-grid{grid-template-columns:1fr}
191
296
  .cb-card-row .cb-actions{margin-left:0;width:100%;justify-content:flex-end;flex-wrap:wrap}
192
297
  .cb-view{padding:var(--cb-space-4)}
193
298
  .cb-panel{gap:var(--cb-space-4)}
194
299
  }
300
+ @media (max-width: 560px){
301
+ .cb-grid{grid-template-columns:1fr}
302
+ .cb-modal{max-height:92vh}
303
+ }
195
304
  .cb-entry{display:flex;align-items:center;gap:var(--cb-space-3);width:100%;border:none;background:transparent;color:inherit;cursor:pointer;padding:var(--cb-space-2) var(--cb-space-4);border-radius:var(--cb-radius-md);font-size:var(--cb-font-md);text-align:left}
196
305
  .cb-entry:hover{background:var(--cb-hover)}
197
306
  .cb-entry[data-active="true"]{background:var(--cb-accent);color:#fff}
@@ -269,8 +378,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
269
378
  }, option.label)))
270
379
  }
271
380
 
272
- function Field({ label, hint, wide, children }) {
273
- return h('div', { className: 'cb-field' + (wide ? ' cb-field--wide' : '') },
381
+ function Field({ label, hint, wide, fit, children }) {
382
+ return h('div', { className: 'cb-field' + (wide ? ' cb-field--wide' : '') + (fit ? ' cb-field--fit' : '') },
274
383
  h('span', null, label),
275
384
  children,
276
385
  hint ? h('span', { className: 'cb-hint' }, hint) : null)
@@ -301,6 +410,87 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
301
410
  }
302
411
 
303
412
  // —— 任务表单(新建/编辑)——
413
+ // v2 布局(设计稿 shots/7、8 锁定):统一分段组件(cb-seg)承担类型/会话模式/调度频率切换;
414
+ // 调度构建器洗底面板:结构化频率生成表达式,反解不出即 custom 手输;触发点预览仍归后端 cron/preview。
415
+ function Segmented({ options, value, onChange, ariaLabel, multi, selected }) {
416
+ // multi:周几类多选切换;与单选共用同一 DOM/CSS 形态,仅选中判定不同
417
+ const isOn = (optionValue) => (multi ? selected.includes(optionValue) : optionValue === value)
418
+ return h('div', { className: 'cb-seg', role: 'group', 'aria-label': ariaLabel },
419
+ options.map((option) => h('button', {
420
+ key: option.value,
421
+ type: 'button',
422
+ 'aria-pressed': multi ? isOn(option.value) : undefined,
423
+ className: 'cb-seg__opt' + (isOn(option.value) ? ' cb-seg__opt--on' : ''),
424
+ onClick: () => onChange(option.value),
425
+ }, option.label)))
426
+ }
427
+
428
+ const FREQ_OPTIONS = [
429
+ { value: 'daily', label: '每天' },
430
+ { value: 'weekly', label: '每周' },
431
+ { value: 'weekdays', label: '工作日' },
432
+ { value: 'interval', label: '固定间隔' },
433
+ { value: 'custom', label: '自定义' },
434
+ ]
435
+
436
+ // 调度构建器面板:频率分段 + 上下文选择器 + 表达式芯片 + 即将执行 chips
437
+ function ScheduleBuilder({ scheduleState, onStateChange, preview }) {
438
+ const state = scheduleState
439
+ const setFreq = (freq) => onStateChange({ ...state, freq })
440
+ const patchClock = (field, raw) => {
441
+ const value = Math.max(0, Math.min(field === 'hour' ? SB_HOUR_MAX : SB_MINUTE_MAX, Number(raw) || 0))
442
+ onStateChange({ ...state, [field]: value })
443
+ }
444
+ const toggleWeekday = (day) => {
445
+ const days = state.weekdays.includes(day)
446
+ ? state.weekdays.filter((d) => d !== day)
447
+ : SB_WEEKDAY_ORDER.filter((d) => state.weekdays.includes(d) || d === day)
448
+ if (days.length === 0) return
449
+ onStateChange({ ...state, weekdays: days })
450
+ }
451
+ const clockRow = (h('div', { className: 'cb-bline' },
452
+ h('span', { className: 'cb-blabel' }, '执行时间'),
453
+ h('input', { type: 'number', className: 'cb-time', min: 0, max: SB_HOUR_MAX, value: state.hour, onChange: (e) => patchClock('hour', e.target.value) }),
454
+ h('span', { className: 'cb-colon' }, ':'),
455
+ h('input', { type: 'number', className: 'cb-time', min: 0, max: SB_MINUTE_MAX, value: state.minute, onChange: (e) => patchClock('minute', e.target.value) }),
456
+ h('span', { className: 'cb-hint' }, state.freq === 'weekdays' ? '周一至周五在指定时刻运行' : state.freq === 'weekly' ? '所选星期在指定时刻运行' : '按天在指定时刻运行')))
457
+ const intervalRow = (h('div', { className: 'cb-bline' },
458
+ h('span', { className: 'cb-blabel' }, '间隔'),
459
+ h('input', { type: 'number', className: 'cb-time', min: SB_INTERVAL_MIN_MINUTES, max: SB_MINUTE_MAX, value: state.intervalMinutes, onChange: (e) => onStateChange({ ...state, intervalMinutes: Math.max(SB_INTERVAL_MIN_MINUTES, Number(e.target.value) || SB_INTERVAL_MIN_MINUTES) }) }),
460
+ h('span', { className: 'cb-hint' }, '分钟,每 N 分钟运行一次')))
461
+ const customRow = (h('div', { className: 'cb-bline' },
462
+ h('span', { className: 'cb-blabel' }, '表达式'),
463
+ h('input', { type: 'text', value: state.expression, placeholder: '分 时 日 月 周', onChange: (e) => onStateChange({ ...state, expression: e.target.value }) }),
464
+ h('span', { className: 'cb-hint' }, '分 时 日 月 周;预设: ' + CRON_PRESETS.slice(0, 3).map((p) => p.label + ' ' + p.value).join(' / '))))
465
+ return h('div', { className: 'cb-builder' },
466
+ h(Segmented, { options: FREQ_OPTIONS, value: state.freq, onChange: setFreq, ariaLabel: '调度频率' }),
467
+ state.freq === 'interval' ? intervalRow : null,
468
+ state.freq === 'custom' ? customRow : clockRow,
469
+ state.freq === 'weekly'
470
+ ? h('div', { className: 'cb-bline' },
471
+ h('span', { className: 'cb-blabel' }, '星期'),
472
+ h(Segmented, {
473
+ multi: true,
474
+ selected: state.weekdays,
475
+ options: SB_WEEKDAY_ORDER.map((d) => ({ value: d, label: SB_WEEKDAY_LABELS[d] })),
476
+ onChange: toggleWeekday,
477
+ ariaLabel: '星期',
478
+ }))
479
+ : null,
480
+ h('div', { className: 'cb-bline' },
481
+ h('span', { className: 'cb-blabel' }, '生成表达式'),
482
+ h('span', { className: 'cb-expr' }, buildSchedule(state)),
483
+ state.freq === 'custom'
484
+ ? null
485
+ : h('button', { type: 'button', className: 'cb-linkbtn', onClick: () => setFreq('custom') }, '自定义'),
486
+ state.freq === 'custom' ? null : h('span', { className: 'cb-hint' }, '由上方选择自动生成')),
487
+ h('div', { className: 'cb-bline' },
488
+ h('span', { className: 'cb-blabel' }, '即将执行'),
489
+ preview
490
+ ? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at))))
491
+ : h('span', { className: 'cb-hint' }, '表达式非法或计算中')))
492
+ }
493
+
304
494
  function JobForm({ job, onDone, wsModel }) {
305
495
  // 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
306
496
  const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
@@ -311,29 +501,45 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
311
501
  return wsModel.subscribe(update)
312
502
  }, [wsModel])
313
503
  const editing = Boolean(job && job.id)
504
+ const initialSchedule = job ? job.schedule : '0 9 * * *'
314
505
  const [form, setForm] = useState(() => ({
315
506
  name: job ? job.name : '',
316
507
  kind: job ? job.kind : 'shell',
317
508
  command: job ? job.command : '',
318
509
  prompt: job ? job.prompt : '',
319
510
  workdir: job ? job.workdir : '',
320
- schedule: job ? job.schedule : '0 9 * * *',
511
+ schedule: initialSchedule,
321
512
  timeoutMs: job ? job.timeoutMs : 60 * 60 * 1000,
513
+ runOnce: job ? job.runOnce === true : false,
322
514
  concurrency: job && job.concurrency ? String(job.concurrency) : '',
323
515
  enabled: job ? job.enabled : true,
324
516
  session: {
325
517
  mode: job && job.session ? job.session.mode : 'fresh',
326
518
  pinnedSessionId: job && job.session ? job.session.pinnedSessionId : '',
327
- windowStart: job && job.session ? job.session.windowStart : '',
328
- windowEnd: job && job.session ? job.session.windowEnd : '',
329
- onMiss: job && job.session ? job.session.onMiss : 'skip',
519
+ agentPreset: job && job.session ? job.session.agentPreset : '',
330
520
  },
331
521
  }))
522
+ // 构建器状态:由既有表达式反解初始化;结构化变更写回 form.schedule
523
+ const [scheduleState, setScheduleState] = useState(() => parseSchedule(initialSchedule))
332
524
  const [preview, setPreview] = useState(null)
333
525
  const [error, setError] = useState(null)
334
526
  const [saving, setSaving] = useState(false)
527
+ const [presetCatalog, setPresetCatalog] = useState({ defaultId: '', items: [] })
335
528
  const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
336
529
  const setSession = (patch) => setForm((prev) => ({ ...prev, session: { ...prev.session, ...patch } }))
530
+ const applyScheduleState = (next) => {
531
+ setScheduleState(next)
532
+ set({ schedule: buildSchedule(next) })
533
+ }
534
+
535
+ // 执行预设目录:会话任务表单打开时拉取一次;服务缺失即空目录,仅「跟随宿主默认」可选
536
+ useEffect(() => {
537
+ let alive = true
538
+ request('GET', 'agent-presets').then((outcome) => {
539
+ if (alive && outcome.ok && outcome.data && Array.isArray(outcome.data.items)) setPresetCatalog(outcome.data)
540
+ })
541
+ return () => { alive = false }
542
+ }, [])
337
543
 
338
544
  useEffect(() => {
339
545
  let alive = true
@@ -357,26 +563,26 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
357
563
  else setError(outcome.error)
358
564
  }
359
565
 
360
- return h(Modal, { title: editing ? '编辑任务' : '新建任务', onClose: onDone, width: 720 },
361
- h(Section, { title: '基础信息' },
362
- h('div', { className: 'cb-grid' },
363
- h(Field, { label: '名称' }, h('input', { type: 'text', value: form.name, onChange: (e) => set({ name: e.target.value }) })),
364
- h(Field, { label: '类型' }, h(PillGroup, {
365
- options: [{ value: 'shell', label: KIND_LABELS.shell }, { value: 'session', label: KIND_LABELS.session }],
366
- value: form.kind,
367
- onChange: (kind) => set({ kind }),
368
- })))),
369
- h(Section, { title: form.kind === 'shell' ? '执行配置(shell)' : '执行配置(会话)' },
370
- h('div', { className: 'cb-grid' },
371
- form.kind === 'shell'
372
- ? h(Field, { label: '命令', wide: true, hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
373
- h('input', { type: 'text', value: form.command, onChange: (e) => set({ command: e.target.value }) }))
374
- : null,
375
- form.kind === 'shell'
376
- ? h(Field, { label: '工作目录(可空)' }, h('input', { type: 'text', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) }))
377
- : null,
378
- form.kind === 'session'
379
- ? h(Field, {
566
+ return h(Modal, { title: editing ? '编辑任务' : '新建任务', onClose: onDone, width: 640 },
567
+ h('div', { className: 'cb-formrow' },
568
+ h(Field, { label: '名称' }, h('input', { type: 'text', value: form.name, onChange: (e) => set({ name: e.target.value }) })),
569
+ h(Field, { label: '类型', fit: true }, h(Segmented, {
570
+ options: [{ value: 'shell', label: KIND_LABELS.shell }, { value: 'session', label: KIND_LABELS.session }],
571
+ value: form.kind,
572
+ onChange: (kind) => set({ kind }),
573
+ ariaLabel: '类型',
574
+ }))),
575
+ h(Section, { title: '调度计划' },
576
+ h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview })),
577
+ form.kind === 'shell'
578
+ ? h(Section, { title: '运行配置(shell)' },
579
+ h(Field, { label: '命令', hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
580
+ h('input', { type: 'text', value: form.command, onChange: (e) => set({ command: e.target.value }) })),
581
+ h('div', { className: 'cb-formrow' },
582
+ h(Field, { label: '工作目录(可空)' }, h('input', { type: 'text', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) }))))
583
+ : h(Section, { title: '运行配置(会话)' },
584
+ h('div', { className: 'cb-formrow' },
585
+ h(Field, {
380
586
  label: '工作区(会话 cwd,可空)',
381
587
  hint: wsItems.length > 0 ? '从宿主工作区选择,或留空使用默认目录' : '留空使用宿主默认目录;填入路径则会话在该目录下创建',
382
588
  },
@@ -390,52 +596,49 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
390
596
  h('option', { value: '' }, '默认目录'),
391
597
  wsItems.map((w) => h('option', { key: w.path || w.workspaceId, value: w.path }, w.title || w.path)))
392
598
  : null,
393
- h('input', { type: 'text', placeholder: '/path/to/workspace', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) })))
394
- : null,
395
- form.kind === 'session'
396
- ? h(Field, { label: '任务文本', wide: true, hint: '投递给会话的任务内容,环境变量会折叠在文本前部' },
397
- h('textarea', { value: form.prompt, onChange: (e) => set({ prompt: e.target.value }) }))
398
- : null,
399
- form.kind === 'session'
400
- ? h(Field, { label: '会话模式' }, h(PillGroup, {
401
- options: [{ value: 'fresh', label: MODE_LABELS.fresh }, { value: 'pinned', label: MODE_LABELS.pinned }],
402
- value: form.session.mode,
403
- onChange: (mode) => setSession({ mode }),
404
- }))
405
- : null,
406
- form.kind === 'session' && form.session.mode === 'pinned'
407
- ? h(Field, { label: '固定会话 ID(可空,首跑自动绑定)' },
408
- h('input', { type: 'text', value: form.session.pinnedSessionId, onChange: (e) => setSession({ pinnedSessionId: e.target.value }) }))
409
- : null,
410
- form.kind === 'session'
411
- ? h(Field, { label: '允许时段起(可空)' }, h('input', { type: 'text', placeholder: '09:00', value: form.session.windowStart, onChange: (e) => setSession({ windowStart: e.target.value }) }))
412
- : null,
413
- form.kind === 'session'
414
- ? h(Field, { label: '允许时段止(可空)' }, h('input', { type: 'text', placeholder: '23:00', value: form.session.windowEnd, onChange: (e) => setSession({ windowEnd: e.target.value }) }))
415
- : null,
416
- form.kind === 'session'
417
- ? h(Field, { label: '窗口外策略' }, h(PillGroup, {
418
- options: [{ value: 'skip', label: ONMISS_LABELS.skip }, { value: 'defer', label: ONMISS_LABELS.defer }],
419
- value: form.session.onMiss,
420
- onChange: (onMiss) => setSession({ onMiss }),
421
- }))
422
- : null)),
423
- h(Section, { title: '调度计划' },
424
- h('div', { className: 'cb-grid' },
425
- h(Field, { label: 'cron 表达式(分 时 日 月 周)' },
426
- h('div', { style: { display: 'flex', gap: 6 } },
427
- h('input', { type: 'text', value: form.schedule, onChange: (e) => set({ schedule: e.target.value }) }),
599
+ h('input', { type: 'text', placeholder: '或手输路径', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) }))),
600
+ h(Field, { label: '会话模式', fit: true },
601
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
602
+ h(Segmented, {
603
+ options: [{ value: 'fresh', label: MODE_LABELS.fresh }, { value: 'pinned', label: MODE_LABELS.pinned }],
604
+ value: form.session.mode,
605
+ onChange: (mode) => setSession({ mode }),
606
+ ariaLabel: '会话模式',
607
+ }),
608
+ form.session.mode === 'pinned'
609
+ ? h('input', {
610
+ type: 'text',
611
+ placeholder: '固定会话 ID(可空,首跑自动绑定)',
612
+ title: '留空则首跑自动绑定;会话被删除或归档时自动重建并回写',
613
+ value: form.session.pinnedSessionId,
614
+ onChange: (e) => setSession({ pinnedSessionId: e.target.value }),
615
+ })
616
+ : null))),
617
+ h(Field, { label: '执行预设', hint: '会话使用的 Agent Preset;跟随宿主默认时由宿主解析当前默认' },
428
618
  h('select', {
429
619
  className: 'cb-select',
430
- value: '',
431
- onChange: (e) => { if (e.target.value) set({ schedule: e.target.value }) },
620
+ value: presetCatalog.items.some((item) => item.id === form.session.agentPreset) ? form.session.agentPreset : '',
621
+ onChange: (e) => setSession({ agentPreset: e.target.value }),
432
622
  },
433
- h('option', { value: '' }, '预设'),
434
- CRON_PRESETS.map((preset) => h('option', { key: preset.value, value: preset.value }, preset.label)))),
435
- preview ? h('span', { className: 'cb-preview' },
436
- preview.summary + ';接下来 ' + preview.nextAt.map((at) => formatDateTime(at)).join(' / ')) : null),
437
- h(Field, { label: '超时(毫秒)' }, h('input', { type: 'number', value: form.timeoutMs, onChange: (e) => set({ timeoutMs: Number(e.target.value) }) })),
438
- h(Field, { label: '并发上限(可空)' }, h('input', { type: 'number', value: form.concurrency, onChange: (e) => set({ concurrency: e.target.value }) })))),
623
+ h('option', { value: '' }, '跟随宿主默认'),
624
+ presetCatalog.items.map((item) => h('option', { key: item.id, value: item.id }, item.label)))),
625
+ h(Field, { label: '任务文本', hint: '投递给会话的任务内容,环境变量会折叠在文本前部' },
626
+ h('textarea', { value: form.prompt, onChange: (e) => set({ prompt: e.target.value }) }))),
627
+ h('div', { className: 'cb-inline' },
628
+ h('span', { className: 'cb-inlinesection' }, '运行策略'),
629
+ switchToggle({ checked: form.runOnce, onChange: (e) => set({ runOnce: e.target.checked }), label: '单次运行' }),
630
+ form.kind === 'shell'
631
+ ? h('span', { className: 'cb-if' },
632
+ h('span', { className: 'cb-blabel' }, '超时'),
633
+ h('input', { type: 'number', value: form.timeoutMs, title: '毫秒,超时即中断并标记失败', onChange: (e) => set({ timeoutMs: Number(e.target.value) }) }),
634
+ h('span', { className: 'cb-hint' }, 'ms'))
635
+ : null,
636
+ form.kind === 'shell'
637
+ ? h('span', { className: 'cb-if' },
638
+ h('span', { className: 'cb-blabel' }, '并发上限'),
639
+ h('input', { type: 'number', value: form.concurrency, style: { width: 72 }, title: '限制该任务同时进行的执行数,可空', onChange: (e) => set({ concurrency: e.target.value }) }))
640
+ : null,
641
+ form.kind === 'session' ? h('span', { className: 'cb-hint' }, '会话任务投递即完成,无超时与并发语义') : null),
439
642
  error ? h('div', { className: 'cb-error' }, error) : null,
440
643
  h('div', { className: 'cb-toolbar' },
441
644
  h('button', { className: 'cb-button', onClick: onDone }, '取消'),
@@ -453,8 +656,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
453
656
  h('span', { className: 'cb-name' }, job.name),
454
657
  h('span', { className: 'cb-badge' }, KIND_LABELS[job.kind] || job.kind),
455
658
  job.kind === 'session' && job.session ? h('span', { className: 'cb-badge' }, MODE_LABELS[job.session.mode]) : null,
456
- job.kind === 'session' && job.session && job.session.windowStart
457
- ? h('span', { className: 'cb-badge' }, job.session.windowStart + '-' + job.session.windowEnd) : null,
458
659
  h('div', { className: 'cb-actions', onClick: (event) => event.stopPropagation() },
459
660
  switchToggle({ checked: Boolean(job.enabled), onChange: onToggle }),
460
661
  h('button', { className: 'cb-button cb-button--primary cb-button--sm', disabled: !job.enabled, title: job.enabled ? '立即运行' : '已停用', onClick: onRun }, '运行'),
@@ -821,8 +1022,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
821
1022
  sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
822
1023
  }
823
1024
 
824
- // better-sidebar tab:注册即单实例(single);v0.19.0 起注册只入「可打开」目录,
825
- // 须显式 openTab 落入底部工作台;wsModel 供会话任务选工作区
1025
+ // better-sidebar tab:注册即单实例(single);只注册不主动打开,
1026
+ // 打开由用户从侧边栏目录手动进入(注册即 openTab 会在每次页面加载时抢占当前视图)
826
1027
  function registerBoardTab(ctx, service, wsModel) {
827
1028
  return ctx.effect(() => {
828
1029
  const disposeTab = service.registerTab({
@@ -836,10 +1037,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
836
1037
  single: true,
837
1038
  component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
838
1039
  })
839
- // 无会话时 openTab 静默不落,tab 仍注册在册(新建标签页菜单可见)
840
- if (typeof service.openTab === 'function') {
841
- try { service.openTab({ type: TAB_ID }) } catch { /* 打开失败不回滚注册 */ }
842
- }
843
1040
  return disposeTab
844
1041
  }, 'cron-board sidebar tab')
845
1042
  }
package/src/executor.mjs CHANGED
@@ -67,7 +67,12 @@ export function createExecutor({ store, logger, runner, sessionRunner, maxExpans
67
67
  await store.runs.update(record.runId, patch)
68
68
  // 任务卡状态回填:看板状态点/耗时直接读任务行(手动与 cron 同源更新)
69
69
  const jobPatch = { lastStatus: outcome.status, lastDurationMs: patch.durationMs }
70
- if (outcome.pinnedNewId !== undefined) jobPatch.pinnedSessionId = outcome.pinnedNewId
70
+ if (outcome.pinnedNewId !== undefined) {
71
+ // pinned 绑定必须落 session 子对象(driver 读 job.session.pinnedSessionId),
72
+ // 浅合并保留 mode 等其余字段;顶层孤立字段以 undefined 覆盖,落盘时自然消失(历史数据自愈)
73
+ jobPatch.session = { ...job.session, pinnedSessionId: outcome.pinnedNewId }
74
+ jobPatch.pinnedSessionId = undefined
75
+ }
71
76
  await store.jobs.update(job.id, jobPatch)
72
77
  } catch (error) {
73
78
  // runner 拒绝(会话服务异常等)也必须有终态,否则运行记录永久停留 running;
@@ -124,6 +129,10 @@ export function createExecutor({ store, logger, runner, sessionRunner, maxExpans
124
129
  records.push(record)
125
130
  queue.push({ job, record, env: combinations[i], runnerFor })
126
131
  }
132
+ // 单次任务入队即禁用:先占运行再落禁用,封死 cron 连续 tick 的重复触发窗口
133
+ if (job.runOnce === true) {
134
+ await store.jobs.update(job.id, { enabled: false })
135
+ }
127
136
  pump()
128
137
  return records.map((record) => record.runId)
129
138
  },
package/src/index.js CHANGED
@@ -64,6 +64,10 @@ export function apply(ctx, config) {
64
64
  const logger = createLogger({ rootDir: join(dataDir, 'logs') })
65
65
  const sessionDriver = createSessionDriver({
66
66
  getSessionController: () => sessionControllerRef.current,
67
+ // 分组解析为可选增强:registry 缺失(旧宿主)时 driver 干净降级为 cwd-only
68
+ getWorkspaceRegistry: () => ctx.get('workspaceRegistry'),
69
+ // 执行预设目录为可选增强:服务缺失时任务不带 preset,由宿主解析默认
70
+ getAgentPresets: () => ctx.get('agentPresets'),
67
71
  agents: ctx.get('agents'),
68
72
  sessionQuery: ctx.get('sessionQuery'),
69
73
  })
@@ -88,6 +92,7 @@ export function apply(ctx, config) {
88
92
  sessionState,
89
93
  readSidebarTab: () => readBoolean('sidebarTab', false),
90
94
  updateUiSettings,
95
+ getAgentPresets: () => ctx.get('agentPresets'),
91
96
  logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
92
97
  })
93
98
  return { api, scheduler }
@@ -210,5 +215,6 @@ export function apply(ctx, config) {
210
215
  )
211
216
 
212
217
  // defer 挂起定时器随插件生命周期回收,不遗留生命周期外触发
213
- ctx.effect(() => () => schedulerRef.current.dispose(), 'cron-board scheduler lifecycle')
218
+ // 生命周期钩子:scheduler 无挂起资源时 dispose 可缺省,可选调用保持约定统一
219
+ ctx.effect(() => () => schedulerRef.current.dispose?.(), 'cron-board scheduler lifecycle')
214
220
  }