@mzzsfy/dsh-cron-board 0.1.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 ADDED
@@ -0,0 +1,949 @@
1
+ (() => {
2
+ // 定时任务看板 client 半区:设置页三 Tab(看板/环境变量/日志)+ 任务与变量的完整 CRUD。
3
+ // 无构建:createElement + 一次性样式注入;IIFE 书挡,顶层零词法声明(经典 script 全局词法环境跨 bundle 共享)。
4
+ // 开关一律 cb-switch(规约 switch 形态);状态色语义以 STATUS_META 单一来源,与 core status-meta.mjs 镜像(parity 锁定)。
5
+
6
+ /* LOGIC-BEGIN */
7
+ const STATUS_META = {
8
+ success: { label: '成功', tone: 'ok' },
9
+ fail: { label: '失败', tone: 'bad' },
10
+ timeout: { label: '超时', tone: 'warn' },
11
+ skipped: { label: '跳过', tone: 'mute' },
12
+ interrupted: { label: '中断', tone: 'mute' },
13
+ running: { label: '运行中', tone: 'run' },
14
+ }
15
+ const TRIGGER_META = { cron: { label: '定时' }, manual: { label: '手动' } }
16
+ const KIND_LABELS = { shell: '脚本', session: '会话' }
17
+ const MODE_LABELS = { fresh: '每次新建', pinned: '固定会话' }
18
+ const ONMISS_LABELS = { skip: '跳过', defer: '顺延' }
19
+ const STATUS_TONE_COLOR = { ok: '#22a06b', bad: '#e5484d', warn: '#f5a524', mute: '#8b8d98', run: '#3b82f6' }
20
+ const SECOND_MS = 1000
21
+ const MINUTE_MS = 60 * SECOND_MS
22
+ const HOUR_MS = 60 * MINUTE_MS
23
+ const DAY_MS = 24 * HOUR_MS
24
+ const PAD2 = (value) => String(value).padStart(2, '0')
25
+
26
+ // 相对时间人话:未来「x 后」过去「x 前」
27
+ function relativeTime(timestamp, now) {
28
+ if (typeof timestamp !== 'number' || !(timestamp > 0)) return '-'
29
+ const delta = timestamp - now
30
+ const abs = Math.abs(delta)
31
+ const suffix = delta >= 0 ? '后' : '前'
32
+ if (abs < MINUTE_MS) return Math.max(1, Math.round(abs / SECOND_MS)) + ' 秒' + suffix
33
+ if (abs < HOUR_MS) return Math.round(abs / MINUTE_MS) + ' 分钟' + suffix
34
+ if (abs < DAY_MS) return Math.round(abs / HOUR_MS) + ' 小时' + suffix
35
+ return Math.round(abs / DAY_MS) + ' 天' + suffix
36
+ }
37
+
38
+ function formatDateTime(timestamp) {
39
+ if (typeof timestamp !== 'number' || !(timestamp > 0)) return '-'
40
+ const date = new Date(timestamp)
41
+ return date.getFullYear() + '-' + PAD2(date.getMonth() + 1) + '-' + PAD2(date.getDate()) + ' ' + PAD2(date.getHours()) + ':' + PAD2(date.getMinutes())
42
+ }
43
+
44
+ function formatDuration(ms) {
45
+ if (typeof ms !== 'number' || !(ms >= 0)) return '-'
46
+ if (ms < SECOND_MS) return ms + 'ms'
47
+ if (ms < MINUTE_MS) return (ms / SECOND_MS).toFixed(1) + 's'
48
+ return Math.round(ms / MINUTE_MS) + 'min'
49
+ }
50
+
51
+ function statusMeta(status) {
52
+ return STATUS_META[status] || { label: status || '-', tone: 'mute' }
53
+ }
54
+ /* LOGIC-END */
55
+
56
+ const API_PREFIX = '/api/cron-board/'
57
+ const REFRESH_INTERVAL_MS = 30 * SECOND_MS
58
+ const TABS = [
59
+ { value: 'jobs', label: '看板' },
60
+ { value: 'envs', label: '环境变量' },
61
+ { value: 'logs', label: '日志' },
62
+ ]
63
+ const CRON_PRESETS = [
64
+ { label: '每天 08:30', value: '30 8 * * *' },
65
+ { label: '每天 22:00', value: '0 22 * * *' },
66
+ { label: '每 30 分钟', value: '*/30 * * * *' },
67
+ { label: '每 2 小时', value: '0 */2 * * *' },
68
+ { label: '工作日 09:00', value: '0 9 * * 1-5' },
69
+ { label: '每周一 09:00', value: '0 9 * * 1' },
70
+ { label: '每月 1 日 00:00', value: '0 0 1 * *' },
71
+ ]
72
+
73
+ const STYLE = `
74
+ /* 组件令牌层(形制对齐 dsh-im):宿主 dsw-alias 定义在 body 作用域,cb- 令牌必须在 body 声明才能解析到主题值;
75
+ fallback 取浅色中性(dsh-im 同款),深色主题由令牌接管,fallback 只兜裸宿主 */
76
+ body{
77
+ --cb-bg:var(--dsw-alias-bg-layer-1,#ffffff);
78
+ --cb-bg-sub:var(--dsw-alias-bg-layer-2,#f7f8fa);
79
+ --cb-bg-muted:var(--dsw-alias-bg-module-platform,#f2f3f5);
80
+ --cb-border:var(--dsw-alias-border-l2,#e5e6eb);
81
+ --cb-border-strong:var(--dsw-alias-border-l3,#dfe1e5);
82
+ --cb-text:var(--dsw-alias-label-primary,#1f2329);
83
+ --cb-text-sub:var(--dsw-alias-label-secondary,#646a73);
84
+ --cb-text-dim:var(--dsw-alias-label-tertiary,#8f959e);
85
+ --cb-accent:#1677ff;
86
+ --cb-accent-deep:#0958d9;
87
+ --cb-accent-wash:#eaf3ff;
88
+ --cb-danger:var(--dsw-alias-state-error-primary,#d54941);
89
+ --cb-ok:var(--dsw-alias-state-success-primary,#20a162);
90
+ --cb-warn:var(--dsw-alias-state-warn-primary,#d97706);
91
+ --cb-hover:var(--dsw-alias-interactive-bg-hover,#f7f8fa);
92
+ --cb-mask:var(--dsw-alias-bg-mask-1,rgba(31,35,41,.5));
93
+ --cb-shadow:0 1px 2px rgb(31 35 41 / 4%);
94
+ --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;
96
+ --cb-font-xs:12px;--cb-font-sm:12px;--cb-font-md:13px;--cb-font-lg:15px;
97
+ --cb-control-h:34px;
98
+ }
99
+ .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
+ .cb-toolbar{display:flex;align-items:center;gap:var(--cb-space-3);flex-wrap:wrap}
101
+ .cb-tabs{display:flex;gap:var(--cb-space-1);background:var(--cb-bg-muted);border-radius:999px;padding:3px}
102
+ .cb-pill{border:none;border-radius:999px;padding:5px 14px;font-size:var(--cb-font-md);background:transparent;color:var(--cb-text-sub);cursor:pointer;font-weight:560;transition:color .15s,background .15s}
103
+ .cb-pill:hover{color:var(--cb-text)}
104
+ .cb-pill--on{background:var(--cb-bg);color:var(--cb-text);box-shadow:var(--cb-shadow)}
105
+ .cb-pill--on:hover{color:var(--cb-text)}
106
+ .cb-spacer{flex:1}
107
+ .cb-button{min-height:var(--cb-control-h);display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--cb-border-strong);border-radius:var(--cb-radius-md);padding:0 13px;font:inherit;font-size:var(--cb-font-md);font-weight:560;background:var(--cb-bg);color:var(--cb-text);cursor:pointer;transition:border-color .15s,background .15s,color .15s}
108
+ .cb-button:hover:not(:disabled){border-color:#aeb3bb;background:var(--cb-hover)}
109
+ .cb-button:disabled{cursor:not-allowed;opacity:.55}
110
+ .cb-button--primary{background:var(--cb-accent);border-color:var(--cb-accent);color:#fff}
111
+ .cb-button--primary:hover:not(:disabled){background:var(--cb-accent-deep);border-color:var(--cb-accent-deep)}
112
+ .cb-button--danger{color:var(--cb-danger)}
113
+ .cb-button--danger:hover:not(:disabled){border-color:var(--cb-danger);background:var(--cb-danger);color:#fff}
114
+ .cb-button--sm{min-height:28px;padding:0 10px;font-size:var(--cb-font-sm)}
115
+ .cb-button:focus-visible,.cb-icon:focus-visible,.cb-pill:focus-visible,.cb-select:focus-visible,.cb-entry:focus-visible{outline:2px solid color-mix(in srgb, var(--cb-accent) 70%, white);outline-offset:2px}
116
+ .cb-button:active:not(:disabled){transform:translateY(1px)}
117
+ .cb-cards{display:flex;flex-direction:column;gap:var(--cb-space-3)}
118
+ .cb-card{position:relative;display:flex;flex-direction:column;gap:var(--cb-space-2);border:1px solid var(--cb-border);border-radius:var(--cb-radius-lg);padding:var(--cb-space-4) var(--cb-space-5);cursor:pointer;overflow:hidden;background:var(--cb-bg);box-shadow:var(--cb-shadow);transition:border-color .15s,box-shadow .15s}
119
+ .cb-card:hover{border-color:var(--cb-accent);box-shadow:0 2px 8px rgb(31 35 41 / 8%)}
120
+ .cb-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3.5px;background:var(--cb-status,#aeb3bb)}
121
+ .cb-card-row{display:flex;align-items:center;gap:var(--cb-space-3);flex-wrap:wrap}
122
+ .cb-name{font-size:14px;font-weight:600;color:var(--cb-text)}
123
+ .cb-badge{min-height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:999px;color:var(--cb-text-sub);background:var(--cb-bg-muted);font-size:var(--cb-font-sm);white-space:nowrap}
124
+ .cb-dot{width:8px;height:8px;border-radius:50%;background:var(--cb-status,#aeb3bb);flex:none}
125
+ .cb-meta{font-size:var(--cb-font-sm);color:var(--cb-text-sub)}
126
+ .cb-actions{display:flex;align-items:center;gap:var(--cb-space-2);margin-left:auto}
127
+ .cb-icon{min-height:28px;border:none;background:transparent;color:var(--cb-text-sub);cursor:pointer;font-size:var(--cb-font-sm);padding:0 8px;border-radius:var(--cb-radius-sm);transition:background .15s,color .15s}
128
+ .cb-icon:hover{background:var(--cb-hover);color:var(--cb-text)}
129
+ .cb-switch{position:relative;display:inline-flex;align-items:center;cursor:pointer;gap:var(--cb-space-2)}
130
+ .cb-switch input[type="checkbox"] { position:absolute;opacity:0;width:1px;height:1px }
131
+ .cb-switch__track{width:30px;height:17px;border-radius:999px;background:#aeb3bb;position:relative;transition:background .15s;flex:none}
132
+ .cb-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.3)}
133
+ .cb-switch input[type="checkbox"]:checked + .cb-switch__track{background:var(--cb-accent)}
134
+ .cb-switch input[type="checkbox"]:checked + .cb-switch__track .cb-switch__thumb{left:15px}
135
+ .cb-switch input[type="checkbox"]:focus-visible + .cb-switch__track{outline:2px solid color-mix(in srgb, var(--cb-accent) 70%, white);outline-offset:1px}
136
+ .cb-switch input[type="checkbox"]:disabled + .cb-switch__track{opacity:.4;cursor:not-allowed}
137
+ .cb-switch:not(:has(input[type="checkbox"]:disabled)):hover .cb-switch__track{background:var(--cb-accent-deep);opacity:.85}
138
+ .cb-switch input[type="checkbox"]:checked:not(:disabled) + .cb-switch__track:hover{background:var(--cb-accent-deep)}
139
+ .cb-table{display:flex;flex-direction:column;gap:var(--cb-space-1);font-size:var(--cb-font-md)}
140
+ .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}
141
+ .cb-row:hover{background:var(--cb-hover)}
142
+ .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%}
143
+ .cb-mask{color:var(--cb-text-sub)}
144
+ .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}
145
+ .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}
146
+ @keyframes cb-fade-in{from{opacity:0}to{opacity:1}}
147
+ @keyframes cb-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
148
+ @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}}
149
+ .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)}
150
+ .cb-modal-title{font-size:16px;font-weight:600}
151
+ .cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-5)}
152
+ .cb-section{display:flex;flex-direction:column;gap:var(--cb-space-3)}
153
+ .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)}
154
+ .cb-section-title::before{content:'';width:3px;height:12px;border-radius:2px;background:var(--cb-accent)}
155
+ .cb-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--cb-space-4) var(--cb-space-5)}
156
+ .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)}
157
+ .cb-field--wide{grid-column:1 / -1}
158
+ .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}
159
+ .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)}
160
+ .cb-field textarea{min-height:72px;resize:vertical;font-family:inherit}
161
+ .cb-hint{font-size:var(--cb-font-xs);color:var(--cb-text-dim)}
162
+ .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)}
163
+ .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)}
164
+ .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}
165
+ .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)}
166
+ .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)}
167
+ .cb-confirm-text{font-size:var(--cb-font-md);color:var(--cb-text);line-height:1.5}
168
+ .cb-log::-webkit-scrollbar,.cb-modal::-webkit-scrollbar{width:8px;height:8px}
169
+ .cb-log::-webkit-scrollbar-thumb,.cb-modal::-webkit-scrollbar-thumb{background:var(--cb-border-strong);border-radius:999px}
170
+ .cb-log::-webkit-scrollbar-track,.cb-modal::-webkit-scrollbar-track{background:transparent}
171
+ /* 容器查询:better-sidebar tab 与主视图宽度差异大,跟随面板自身宽度而非视口 */
172
+ @container (max-width: 560px){
173
+ .cb-grid{grid-template-columns:1fr}
174
+ .cb-card-row .cb-actions{margin-left:0;width:100%;justify-content:flex-end;flex-wrap:wrap}
175
+ .cb-view{padding:var(--cb-space-4)}
176
+ .cb-panel{gap:var(--cb-space-4)}
177
+ }
178
+ .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}
179
+ .cb-entry:hover{background:var(--cb-hover)}
180
+ .cb-entry[data-active="true"]{background:var(--cb-accent);color:#fff}
181
+ .cb-entry-icon{display:inline-flex;flex:none}
182
+ .cb-view{display:none;flex:1;min-height:0;flex-direction:column;padding:var(--cb-space-6);overflow:auto;background:var(--dsw-alias-bg-base,var(--cb-bg))}
183
+ .cb-view > .cb-panel{margin:0 auto;width:100%}
184
+ html[data-cb-board-active] [data-cb-view]{display:flex}
185
+ html[data-cb-board-active] [data-pane="conversation"] > :not([data-cb-view]){display:none !important}
186
+ html[data-cb-board-active] [class*="centerCol"] > :not([data-cb-view]){display:none !important}
187
+ html[data-cb-board-active] .dshDesktopConversationSurface > :not([data-cb-view]){display:none !important}
188
+ `
189
+
190
+ // 空模块兜底:react 缺席即整面板禁用(宿主必有 react,防御性)
191
+ function noopFactory() {
192
+ return { inject: [], apply() {} }
193
+ }
194
+
195
+ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
196
+ window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-cron-board', factory })
197
+
198
+ function factory(require) {
199
+ let React = null
200
+ let ReactDOMClient = null
201
+ try {
202
+ React = require('react')
203
+ ReactDOMClient = require('react-dom/client')
204
+ } catch {
205
+ return noopFactory()
206
+ }
207
+ const { useState, useEffect, useRef, useCallback } = React
208
+ const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
209
+
210
+ // 数据请求:统一 envelope,error 透传中文业务文案
211
+ async function request(method, endpoint, body) {
212
+ let response
213
+ try {
214
+ response = await fetch(API_PREFIX + endpoint, {
215
+ method,
216
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
217
+ body: body === undefined ? undefined : JSON.stringify(body),
218
+ })
219
+ } catch (error) {
220
+ return { ok: false, error: String(error && error.message || error) }
221
+ }
222
+ let json = null
223
+ try { json = await response.json() } catch { json = null }
224
+ if (!response.ok) return { ok: false, error: json && json.error ? json.error : '请求失败(' + response.status + ')' }
225
+ return { ok: true, data: json }
226
+ }
227
+
228
+ function ensureStyle(document) {
229
+ const existing = document.getElementById('dsh-cron-board-style')
230
+ if (existing) return
231
+ const style = document.createElement('style')
232
+ style.id = 'dsh-cron-board-style'
233
+ style.textContent = STYLE
234
+ document.head.appendChild(style)
235
+ }
236
+
237
+ // 规约 switch:input 锚定状态选择器,视觉由 track+thumb 呈现
238
+ function switchToggle(props) {
239
+ return h('label', { className: 'cb-switch' },
240
+ h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange }),
241
+ h('span', { className: 'cb-switch__track' }, h('span', { className: 'cb-switch__thumb' })),
242
+ props.label ? h('span', { className: 'cb-meta' }, props.label) : null)
243
+ }
244
+
245
+ function PillGroup({ options, value, onChange }) {
246
+ return h('div', { className: 'cb-tabs' },
247
+ options.map((option) => h('button', {
248
+ key: option.value,
249
+ className: 'cb-pill' + (option.value === value ? ' cb-pill--on' : ''),
250
+ onClick: () => onChange(option.value),
251
+ }, option.label)))
252
+ }
253
+
254
+ function Field({ label, hint, wide, children }) {
255
+ return h('div', { className: 'cb-field' + (wide ? ' cb-field--wide' : '') },
256
+ h('span', null, label),
257
+ children,
258
+ hint ? h('span', { className: 'cb-hint' }, hint) : null)
259
+ }
260
+
261
+ function Modal({ title, onClose, children, width }) {
262
+ const bodyRef = useRef(null)
263
+ useEffect(() => {
264
+ const onKey = (event) => { if (event.key === 'Escape') onClose() }
265
+ document.addEventListener('keydown', onKey)
266
+ const first = bodyRef.current && bodyRef.current.querySelector('input, textarea, select')
267
+ if (first) first.focus()
268
+ return () => document.removeEventListener('keydown', onKey)
269
+ }, [])
270
+ return h('div', { className: 'cb-modal-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } },
271
+ h('div', { className: 'cb-modal', style: width ? { width: 'min(' + width + 'px, 92vw)' } : null },
272
+ h('div', { className: 'cb-modal-head' },
273
+ h('span', { className: 'cb-modal-title' }, title),
274
+ h('button', { className: 'cb-icon', 'aria-label': '关闭', onClick: onClose }, '✕')),
275
+ h('div', { className: 'cb-modal-body', ref: bodyRef }, children)))
276
+ }
277
+
278
+ // 节标题:表单分区渐进披露(基础/执行/调度)
279
+ function Section({ title, children }) {
280
+ return h('div', { className: 'cb-section' },
281
+ h('div', { className: 'cb-section-title' }, title),
282
+ children)
283
+ }
284
+
285
+ // —— 任务表单(新建/编辑)——
286
+ function JobForm({ job, onDone, wsModel }) {
287
+ // 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
288
+ const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
289
+ useEffect(() => {
290
+ if (!wsModel) return undefined
291
+ const update = () => setWsItems(wsModel.getSnapshot().items || [])
292
+ update()
293
+ return wsModel.subscribe(update)
294
+ }, [wsModel])
295
+ const editing = Boolean(job && job.id)
296
+ const [form, setForm] = useState(() => ({
297
+ name: job ? job.name : '',
298
+ kind: job ? job.kind : 'shell',
299
+ command: job ? job.command : '',
300
+ prompt: job ? job.prompt : '',
301
+ workdir: job ? job.workdir : '',
302
+ schedule: job ? job.schedule : '0 9 * * *',
303
+ timeoutMs: job ? job.timeoutMs : 60 * 60 * 1000,
304
+ concurrency: job && job.concurrency ? String(job.concurrency) : '',
305
+ enabled: job ? job.enabled : true,
306
+ session: {
307
+ mode: job && job.session ? job.session.mode : 'fresh',
308
+ pinnedSessionId: job && job.session ? job.session.pinnedSessionId : '',
309
+ windowStart: job && job.session ? job.session.windowStart : '',
310
+ windowEnd: job && job.session ? job.session.windowEnd : '',
311
+ onMiss: job && job.session ? job.session.onMiss : 'skip',
312
+ },
313
+ }))
314
+ const [preview, setPreview] = useState(null)
315
+ const [error, setError] = useState(null)
316
+ const [saving, setSaving] = useState(false)
317
+ const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
318
+ const setSession = (patch) => setForm((prev) => ({ ...prev, session: { ...prev.session, ...patch } }))
319
+
320
+ useEffect(() => {
321
+ let alive = true
322
+ const timer = setTimeout(async () => {
323
+ const outcome = await request('POST', 'cron/preview', { schedule: form.schedule })
324
+ if (alive) setPreview(outcome.ok ? outcome.data : null)
325
+ }, 300)
326
+ return () => { alive = false; clearTimeout(timer) }
327
+ }, [form.schedule])
328
+
329
+ const submit = async () => {
330
+ if (saving) return
331
+ setSaving(true)
332
+ setError(null)
333
+ const payload = { ...form }
334
+ payload.concurrency = form.concurrency === '' ? undefined : Number(form.concurrency)
335
+ payload.session = form.kind === 'session' ? form.session : undefined
336
+ const outcome = await request(editing ? 'PATCH' : 'POST', editing ? 'jobs/' + job.id : 'jobs', payload)
337
+ setSaving(false)
338
+ if (outcome.ok) onDone()
339
+ else setError(outcome.error)
340
+ }
341
+
342
+ return h(Modal, { title: editing ? '编辑任务' : '新建任务', onClose: onDone, width: 720 },
343
+ h(Section, { title: '基础信息' },
344
+ h('div', { className: 'cb-grid' },
345
+ h(Field, { label: '名称' }, h('input', { type: 'text', value: form.name, onChange: (e) => set({ name: e.target.value }) })),
346
+ h(Field, { label: '类型' }, h(PillGroup, {
347
+ options: [{ value: 'shell', label: KIND_LABELS.shell }, { value: 'session', label: KIND_LABELS.session }],
348
+ value: form.kind,
349
+ onChange: (kind) => set({ kind }),
350
+ })))),
351
+ h(Section, { title: form.kind === 'shell' ? '执行配置(shell)' : '执行配置(会话)' },
352
+ h('div', { className: 'cb-grid' },
353
+ form.kind === 'shell'
354
+ ? h(Field, { label: '命令', wide: true, hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
355
+ h('input', { type: 'text', value: form.command, onChange: (e) => set({ command: e.target.value }) }))
356
+ : null,
357
+ form.kind === 'shell'
358
+ ? h(Field, { label: '工作目录(可空)' }, h('input', { type: 'text', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) }))
359
+ : null,
360
+ form.kind === 'session'
361
+ ? h(Field, {
362
+ label: '工作区(会话 cwd,可空)',
363
+ hint: wsItems.length > 0 ? '从宿主工作区选择,或留空使用默认目录' : '留空使用宿主默认目录;填入路径则会话在该目录下创建',
364
+ },
365
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
366
+ wsItems.length > 0
367
+ ? h('select', {
368
+ className: 'cb-select',
369
+ value: wsItems.some((w) => w.path === form.workdir) ? form.workdir : '',
370
+ onChange: (e) => set({ workdir: e.target.value }),
371
+ },
372
+ h('option', { value: '' }, '默认目录'),
373
+ wsItems.map((w) => h('option', { key: w.path || w.workspaceId, value: w.path }, w.title || w.path)))
374
+ : null,
375
+ h('input', { type: 'text', placeholder: '/path/to/workspace', value: form.workdir, onChange: (e) => set({ workdir: e.target.value }) })))
376
+ : null,
377
+ form.kind === 'session'
378
+ ? h(Field, { label: '任务文本', wide: true, hint: '投递给会话的任务内容,环境变量会折叠在文本前部' },
379
+ h('textarea', { value: form.prompt, onChange: (e) => set({ prompt: e.target.value }) }))
380
+ : null,
381
+ form.kind === 'session'
382
+ ? h(Field, { label: '会话模式' }, h(PillGroup, {
383
+ options: [{ value: 'fresh', label: MODE_LABELS.fresh }, { value: 'pinned', label: MODE_LABELS.pinned }],
384
+ value: form.session.mode,
385
+ onChange: (mode) => setSession({ mode }),
386
+ }))
387
+ : null,
388
+ form.kind === 'session' && form.session.mode === 'pinned'
389
+ ? h(Field, { label: '固定会话 ID(可空,首跑自动绑定)' },
390
+ h('input', { type: 'text', value: form.session.pinnedSessionId, onChange: (e) => setSession({ pinnedSessionId: e.target.value }) }))
391
+ : null,
392
+ form.kind === 'session'
393
+ ? h(Field, { label: '允许时段起(可空)' }, h('input', { type: 'text', placeholder: '09:00', value: form.session.windowStart, onChange: (e) => setSession({ windowStart: e.target.value }) }))
394
+ : null,
395
+ form.kind === 'session'
396
+ ? h(Field, { label: '允许时段止(可空)' }, h('input', { type: 'text', placeholder: '23:00', value: form.session.windowEnd, onChange: (e) => setSession({ windowEnd: e.target.value }) }))
397
+ : null,
398
+ form.kind === 'session'
399
+ ? h(Field, { label: '窗口外策略' }, h(PillGroup, {
400
+ options: [{ value: 'skip', label: ONMISS_LABELS.skip }, { value: 'defer', label: ONMISS_LABELS.defer }],
401
+ value: form.session.onMiss,
402
+ onChange: (onMiss) => setSession({ onMiss }),
403
+ }))
404
+ : null)),
405
+ h(Section, { title: '调度计划' },
406
+ h('div', { className: 'cb-grid' },
407
+ h(Field, { label: 'cron 表达式(分 时 日 月 周)' },
408
+ h('div', { style: { display: 'flex', gap: 6 } },
409
+ h('input', { type: 'text', value: form.schedule, onChange: (e) => set({ schedule: e.target.value }) }),
410
+ h('select', {
411
+ className: 'cb-select',
412
+ value: '',
413
+ onChange: (e) => { if (e.target.value) set({ schedule: e.target.value }) },
414
+ },
415
+ h('option', { value: '' }, '预设'),
416
+ CRON_PRESETS.map((preset) => h('option', { key: preset.value, value: preset.value }, preset.label)))),
417
+ preview ? h('span', { className: 'cb-preview' },
418
+ preview.summary + ';接下来 ' + preview.nextAt.map((at) => formatDateTime(at)).join(' / ')) : null),
419
+ h(Field, { label: '超时(毫秒)' }, h('input', { type: 'number', value: form.timeoutMs, onChange: (e) => set({ timeoutMs: Number(e.target.value) }) })),
420
+ h(Field, { label: '并发上限(可空)' }, h('input', { type: 'number', value: form.concurrency, onChange: (e) => set({ concurrency: e.target.value }) })))),
421
+ error ? h('div', { className: 'cb-error' }, error) : null,
422
+ h('div', { className: 'cb-toolbar' },
423
+ h('button', { className: 'cb-button', onClick: onDone }, '取消'),
424
+ h('div', { className: 'cb-spacer' }),
425
+ switchToggle({ checked: form.enabled, onChange: (e) => set({ enabled: e.target.checked }), label: '启用' }),
426
+ h('button', { className: 'cb-button cb-button--primary', disabled: saving, onClick: submit }, saving ? '保存中…' : '保存')))
427
+ }
428
+
429
+ function JobCard({ job, now, onToggle, onRun, onEdit, onDelete }) {
430
+ const meta = statusMeta(job.lastStatus)
431
+ const style = { '--cb-status': STATUS_TONE_COLOR[meta.tone] }
432
+ return h('div', { className: 'cb-card', style, role: 'button', tabIndex: 0, onClick: onEdit },
433
+ h('div', { className: 'cb-card-row' },
434
+ h('span', { className: 'cb-dot' }),
435
+ h('span', { className: 'cb-name' }, job.name),
436
+ h('span', { className: 'cb-badge' }, KIND_LABELS[job.kind] || job.kind),
437
+ job.kind === 'session' && job.session ? h('span', { className: 'cb-badge' }, MODE_LABELS[job.session.mode]) : null,
438
+ job.kind === 'session' && job.session && job.session.windowStart
439
+ ? h('span', { className: 'cb-badge' }, job.session.windowStart + '-' + job.session.windowEnd) : null,
440
+ h('div', { className: 'cb-actions', onClick: (event) => event.stopPropagation() },
441
+ switchToggle({ checked: Boolean(job.enabled), onChange: onToggle }),
442
+ h('button', { className: 'cb-button cb-button--primary cb-button--sm', disabled: !job.enabled, title: job.enabled ? '立即运行' : '已停用', onClick: onRun }, '运行'),
443
+ h('button', { className: 'cb-icon', title: '编辑', onClick: onEdit }, '编辑'),
444
+ h('button', { className: 'cb-icon cb-button--danger', title: '删除', onClick: onDelete }, '删除'))),
445
+ h('div', { className: 'cb-card-row' },
446
+ h('span', { className: 'cb-meta' }, job.lastStatus ? meta.label : '未运行'),
447
+ h('span', { className: 'cb-meta' }, job.summary || job.schedule),
448
+ typeof job.nextRunAt === 'number' && job.enabled ? h('span', { className: 'cb-meta' }, '下次 ' + relativeTime(job.nextRunAt, now)) : null,
449
+ typeof job.lastDurationMs === 'number' ? h('span', { className: 'cb-meta' }, '耗时 ' + formatDuration(job.lastDurationMs)) : null),
450
+ h('div', { className: 'cb-code' }, job.kind === 'session' ? job.prompt : job.command))
451
+ }
452
+
453
+ // 应用内删除确认(替换 window.confirm:风格统一 + ESC 可取消)
454
+ function ConfirmDialog({ message, onConfirm, onCancel }) {
455
+ return h(Modal, { title: '删除确认', onClose: onCancel, width: 420 },
456
+ h('div', { className: 'cb-confirm-text' }, message),
457
+ h('div', { className: 'cb-toolbar' },
458
+ h('div', { className: 'cb-spacer' }),
459
+ h('button', { className: 'cb-button', onClick: onCancel }, '取消'),
460
+ h('button', { className: 'cb-button cb-button--danger', onClick: onConfirm }, '删除')))
461
+ }
462
+
463
+ function JobsTab({ jobs, status, now, reload, wsModel }) {
464
+ const [formJob, setFormJob] = useState(null)
465
+ const [notice, setNotice] = useState(null)
466
+ const [kindFilter, setKindFilter] = useState('all')
467
+ const [pendingDelete, setPendingDelete] = useState(null)
468
+
469
+ const runJob = async (job) => {
470
+ const outcome = await request('POST', 'jobs/' + job.id + '/run')
471
+ setNotice(outcome.ok ? '已发起 ' + outcome.data.runIds.length + ' 个运行' : outcome.error)
472
+ setTimeout(reload, 300)
473
+ }
474
+ const toggleJob = async (job) => {
475
+ await request('PATCH', 'jobs/' + job.id, { enabled: !job.enabled })
476
+ reload()
477
+ }
478
+ const deleteJob = async (job) => {
479
+ await request('DELETE', 'jobs/' + job.id)
480
+ setPendingDelete(null)
481
+ setNotice('已删除「' + job.name + '」')
482
+ reload()
483
+ }
484
+
485
+ const visible = jobs.filter((job) => kindFilter === 'all' || job.kind === kindFilter)
486
+ .sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0))
487
+ return h('div', { className: 'cb-panel' },
488
+ h('div', { className: 'cb-toolbar' },
489
+ h(PillGroup, {
490
+ options: [{ value: 'all', label: '全部' }, { value: 'shell', label: KIND_LABELS.shell }, { value: 'session', label: KIND_LABELS.session }],
491
+ value: kindFilter,
492
+ onChange: setKindFilter,
493
+ }),
494
+ h('div', { className: 'cb-spacer' }),
495
+ status && !status.timerRunning ? h('span', { className: 'cb-badge', style: { color: 'var(--cb-danger)' } }, status.timerReason || '调度停用') : null,
496
+ h('button', { className: 'cb-button cb-button--primary', onClick: () => setFormJob({}) }, '新建任务')),
497
+ notice ? h('div', { className: 'cb-hint' }, notice) : null,
498
+ visible.length === 0
499
+ ? h('div', { className: 'cb-empty' },
500
+ h('div', null, kindFilter === 'all' ? '还没有定时任务' : '该类型下暂无任务'),
501
+ h('button', { className: 'cb-button', onClick: () => setFormJob({}) }, '新建任务'))
502
+ : h('div', { className: 'cb-cards' },
503
+ visible.map((job) => h(JobCard, {
504
+ key: job.id, job, now,
505
+ onToggle: () => toggleJob(job),
506
+ onRun: () => runJob(job),
507
+ onEdit: () => setFormJob(job),
508
+ onDelete: () => setPendingDelete(job),
509
+ }))),
510
+ pendingDelete ? h(ConfirmDialog, {
511
+ message: '删除任务「' + pendingDelete.name + '」?运行记录与日志一并清除,操作不可撤销。',
512
+ onConfirm: () => deleteJob(pendingDelete),
513
+ onCancel: () => setPendingDelete(null),
514
+ }) : null,
515
+ formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, onDone: () => { setFormJob(null); reload() } }) : null)
516
+ }
517
+
518
+ // —— 环境变量 Tab ——
519
+ // —— 环境变量表单(新建/编辑)——
520
+ // 编辑经明文单条接口回填:列表接口的 value 已打码,直接回填保存会覆盖真实值
521
+ function EnvForm({ row, onDone }) {
522
+ const editing = Boolean(row && row.id)
523
+ const [form, setForm] = useState(() => ({
524
+ name: row ? row.name : '',
525
+ value: row ? row.value : '',
526
+ remarks: row ? row.remarks : '',
527
+ multi: row ? row.multi : false,
528
+ enabled: row ? row.enabled : true,
529
+ }))
530
+ const [error, setError] = useState(null)
531
+ useEffect(() => {
532
+ if (!editing) return
533
+ let alive = true
534
+ request('GET', 'envs/' + row.id).then((outcome) => {
535
+ if (!alive) return
536
+ if (outcome.ok) setForm((prev) => ({ ...prev, value: outcome.data.value }))
537
+ else setError('读取变量明文失败:' + outcome.error)
538
+ })
539
+ return () => { alive = false }
540
+ }, [editing, row && row.id])
541
+ const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
542
+ const submit = async () => {
543
+ setError(null)
544
+ const payload = { ...form }
545
+ if (form.multi) payload.values = form.value.split('\n').map((line) => line.trim()).filter(Boolean)
546
+ const outcome = await request(editing ? 'PATCH' : 'POST', editing ? 'envs/' + row.id : 'envs', payload)
547
+ if (outcome.ok) onDone()
548
+ else setError(outcome.error)
549
+ }
550
+ return h(Modal, { title: editing ? '编辑变量' : '新建变量', onClose: onDone, width: 520 },
551
+ h('div', { className: 'cb-grid' },
552
+ h(Field, { label: '名称' }, h('input', { type: 'text', value: form.name, onChange: (e) => set({ name: e.target.value }) })),
553
+ h(Field, { label: '备注(可空)' }, h('input', { type: 'text', value: form.remarks, onChange: (e) => set({ remarks: e.target.value }) })),
554
+ h(Field, { label: '值', wide: true, hint: '勾选多值后每行一个值,运行时按同名多值展开' },
555
+ form.multi
556
+ ? h('textarea', { value: form.value, onChange: (e) => set({ value: e.target.value }) })
557
+ : h('input', { type: 'text', value: form.value, onChange: (e) => set({ value: e.target.value }) })),
558
+ h('div', { className: 'cb-field' }, switchToggle({ checked: form.multi, onChange: (e) => set({ multi: e.target.checked }), label: '多值' })),
559
+ ),
560
+ error ? h('div', { className: 'cb-error' }, error) : null,
561
+ h('div', { className: 'cb-toolbar' },
562
+ h('button', { className: 'cb-button', onClick: onDone }, '取消'),
563
+ h('div', { className: 'cb-spacer' }),
564
+ switchToggle({ checked: form.enabled, onChange: (e) => set({ enabled: e.target.checked }), label: '启用' }),
565
+ h('button', { className: 'cb-button cb-button--primary', onClick: submit }, '保存')))
566
+ }
567
+
568
+ function ImportForm({ onDone, reload }) {
569
+ const [text, setText] = useState('')
570
+ const [preview, setPreview] = useState(null)
571
+ const [error, setError] = useState(null)
572
+ const doPreview = async () => {
573
+ const outcome = await request('POST', 'envs/import', { text, apply: false })
574
+ if (outcome.ok) setPreview(outcome.data)
575
+ else setError(outcome.error)
576
+ }
577
+ const doApply = async (overwrite) => {
578
+ const outcome = await request('POST', 'envs/import', { text, apply: true, overwrite })
579
+ if (outcome.ok) { onDone(); reload() } else setError(outcome.error)
580
+ }
581
+ return h(Modal, { title: '导入环境变量', onClose: onDone, width: 560 },
582
+ h('div', { className: 'cb-field cb-field--wide' },
583
+ h('textarea', { style: { minHeight: '120px' }, placeholder: 'NAME=value #备注', value: text, onChange: (e) => setText(e.target.value) })),
584
+ preview ? h('div', { className: 'cb-preview' },
585
+ '解析 ' + preview.parsed.length + ' 条,非法 ' + preview.invalid + ' 条') : null,
586
+ error ? h('div', { className: 'cb-error' }, error) : null,
587
+ h('div', { className: 'cb-toolbar' },
588
+ h('button', { className: 'cb-button', onClick: onDone }, '取消'),
589
+ h('div', { className: 'cb-spacer' }),
590
+ h('button', { className: 'cb-button', onClick: doPreview }, '预览'),
591
+ h('button', { className: 'cb-button', onClick: () => doApply(false) }, '追加导入'),
592
+ h('button', { className: 'cb-button cb-button--danger', onClick: () => doApply(true) }, '覆盖导入')))
593
+ }
594
+
595
+ function EnvsTab({ envs, reload }) {
596
+ const [formRow, setFormRow] = useState(null)
597
+ const [importing, setImporting] = useState(false)
598
+ const [exportText, setExportText] = useState(null)
599
+ const [notice, setNotice] = useState(null)
600
+
601
+ const toggleEnv = async (row) => {
602
+ await request('PATCH', 'envs/' + row.id, { enabled: !row.enabled })
603
+ reload()
604
+ }
605
+ const deleteEnv = async (row) => {
606
+ if (!window.confirm('删除变量「' + row.name + '」?')) return
607
+ await request('DELETE', 'envs/' + row.id)
608
+ reload()
609
+ }
610
+ const doExport = async () => {
611
+ const outcome = await request('GET', 'envs/export')
612
+ if (outcome.ok) setExportText(outcome.data.text)
613
+ }
614
+
615
+ return h('div', { className: 'cb-panel' },
616
+ h('div', { className: 'cb-toolbar' },
617
+ h('div', { className: 'cb-spacer' }),
618
+ h('button', { className: 'cb-button', onClick: doExport }, '导出'),
619
+ h('button', { className: 'cb-button', onClick: () => setImporting(true) }, '导入'),
620
+ h('button', { className: 'cb-button cb-button--primary', onClick: () => setFormRow({}) }, '新建变量')),
621
+ notice ? h('div', { className: 'cb-hint' }, notice) : null,
622
+ envs.length === 0 ? h('div', { className: 'cb-empty' }, '暂无环境变量') :
623
+ h('div', { className: 'cb-table' },
624
+ envs.map((row) => h('div', { key: row.id, className: 'cb-row' },
625
+ switchToggle({ checked: Boolean(row.enabled), onChange: () => toggleEnv(row) }),
626
+ h('span', { className: 'cb-code' }, row.name),
627
+ h('span', { className: 'cb-code cb-mask' }, row.value),
628
+ row.remarks ? h('span', { className: 'cb-meta' }, '# ' + row.remarks) : null,
629
+ row.multi ? h('span', { className: 'cb-badge' }, '多值') : null,
630
+ h('div', { className: 'cb-actions' },
631
+ h('button', { className: 'cb-icon', onClick: () => setFormRow(row) }, '编辑'),
632
+ h('button', { className: 'cb-icon cb-button--danger', onClick: () => deleteEnv(row) }, '删除'))))),
633
+ formRow ? h(EnvForm, { row: formRow.id ? formRow : null, onDone: () => { setFormRow(null); reload() } }) : null,
634
+ importing ? h(ImportForm, { onDone: () => setImporting(false), reload }) : null,
635
+ exportText !== null ? h(Modal, { title: '导出(仅启用行)', onClose: () => setExportText(null) },
636
+ h('div', { className: 'cb-field cb-field--wide' }, h('textarea', { readOnly: true, value: exportText, style: { minHeight: '160px' } })),
637
+ h('div', { className: 'cb-toolbar' },
638
+ h('button', { className: 'cb-button', onClick: () => { setNotice(null); navigator.clipboard && navigator.clipboard.writeText(exportText).then(() => setNotice('已复制到剪贴板')) } }, '复制'),
639
+ h('div', { className: 'cb-spacer' }),
640
+ h('button', { className: 'cb-button', onClick: () => setExportText(null) }, '关闭'))) : null)
641
+ }
642
+
643
+ // —— 日志 Tab ——
644
+ function LogsTab({ jobs, reload, reloadFlag }) {
645
+ const [jobId, setJobId] = useState(jobs.length > 0 ? jobs[0].id : null)
646
+ const [runs, setRuns] = useState([])
647
+ const [logText, setLogText] = useState(null)
648
+ const [logRunId, setLogRunId] = useState(null)
649
+
650
+ useEffect(() => {
651
+ let alive = true
652
+ if (!jobId) { setRuns([]); return undefined }
653
+ request('GET', 'runs?jobId=' + encodeURIComponent(jobId)).then((outcome) => {
654
+ if (alive && outcome.ok) setRuns(outcome.data.items)
655
+ })
656
+ return () => { alive = false }
657
+ }, [jobId, reloadFlag])
658
+
659
+ const openLog = async (run) => {
660
+ const outcome = await request('GET', 'runs/' + run.runId + '/log')
661
+ if (outcome.ok) { setLogRunId(run.runId); setLogText(outcome.data.text) }
662
+ }
663
+ const clearLog = async (run) => {
664
+ await request('DELETE', 'runs/' + run.runId + '/log')
665
+ setLogText(null)
666
+ setLogRunId(null)
667
+ reload()
668
+ }
669
+
670
+ return h('div', { className: 'cb-panel' },
671
+ h('div', { className: 'cb-toolbar' },
672
+ h('select', { className: 'cb-select', value: jobId || '', onChange: (e) => setJobId(e.target.value) },
673
+ jobs.map((job) => h('option', { key: job.id, value: job.id }, job.name)))),
674
+ runs.length === 0 ? h('div', { className: 'cb-empty' }, '该任务暂无运行记录') :
675
+ h('div', { className: 'cb-table' },
676
+ runs.map((run) => {
677
+ const meta = statusMeta(run.status)
678
+ return h('div', { key: run.runId, className: 'cb-row' },
679
+ h('span', { className: 'cb-dot', style: { '--cb-status': STATUS_TONE_COLOR[meta.tone] } }),
680
+ h('span', { className: 'cb-meta' }, meta.label),
681
+ h('span', { className: 'cb-badge' }, TRIGGER_META[run.trigger] ? TRIGGER_META[run.trigger].label : run.trigger),
682
+ h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt)),
683
+ typeof run.durationMs === 'number' ? h('span', { className: 'cb-meta' }, formatDuration(run.durationMs)) : null,
684
+ run.message ? h('span', { className: 'cb-meta' }, run.message) : null,
685
+ h('div', { className: 'cb-actions' },
686
+ h('button', { className: 'cb-icon', onClick: () => openLog(run) }, '日志'),
687
+ h('button', { className: 'cb-icon cb-button--danger', onClick: () => clearLog(run) }, '清空')))
688
+ })),
689
+ logText !== null ? h(Modal, { title: '运行日志', onClose: () => setLogText(null), width: 640 },
690
+ h('div', { className: 'cb-log' }, logText || '(空)'),
691
+ h('div', { className: 'cb-toolbar' },
692
+ h('div', { className: 'cb-spacer' }),
693
+ h('button', { className: 'cb-button', onClick: () => setLogText(null) }, '关闭'))) : null)
694
+ }
695
+
696
+ function CronBoardPanel({ visible, wsModel } = {}) {
697
+ const active = visible !== false
698
+ const [tab, setTab] = useState('jobs')
699
+ const [jobs, setJobs] = useState([])
700
+ const [envs, setEnvs] = useState([])
701
+ const [status, setStatus] = useState(null)
702
+ const [now, setNow] = useState(Date.now())
703
+ const [reloadFlag, setReloadFlag] = useState(0)
704
+ const reload = useCallback(() => setReloadFlag((value) => value + 1), [])
705
+
706
+ useEffect(() => {
707
+ if (!active) return undefined
708
+ let alive = true
709
+ const load = async () => {
710
+ const [jobsOutcome, envsOutcome, statusOutcome] = await Promise.all([
711
+ request('GET', 'jobs'),
712
+ request('GET', 'envs'),
713
+ request('GET', 'status'),
714
+ ])
715
+ if (!alive) return
716
+ if (jobsOutcome.ok) setJobs(jobsOutcome.data.items)
717
+ if (envsOutcome.ok) setEnvs(envsOutcome.data.items)
718
+ if (statusOutcome.ok) setStatus(statusOutcome.data)
719
+ setNow(Date.now())
720
+ }
721
+ load()
722
+ const timer = setInterval(load, REFRESH_INTERVAL_MS)
723
+ return () => { alive = false; clearInterval(timer) }
724
+ }, [reloadFlag, active])
725
+
726
+ return h('div', { className: 'cb-panel' },
727
+ h('div', { className: 'cb-toolbar' },
728
+ h(PillGroup, { options: TABS, value: tab, onChange: setTab }),
729
+ h('div', { className: 'cb-spacer' }),
730
+ status ? h('span', { className: 'cb-meta' },
731
+ '运行中 ' + status.scheduler.active + ' · 队列 ' + status.scheduler.queued +
732
+ (status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
733
+ tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, wsModel }) : null,
734
+ tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload }) : null,
735
+ tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
736
+ }
737
+
738
+ // —— 主页面挂载(形态对齐 dsh-taskboard,cb- 属性命名空间)——
739
+ // 优先 better-sidebar 扩展槽(单实例 tab);未装回退主界面:侧栏入口行 + 中心列看板容器。
740
+
741
+ const TAB_ID = 'cron-board:board'
742
+ const ENTRY_ATTR = 'data-cb-entry'
743
+ const VIEW_ATTR = 'data-cb-view'
744
+ const BOARD_ACTIVE_ATTR = 'data-cb-board-active'
745
+ const PANEL_ACTIVATE_EVENT = 'dsh-panel-activate'
746
+ const PANEL_NAME = 'dsh-cron-board'
747
+ const ENTRY_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="12" height="12" rx="2"/><path d="M6 2v12M10 2v12"/></svg>'
748
+ // 三代布局壳兼容:dev data-pane / 官方 CSS-Module centerCol / DSH Desktop 扩展面
749
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"], .dshDesktopConversationSurface'
750
+ const SIDEBAR_ROOT_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface'
751
+ // 家族面板入口条目(taskboard 系),插入时排其前,避免重渲染后顺序漂移
752
+ const FAMILY_ENTRY_SELECTOR = '[data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry], [' + ENTRY_ATTR + ']'
753
+ const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
754
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
755
+
756
+ // better-sidebar tab:注册即单实例(single),visible 门控轮询;wsModel 供会话任务选工作区
757
+ function registerBoardTab(ctx, service, wsModel) {
758
+ return ctx.effect(() => service.registerTab({
759
+ id: TAB_ID,
760
+ title: '定时任务',
761
+ icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
762
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
763
+ h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
764
+ h('path', { d: 'M6 2v12M10 2v12' })),
765
+ order: 46,
766
+ single: true,
767
+ component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
768
+ }), 'cron-board sidebar tab')
769
+ }
770
+
771
+ function sidebarRoot() {
772
+ const column = document.querySelector(SIDEBAR_ROOT_SELECTOR)
773
+ if (!column) return undefined
774
+ const logoOwner = column.querySelector('[class*="logoRow"]')
775
+ return (logoOwner && logoOwner.parentElement) || column.firstElementChild || undefined
776
+ }
777
+
778
+ function newSessionButton(root) {
779
+ const nested = root.querySelector('button[class*="newSession"]')
780
+ if (nested) return nested
781
+ for (const child of root.children) {
782
+ if (child instanceof HTMLButtonElement && !child.matches('[' + ENTRY_ATTR + ']')) return child
783
+ }
784
+ return root.querySelector('button[aria-label="新建会话"], button[aria-label*="新会话"], button[aria-label*="new session" i]') || undefined
785
+ }
786
+
787
+ function placeEntry(root, entry) {
788
+ const button = newSessionButton(root)
789
+ if (!button) return false
790
+ if (entry.parentElement !== root) {
791
+ const row = button.closest('[class*="logoRow"]')
792
+ const base = (row && row.parentElement === root) ? row : button
793
+ const family = Array.from(root.children).filter((el) => el.matches(FAMILY_ENTRY_SELECTOR))
794
+ const anchor = family.length > 0 ? family[0] : (base.nextElementSibling || null)
795
+ root.insertBefore(entry, anchor)
796
+ }
797
+ return true
798
+ }
799
+
800
+ // 主界面形态:入口行 + 中心列容器 + html 属性显隐 + 面板互斥;返回幂等 disposer
801
+ function mountStandaloneBoard(wsModel) {
802
+ let disposed = false
803
+ let entry = null
804
+ let rootEl = undefined
805
+ let placed = false
806
+ let rootObserver = null
807
+ let viewRoot = null
808
+ let viewEl = null
809
+
810
+ const setOpen = (open) => {
811
+ if (open) {
812
+ for (const attr of OTHER_PANEL_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
813
+ document.documentElement.setAttribute(BOARD_ACTIVE_ATTR, '')
814
+ document.dispatchEvent(new CustomEvent(PANEL_ACTIVATE_EVENT, { detail: PANEL_NAME }))
815
+ entry.dataset.active = 'true'
816
+ } else {
817
+ document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
818
+ entry.dataset.active = 'false'
819
+ }
820
+ }
821
+ const isOpen = () => document.documentElement.hasAttribute(BOARD_ACTIVE_ATTR)
822
+
823
+ const ensureEntry = () => {
824
+ if (rootEl !== undefined && !rootEl.isConnected) { placed = false; rootEl = undefined }
825
+ if (placed && document.body.contains(entry)) return true
826
+ placed = false
827
+ rootEl = sidebarRoot()
828
+ if (!rootEl) return false
829
+ placed = placeEntry(rootEl, entry)
830
+ if (placed && rootObserver) rootObserver.observe(rootEl, { childList: true, subtree: true })
831
+ return placed
832
+ }
833
+
834
+ const ensureView = () => {
835
+ if (viewEl && viewEl.isConnected) return true
836
+ const column = document.querySelector(CONVERSATION_COLUMN_SELECTOR)
837
+ if (!column) return false
838
+ viewEl = document.createElement('div')
839
+ viewEl.setAttribute(VIEW_ATTR, '')
840
+ viewEl.className = 'cb-view'
841
+ column.appendChild(viewEl)
842
+ viewRoot = ReactDOMClient.createRoot(viewEl)
843
+ viewRoot.render(React.createElement(CronBoardPanel, { visible: true, wsModel }))
844
+ return true
845
+ }
846
+
847
+ const onClickDocument = (event) => {
848
+ if (!isOpen()) return
849
+ const target = event.target
850
+ if (target && target.closest && target.closest('[' + ENTRY_ATTR + ']')) return
851
+ if (target && target.closest && target.closest(SIDEBAR_ROW_SELECTOR)) setOpen(false)
852
+ }
853
+ const onOtherActivate = (event) => {
854
+ if (event.detail !== PANEL_NAME && isOpen()) setOpen(false)
855
+ }
856
+ entry = document.createElement('button')
857
+ entry.type = 'button'
858
+ entry.setAttribute(ENTRY_ATTR, '')
859
+ entry.className = 'cb-entry'
860
+ entry.setAttribute('aria-label', '定时任务')
861
+ entry.innerHTML = '<span class="cb-entry-icon">' + ENTRY_ICON + '</span><span>定时任务</span>'
862
+ entry.addEventListener('click', () => setOpen(!isOpen()))
863
+ document.addEventListener('click', onClickDocument, true)
864
+ document.addEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
865
+ const waitObserver = new MutationObserver(() => { ensureEntry(); ensureView() })
866
+ waitObserver.observe(document.body, { childList: true, subtree: true })
867
+ rootObserver = new MutationObserver(() => {
868
+ if (rootEl === undefined || !rootEl.isConnected) { ensureEntry(); return }
869
+ if (!rootEl.contains(entry)) placeEntry(rootEl, entry)
870
+ })
871
+ const retry = setInterval(() => { ensureEntry(); ensureView() }, 2000)
872
+ ensureEntry()
873
+ ensureView()
874
+
875
+ return () => {
876
+ if (disposed) return
877
+ disposed = true
878
+ clearInterval(retry)
879
+ waitObserver.disconnect()
880
+ if (rootObserver) rootObserver.disconnect()
881
+ document.removeEventListener('click', onClickDocument, true)
882
+ document.removeEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
883
+ document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
884
+ entry.remove()
885
+ if (viewRoot) viewRoot.unmount()
886
+ if (viewEl) viewEl.remove()
887
+ }
888
+ }
889
+
890
+ return {
891
+ // workspaces 由宿主 client 常驻提供;声明注入保证 apply 时已就绪(缺失则 fiber 未激活,即干净禁用)
892
+ inject: ['workspaces'],
893
+ apply(ctx) {
894
+ ensureStyle(document)
895
+ // 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
896
+ const wsService = ctx.get('workspaces')
897
+ const wsModel = wsService && wsService.list && typeof wsService.list.getSnapshot === 'function' ? wsService.list : null
898
+ // 扩展槽接入:tab 注册常驻 + 页签联动;直挂与服务晚注册两条路径共用
899
+ const attachBoardTab = (sidebar) => {
900
+ registerBoardTab(ctx, sidebar, wsModel)
901
+ const tabActive = () => {
902
+ // 页签开着(哪怕类型被禁,better-sidebar 仍渲染已开页签)即扩展槽形态;没开(未开/被关/被禁)即主界面形态
903
+ const snap = typeof sidebar.getSnapshot === 'function' ? sidebar.getSnapshot() : null
904
+ const openTabs = snap && snap.state && Array.isArray(snap.state.openTabs) ? snap.state.openTabs : null
905
+ if (openTabs) return openTabs.some((tab) => tab.type === TAB_ID)
906
+ // 快照未就绪(会话未激活)的保守兜底:类型被禁视作主界面
907
+ if (typeof sidebar.isTabEnabled === 'function') return sidebar.isTabEnabled(TAB_ID)
908
+ return true
909
+ }
910
+ let fallback = null
911
+ const syncFallback = () => {
912
+ const active = tabActive()
913
+ if (!active && !fallback) {
914
+ fallback = mountStandaloneBoard(wsModel)
915
+ ctx.effect(() => () => { if (fallback) { fallback(); fallback = null } }, 'cron-board tab-closed fallback')
916
+ } else if (active && fallback) {
917
+ fallback()
918
+ fallback = null
919
+ }
920
+ }
921
+ syncFallback()
922
+ if (typeof sidebar.subscribeState === 'function') {
923
+ ctx.effect(() => sidebar.subscribeState(syncFallback), 'cron-board tab-open watch')
924
+ }
925
+ // 快照晚载入兜底:页签持久化恢复可能落在订阅注册之前且不再发事件,延迟重查一次纠正双形态并存
926
+ const LATE_SYNC_MS = 2 * 1000
927
+ const lateSync = setTimeout(syncFallback, LATE_SYNC_MS)
928
+ ctx.effect(() => () => clearTimeout(lateSync), 'cron-board late sync')
929
+ }
930
+ // 路径一:apply 时服务已在,直接接入
931
+ const sidebar = ctx.get('betterSidebar')
932
+ if (sidebar !== undefined) {
933
+ attachBoardTab(sidebar)
934
+ return
935
+ }
936
+ // 路径二:服务缺席先落主界面形态,服务就绪后拆主界面、接扩展槽(含页签联动)
937
+ let standalone = mountStandaloneBoard(wsModel)
938
+ let upgraded = false
939
+ ctx.effect(() => () => { if (!upgraded) standalone() }, 'cron-board standalone board')
940
+ ctx.inject(['betterSidebar'], (bsCtx) => {
941
+ upgraded = true
942
+ standalone()
943
+ attachBoardTab(bsCtx.betterSidebar)
944
+ })
945
+ },
946
+ }
947
+ }
948
+ }
949
+ })()