@mzzsfy/dsh-cron-board 0.2.0 → 0.3.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/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}
@@ -136,47 +207,100 @@ body{
136
207
  .cb-switch input[type="checkbox"]:disabled + .cb-switch__track{opacity:.4;cursor:not-allowed}
137
208
  .cb-switch:not(:has(input[type="checkbox"]:disabled)):hover .cb-switch__track{background:var(--cb-accent-deep);opacity:.85}
138
209
  .cb-switch input[type="checkbox"]:checked:not(:disabled) + .cb-switch__track:hover{background:var(--cb-accent-deep)}
139
- .cb-settings{display:flex;flex-direction:column;gap:var(--cb-space-2);padding:var(--cb-space-2) 0}
140
- .cb-settings__hint{font-size:var(--cb-font-sm);color:var(--cb-text-dim)}
210
+ /* 设置>插件页卡片(cb-pc):镜像官方 PluginCard/SubagentModelSelectionCard 形制,数值与令牌直取官方 CSS Module;
211
+ 官方壳未导出,slot 契约声明卡片外观归插件自持,同页同 dsw-alias 令牌即同观感(含深色主题) */
212
+ .cb-pc{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}
213
+ .cb-pc:hover{border-color:var(--dsw-alias-label-dimmed)}
214
+ .cb-pc--open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
215
+ .cb-pc__head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
216
+ .cb-pc__head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
217
+ .cb-pc__text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
218
+ .cb-pc__name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
219
+ .cb-pc__desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
220
+ .cb-pc__chevron{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex;transition:transform .16s}
221
+ .cb-pc--open .cb-pc__chevron{transform:rotate(180deg)}
222
+ .cb-pc__body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
223
+ .cb-pc__row{gap:6px;padding:12px 0;display:grid}
224
+ .cb-pc__rowLine{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:flex-start;gap:16px;font-size:13px;line-height:1.5;display:flex}
225
+ .cb-pc__rowLabel{flex:1;min-width:0}
226
+ .cb-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
141
227
  .cb-table{display:flex;flex-direction:column;gap:var(--cb-space-1);font-size:var(--cb-font-md)}
142
228
  .cb-row{display:flex;align-items:center;gap:var(--cb-space-3);border:1px solid var(--cb-border);border-radius:var(--cb-radius-md);padding:var(--cb-space-2) var(--cb-space-4);flex-wrap:wrap;transition:background .15s,border-color .15s}
143
229
  .cb-row:hover{background:var(--cb-hover)}
144
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%}
145
231
  .cb-mask{color:var(--cb-text-sub)}
146
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}
147
- .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}
148
234
  @keyframes cb-fade-in{from{opacity:0}to{opacity:1}}
149
235
  @keyframes cb-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
150
- @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{transition:none}}
151
- .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)}
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}}
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)}
152
238
  .cb-modal-title{font-size:16px;font-weight:600}
153
- .cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-5)}
154
- .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)}
155
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)}
156
242
  .cb-section-title::before{content:'';width:3px;height:12px;border-radius:2px;background:var(--cb-accent)}
157
- .cb-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--cb-space-4) var(--cb-space-5)}
158
- .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)}
159
245
  .cb-field--wide{grid-column:1 / -1}
160
- .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}
161
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)}
162
- .cb-field textarea{min-height:72px;resize:vertical;font-family:inherit}
248
+ .cb-field textarea{min-height:64px;resize:vertical;font-family:inherit}
163
249
  .cb-hint{font-size:var(--cb-font-xs);color:var(--cb-text-dim)}
164
- .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)}
165
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}
166
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}
167
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)}
168
- .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)}
169
288
  .cb-confirm-text{font-size:var(--cb-font-md);color:var(--cb-text);line-height:1.5}
170
289
  .cb-log::-webkit-scrollbar,.cb-modal::-webkit-scrollbar{width:8px;height:8px}
