@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/README.md +6 -6
- package/package.json +1 -1
- package/src/api.mjs +47 -18
- package/src/client.js +289 -92
- package/src/executor.mjs +10 -1
- package/src/index.js +7 -1
- package/src/schedule-builder.mjs +73 -0
- package/src/scheduler.mjs +15 -40
- package/src/session-driver.mjs +63 -27
- package/test/executor.test.mjs +44 -0
- package/test/index.test.mjs +22 -3
- package/test/parity.test.mjs +1 -1
- package/test/routes.test.mjs +98 -25
- package/test/schedule-builder.test.mjs +123 -0
- package/test/scheduler.test.mjs +47 -131
- package/test/session-driver.test.mjs +167 -31
- package/src/session-window.mjs +0 -37
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:
|
|
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:
|
|
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
|
|
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-
|
|
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-
|
|
169
|
-
.cb-section{display:flex;flex-direction:column;gap:var(--cb-space-
|
|
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-
|
|
173
|
-
.cb-field{display:flex;flex-direction:column;gap:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
361
|
-
h(
|
|
362
|
-
h('
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
h(Section, { title:
|
|
370
|
-
h(
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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: '
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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) =>
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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)
|
|
825
|
-
//
|
|
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)
|
|
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
|
-
|
|
218
|
+
// 生命周期钩子:scheduler 无挂起资源时 dispose 可缺省,可选调用保持约定统一
|
|
219
|
+
ctx.effect(() => () => schedulerRef.current.dispose?.(), 'cron-board scheduler lifecycle')
|
|
214
220
|
}
|