@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/README.md +55 -0
- package/cordis.patch.yml +5 -0
- package/package.json +44 -0
- package/src/api.mjs +434 -0
- package/src/client.js +949 -0
- package/src/config.mjs +10 -0
- package/src/cron.mjs +50 -0
- package/src/env-expand.mjs +51 -0
- package/src/env-text.mjs +17 -0
- package/src/executor.mjs +139 -0
- package/src/index.js +167 -0
- package/src/logger.mjs +82 -0
- package/src/scheduler.mjs +99 -0
- package/src/session-driver.mjs +82 -0
- package/src/session-window.mjs +37 -0
- package/src/shell-runner.mjs +71 -0
- package/src/status-meta.mjs +16 -0
- package/src/store.mjs +195 -0
- package/test/env-expand.test.mjs +101 -0
- package/test/executor.test.mjs +107 -0
- package/test/index.test.mjs +214 -0
- package/test/logger.test.mjs +98 -0
- package/test/parity.test.mjs +72 -0
- package/test/routes.test.mjs +361 -0
- package/test/scheduler.test.mjs +383 -0
- package/test/session-driver.test.mjs +161 -0
- package/test/shell-runner.test.mjs +87 -0
- package/test/store.test.mjs +92 -0
- package/test/switch-guard.test.mjs +63 -0
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
|
+
})()
|