171
290
  .cb-log::-webkit-scrollbar-thumb,.cb-modal::-webkit-scrollbar-thumb{background:var(--cb-border-strong);border-radius:999px}
172
291
  .cb-log::-webkit-scrollbar-track,.cb-modal::-webkit-scrollbar-track{background:transparent}
173
- /* 容器查询: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 固定定位于视口,断点用视口媒体查询独立判定 */
174
295
  @container (max-width: 560px){
175
- .cb-grid{grid-template-columns:1fr}
176
296
  .cb-card-row .cb-actions{margin-left:0;width:100%;justify-content:flex-end;flex-wrap:wrap}
177
297
  .cb-view{padding:var(--cb-space-4)}
178
298
  .cb-panel{gap:var(--cb-space-4)}
179
299
  }
300
+ @media (max-width: 560px){
301
+ .cb-grid{grid-template-columns:1fr}
302
+ .cb-modal{max-height:92vh}
303
+ }
180
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}
181
305
  .cb-entry:hover{background:var(--cb-hover)}
182
306
  .cb-entry[data-active="true"]{background:var(--cb-accent);color:#fff}
@@ -239,7 +363,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
239
363
  // 规约 switch:input 锚定状态选择器,视觉由 track+thumb 呈现
240
364
  function switchToggle(props) {
241
365
  return h('label', { className: 'cb-switch' },
242
- h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange }),
366
+ h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange,
367
+ 'aria-label': props.ariaLabel }),
243
368
  h('span', { className: 'cb-switch__track' }, h('span', { className: 'cb-switch__thumb' })),
244
369
  props.label ? h('span', { className: 'cb-meta' }, props.label) : null)
245
370
  }
@@ -253,8 +378,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
253
378
  }, option.label)))
254
379
  }
255
380
 
256
- function Field({ label, hint, wide, children }) {
257
- 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' : '') },
258
383
  h('span', null, label),
259
384
  children,
260
385
  hint ? h('span', { className: 'cb-hint' }, hint) : null)
@@ -285,6 +410,87 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
285
410
  }
286
411
 
287
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
+
288
494
  function JobForm({ job, onDone, wsModel }) {
289
495
  // 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
290
496
  const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
@@ -295,29 +501,45 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
295
501
  return wsModel.subscribe(update)
296
502
  }, [wsModel])
297
503
  const editing = Boolean(job && job.id)
504
+ const initialSchedule = job ? job.schedule : '0 9 * * *'
298
505
  const [form, setForm] = useState(() => ({
299
506
  name: job ? job.name : '',
300
507
  kind: job ? job.kind : 'shell',
301
508
  command: job ? job.command : '',
302
509
  prompt: job ? job.prompt : '',
303
510
  workdir: job ? job.workdir : '',
304
- schedule: job ? job.schedule : '0 9 * * *',
511
+ schedule: initialSchedule,
305
512
  timeoutMs: job ? job.timeoutMs : 60 * 60 * 1000,
513
+ runOnce: job ? job.runOnce === true : false,
306
514
  concurrency: job && job.concurrency ? String(job.concurrency) : '',
307
515
  enabled: job ? job.enabled : true,
308
516
  session: {
309
517
  mode: job && job.session ? job.session.mode : 'fresh',
310
518
  pinnedSessionId: job && job.session ? job.session.pinnedSessionId : '',
311
- windowStart: job && job.session ? job.session.windowStart : '',
312
- windowEnd: job && job.session ? job.session.windowEnd : '',
313
- onMiss: job && job.session ? job.session.onMiss : 'skip',
519
+ agentPreset: job && job.session ? job.session.agentPreset : '',
314
520
  },
315
521
  }))
522
+ // 构建器状态:由既有表达式反解初始化;结构化变更写回 form.schedule
523
+ const [scheduleState, setScheduleState] = useState(() => parseSchedule(initialSchedule))
316
524
  const [preview, setPreview] = useState(null)
317
525
  const [error, setError] = useState(null)
318
526
  const [saving, setSaving] = useState(false)
527
+ const [presetCatalog, setPresetCatalog] = useState({ defaultId: '', items: [] })
319
528
  const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
320
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
+ }, [])
321
543
 
322
544
  useEffect(() => {
323
545
  let alive = true
@@ -341,26 +563,26 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
341
563
  else setError(outcome.error)
342
564
  }
343
565
 
344
- return h(Modal, { title: editing ? '编辑任务' : '新建任务', onClose: onDone, width: 720 },
345
- h(Section, { title: '基础信息' },
346
- h('div', { className: 'cb-grid' },
347
- h(Field, { label: '名称' }, h('input', { type: 'text', value: form.name, onChange: (e) => set({ name: e.target.value }) })),
348
- h(Field, { label: '类型' }, h(PillGroup, {
349
- options: [{ value: 'shell', label: KIND_LABELS.shell }, { value: 'session', label: KIND_LABELS.session }],
350
- value: form.kind,
351
- onChange: (kind) => set({ kind }),
352
- })))),
353
- h(Section, { title: form.kind === 'shell' ? '执行配置(shell)' : '执行配置(会话)' },
354
- h('div', { className: 'cb-grid' },
355
- form.kind === 'shell'
356
- ? h(Field, { label: '命令', wide: true, hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
357
- h('input', { type: 'text', value: form.command, onChange: (e) => set({ command: e.target.value }) }))
358
- : null,
359
- form.kind === 'shell'
360
- ? h(Field, { label: '工作目录(可空)' }, h('input', { type: 'text', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) }))
361
- : null,
362
- form.kind === 'session'
363
- ? 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, {
364
586
  label: '工作区(会话 cwd,可空)',
365
587
  hint: wsItems.length > 0 ? '从宿主工作区选择,或留空使用默认目录' : '留空使用宿主默认目录;填入路径则会话在该目录下创建',
366
588
  },
@@ -374,52 +596,49 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
374
596
  h('option', { value: '' }, '默认目录'),
375
597
  wsItems.map((w) => h('option', { key: w.path || w.workspaceId, value: w.path }, w.title || w.path)))
376
598
  : null,
377
- h('input', { type: 'text', placeholder: '/path/to/workspace', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) })))
378
- : null,
379
- form.kind === 'session'
380
- ? h(Field, { label: '任务文本', wide: true, hint: '投递给会话的任务内容,环境变量会折叠在文本前部' },
381
- h('textarea', { value: form.prompt, onChange: (e) => set({ prompt: e.target.value }) }))
382
- : null,
383
- form.kind === 'session'
384
- ? h(Field, { label: '会话模式' }, h(PillGroup, {
385
- options: [{ value: 'fresh', label: MODE_LABELS.fresh }, { value: 'pinned', label: MODE_LABELS.pinned }],
386
- value: form.session.mode,
387
- onChange: (mode) => setSession({ mode }),
388
- }))
389
- : null,
390
- form.kind === 'session' && form.session.mode === 'pinned'
391
- ? h(Field, { label: '固定会话 ID(可空,首跑自动绑定)' },
392
- h('input', { type: 'text', value: form.session.pinnedSessionId, onChange: (e) => setSession({ pinnedSessionId: e.target.value }) }))
393
- : null,
394
- form.kind === 'session'
395
- ? h(Field, { label: '允许时段起(可空)' }, h('input', { type: 'text', placeholder: '09:00', value: form.session.windowStart, onChange: (e) => setSession({ windowStart: e.target.value }) }))
396
- : null,
397
- form.kind === 'session'
398
- ? h(Field, { label: '允许时段止(可空)' }, h('input', { type: 'text', placeholder: '23:00', value: form.session.windowEnd, onChange: (e) => setSession({ windowEnd: e.target.value }) }))
399
- : null,
400
- form.kind === 'session'
401
- ? h(Field, { label: '窗口外策略' }, h(PillGroup, {
402
- options: [{ value: 'skip', label: ONMISS_LABELS.skip }, { value: 'defer', label: ONMISS_LABELS.defer }],
403
- value: form.session.onMiss,
404
- onChange: (onMiss) => setSession({ onMiss }),
405
- }))
406
- : null)),
407
- h(Section, { title: '调度计划' },
408
- h('div', { className: 'cb-grid' },
409
- h(Field, { label: 'cron 表达式(分 时 日 月 周)' },
410
- h('div', { style: { display: 'flex', gap: 6 } },
411
- 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;跟随宿主默认时由宿主解析当前默认' },
412
618
  h('select', {
413
619
  className: 'cb-select',
414
- value: '',
415
- 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 }),
416
622
  },
417
- h('option', { value: '' }, '预设'),
418
- CRON_PRESETS.map((preset) => h('option', { key: preset.value, value: preset.value }, preset.label)))),
419
- preview ? h('span', { className: 'cb-preview' },
420
- preview.summary + ';接下来 ' + preview.nextAt.map((at) => formatDateTime(at)).join(' / ')) : null),
421
- h(Field, { label: '超时(毫秒)' }, h('input', { type: 'number', value: form.timeoutMs, onChange: (e) => set({ timeoutMs: Number(e.target.value) }) })),
422
- 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),
423
642
  error ? h('div', { className: 'cb-error' }, error) : null,
424
643
  h('div', { className: 'cb-toolbar' },
425
644
  h('button', { className: 'cb-button', onClick: onDone }, '取消'),
@@ -437,8 +656,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
437
656
  h('span', { className: 'cb-name' }, job.name),
438
657
  h('span', { className: 'cb-badge' }, KIND_LABELS[job.kind] || job.kind),
439
658
  job.kind === 'session' && job.session ? h('span', { className: 'cb-badge' }, MODE_LABELS[job.session.mode]) : null,
440
- job.kind === 'session' && job.session && job.session.windowStart
441
- ? h('span', { className: 'cb-badge' }, job.session.windowStart + '-' + job.session.windowEnd) : null,
442
659
  h('div', { className: 'cb-actions', onClick: (event) => event.stopPropagation() },
443
660
  switchToggle({ checked: Boolean(job.enabled), onChange: onToggle }),
444
661
  h('button', { className: 'cb-button cb-button--primary cb-button--sm', disabled: !job.enabled, title: job.enabled ? '立即运行' : '已停用', onClick: onRun }, '运行'),
@@ -755,15 +972,25 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
755
972
  const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
756
973
  const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
757
974
 
758
- // 设置>插件页卡片:sidebarTab 选项;better-sidebar 在场可切换,否则仅展示禁用态
975
+ // 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
976
+ const CARD_TITLE = '定时任务'
977
+ const CARD_TOGGLE_LABEL = '看板移入 better-sidebar 侧边栏'
978
+ const readSidebarTabPref = (outcome) => outcome.ok && outcome.data &&
979
+ typeof outcome.data.ui === 'object' && outcome.data.ui !== null && outcome.data.ui.sidebarTab === true
980
+ const CHEVRON_DOWN = () => h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, fill: 'none',
981
+ stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
982
+ h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
983
+
759
984
  function CronBoardPluginCard() {
760
985
  const [enabled, setEnabled] = useState(null)
761
986
  const [busy, setBusy] = useState(false)
762
- const [sidebarReady, setSidebarReady] = useState(Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR)))
987
+ const [open, setOpen] = useState(false)
988
+ // 渲染时重探:侧栏面晚于设置页挂载时,enabled 到达的重渲染会自动纠正禁用态
989
+ const sidebarReady = Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR))
763
990
  useEffect(() => {
764
991
  let alive = true
765
992
  request('GET', 'status').then((outcome) => {
766
- if (alive) setEnabled(outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false)
993
+ if (alive) setEnabled(readSidebarTabPref(outcome))
767
994
  })
768
995
  return () => { alive = false }
769
996
  }, [])
@@ -774,20 +1001,29 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
774
1001
  if (outcome.ok && outcome.data && outcome.data.ui) setEnabled(outcome.data.ui.sidebarTab === true)
775
1002
  setBusy(false)
776
1003
  }
777
- return h('div', { className: 'cb-settings' },
778
- switchToggle({
779
- checked: enabled === true,
780
- disabled: busy || enabled === null || !sidebarReady,
781
- onChange: toggle,
782
- label: '看板移入 better-sidebar 侧边栏',
783
- }),
784
- h('div', { className: 'cb-settings__hint' },
785
- sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'),
786
- )
1004
+ return h('li', { className: 'cb-pc' + (open ? ' cb-pc--open' : '') },
1005
+ h('button', { type: 'button', className: 'cb-pc__head', 'aria-expanded': open,
1006
+ 'aria-label': (open ? '折叠' : '展开') + ': ' + CARD_TITLE, onClick: () => setOpen(!open) },
1007
+ h('span', { className: 'cb-pc__text' },
1008
+ h('span', { className: 'cb-pc__name' }, CARD_TITLE),
1009
+ h('span', { className: 'cb-pc__desc' }, '计划任务看板、环境变量与执行日志')),
1010
+ h('span', { className: 'cb-pc__chevron' }, h(CHEVRON_DOWN))),
1011
+ open ? h('div', { className: 'cb-pc__body' },
1012
+ h('div', { className: 'cb-pc__row' },
1013
+ h('div', { className: 'cb-pc__rowLine' },
1014
+ h('span', { className: 'cb-pc__rowLabel' }, CARD_TOGGLE_LABEL),
1015
+ switchToggle({
1016
+ checked: enabled === true,
1017
+ disabled: busy || enabled === null || !sidebarReady,
1018
+ onChange: toggle,
1019
+ ariaLabel: CARD_TOGGLE_LABEL,
1020
+ })),
1021
+ h('p', { className: 'cb-pc__hint' },
1022
+ sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
787
1023
  }
788
1024
 
789
- // better-sidebar tab:注册即单实例(single);v0.19.0 起注册只入「可打开」目录,
790
- // 须显式 openTab 落入底部工作台;wsModel 供会话任务选工作区
1025
+ // better-sidebar tab:注册即单实例(single);只注册不主动打开,
1026
+ // 打开由用户从侧边栏目录手动进入(注册即 openTab 会在每次页面加载时抢占当前视图)
791
1027
  function registerBoardTab(ctx, service, wsModel) {
792
1028
  return ctx.effect(() => {
793
1029
  const disposeTab = service.registerTab({
@@ -801,10 +1037,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
801
1037
  single: true,
802
1038
  component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
803
1039
  })
804
- // 无会话时 openTab 静默不落,tab 仍注册在册(新建标签页菜单可见)
805
- if (typeof service.openTab === 'function') {
806
- try { service.openTab({ type: TAB_ID }) } catch { /* 打开失败不回滚注册 */ }
807
- }
808
1040
  return disposeTab
809
1041
  }, 'cron-board sidebar tab')
810
1042
  }
@@ -958,12 +1190,10 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
958
1190
  const prefWatch = setInterval(async () => {
959
1191
  try {
960
1192
  const outcome = await request('GET', 'status')
961
- if (outcome.ok && outcome.data && typeof outcome.data.ui === 'object' && outcome.data.ui !== null) {
962
- const next = outcome.data.ui.sidebarTab === true
963
- if (next !== sidebarTabOn) {
964
- sidebarTabOn = next
965
- applyPref()
966
- }
1193
+ const next = readSidebarTabPref(outcome)
1194
+ if (next !== sidebarTabOn) {
1195
+ sidebarTabOn = next
1196
+ applyPref()
967
1197
  }
968
1198
  } catch { /* 轮询失败保形态不变,下轮重试 */ }
969
1199
  }, 5 * 1000)