@mzzsfy/dsh-cron-board 0.3.1 → 0.4.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 +6 -6
- package/package.json +1 -1
- package/src/client.js +89 -168
- package/test/switch-guard.test.mjs +50 -11
package/README.md
CHANGED
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
## 界面挂载
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
挂载走宿主官方契约(main 全局面板插槽 / sidebar.panellist 侧栏面板行 / better-sidebar 服务),不刮宿主 DOM,宿主 UI 变动由契约消化:
|
|
22
22
|
|
|
23
|
-
-
|
|
24
|
-
- **手动移入**:设置>插件 页「定时任务」卡片(官方 PluginCard 形制:折叠卡壳,cb-pc 镜像)提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab
|
|
25
|
-
-
|
|
26
|
-
-
|
|
23
|
+
- **默认:全局面板**。看板以同 id 注册 main keyed 条目(中央面板)+ sidebar.panellist 条目(侧栏「新会话」下方面板行);点击面板行由宿主 `layout.selectPanel` 切换中央区,会话保持不动;点击会话行或新会话即切回会话(官方路径)
|
|
24
|
+
- **手动移入**:设置>插件 页「定时任务」卡片(官方 PluginCard 形制:折叠卡壳,cb-pc 镜像)提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab 并卸载全局面板
|
|
25
|
+
- **不自动回退**:移入后页签关闭/类型禁用均不再自动返回;把开关关掉即回到全局面板形态(轮询感知,约一个 tick 周期内生效)
|
|
26
|
+
- **开关可用性**:已装载 better-sidebar 时可切换;未装载时开关呈禁用态仅展示(保持全局面板,无副作用)
|
|
27
27
|
|
|
28
28
|
## 配置
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
| `maxConcurrent` | 全局同时运行的执行单元上限 | 2 |
|
|
36
36
|
| `logKeepPerJob` | 每任务保留的运行日志份数(超出按文件修改时间裁剪) | 200 |
|
|
37
37
|
| `maskEnvInPrompt` | 会话任务投递文本中环境变量值打码 | 关 |
|
|
38
|
-
| `sidebarTab` | 看板移入 better-sidebar 侧边栏(
|
|
38
|
+
| `sidebarTab` | 看板移入 better-sidebar 侧边栏(需已装载;关闭时以侧栏面板行 + 全局面板呈现) | 关 |
|
|
39
39
|
|
|
40
40
|
## 依赖
|
|
41
41
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -167,7 +167,9 @@ body{
|
|
|
167
167
|
--cb-font-xs:12px;--cb-font-sm:12px;--cb-font-md:13px;--cb-font-lg:15px;
|
|
168
168
|
--cb-control-h:30px;
|
|
169
169
|
}
|
|
170
|
-
.cb-panel{display:flex;flex-direction:column;gap:var(--cb-space-5);min-width:0;max-width:880px;color:var(--cb-text);container-type:inline-size}
|
|
170
|
+
.cb-panel{display:flex;flex-direction:column;gap:var(--cb-space-5);min-width:0;max-width:880px;margin:0 auto;width:100%;color:var(--cb-text);container-type:inline-size}
|
|
171
|
+
/* 全局面板壳:占满宿主中央区,滚动自持(main keyed 条目根) */
|
|
172
|
+
.cb-main{flex:1;min-height:0;display:flex;flex-direction:column;overflow:auto;padding:var(--cb-space-6);background:var(--dsw-alias-bg-base,var(--cb-bg))}
|
|
171
173
|
.cb-toolbar{display:flex;align-items:center;gap:var(--cb-space-3);flex-wrap:wrap}
|
|
172
174
|
.cb-tabs{display:flex;gap:var(--cb-space-1);background:var(--cb-bg-muted);border-radius:999px;padding:3px}
|
|
173
175
|
.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}
|
|
@@ -183,7 +185,7 @@ body{
|
|
|
183
185
|
.cb-button--danger{color:var(--cb-danger)}
|
|
184
186
|
.cb-button--danger:hover:not(:disabled){border-color:var(--cb-danger);background:var(--cb-danger);color:#fff}
|
|
185
187
|
.cb-button--sm{min-height:28px;padding:0 10px;font-size:var(--cb-font-sm)}
|
|
186
|
-
.cb-button:focus-visible,.cb-icon:focus-visible,.cb-pill:focus-visible,.cb-select:focus-visible
|
|
188
|
+
.cb-button:focus-visible,.cb-icon:focus-visible,.cb-pill:focus-visible,.cb-select:focus-visible{outline:2px solid color-mix(in srgb, var(--cb-accent) 70%, white);outline-offset:2px}
|
|
187
189
|
.cb-button:active:not(:disabled){transform:translateY(1px)}
|
|
188
190
|
.cb-cards{display:flex;flex-direction:column;gap:var(--cb-space-3)}
|
|
189
191
|
.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}
|
|
@@ -290,27 +292,17 @@ body{
|
|
|
290
292
|
.cb-log::-webkit-scrollbar-thumb,.cb-modal::-webkit-scrollbar-thumb{background:var(--cb-border-strong);border-radius:999px}
|
|
291
293
|
.cb-log::-webkit-scrollbar-track,.cb-modal::-webkit-scrollbar-track{background:transparent}
|
|
292
294
|
.cb-error{font-size:var(--cb-font-sm);color:var(--cb-danger);background:var(--cb-bg-sub);border-left:3px solid var(--cb-danger);border-radius:var(--cb-radius-sm);padding:var(--cb-space-2) var(--cb-space-3)}
|
|
293
|
-
/*
|
|
295
|
+
/* 容器查询:会话视图区与 better-sidebar tab 宽度差异大,跟随面板自身宽度而非视口。
|
|
294
296
|
模态不受面板容器宽度连累:mask 固定定位于视口,断点用视口媒体查询独立判定 */
|
|
295
297
|
@container (max-width: 560px){
|
|
296
298
|
.cb-card-row .cb-actions{margin-left:0;width:100%;justify-content:flex-end;flex-wrap:wrap}
|
|
297
|
-
.cb-view{padding:var(--cb-space-4)}
|
|
298
299
|
.cb-panel{gap:var(--cb-space-4)}
|
|
299
300
|
}
|
|
300
301
|
@media (max-width: 560px){
|
|
301
302
|
.cb-grid{grid-template-columns:1fr}
|
|
302
303
|
.cb-modal{max-height:92vh}
|
|
304
|
+
.cb-main{padding:var(--cb-space-4)}
|
|
303
305
|
}
|
|
304
|
-
.cb-entry{display:flex;align-items:center;gap:var(--cb-space-3);width:100%;border:none;background:transparent;color:inherit;cursor:pointer;padding:var(--cb-space-2) var(--cb-space-4);border-radius:var(--cb-radius-md);font-size:var(--cb-font-md);text-align:left}
|
|
305
|
-
.cb-entry:hover{background:var(--cb-hover)}
|
|
306
|
-
.cb-entry[data-active="true"]{background:var(--cb-accent);color:#fff}
|
|
307
|
-
.cb-entry-icon{display:inline-flex;flex:none}
|
|
308
|
-
.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))}
|
|
309
|
-
.cb-view > .cb-panel{margin:0 auto;width:100%}
|
|
310
|
-
html[data-cb-board-active] [data-cb-view]{display:flex}
|
|
311
|
-
html[data-cb-board-active] [data-pane="conversation"] > :not([data-cb-view]){display:none !important}
|
|
312
|
-
html[data-cb-board-active] [class*="centerCol"] > :not([data-cb-view]){display:none !important}
|
|
313
|
-
html[data-cb-board-active] .dshDesktopConversationSurface > :not([data-cb-view]){display:none !important}
|
|
314
306
|
`
|
|
315
307
|
|
|
316
308
|
// 空模块兜底:react 缺席即整面板禁用(宿主必有 react,防御性)
|
|
@@ -321,6 +313,13 @@ function noopFactory() {
|
|
|
321
313
|
if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
322
314
|
window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-cron-board', factory })
|
|
323
315
|
|
|
316
|
+
// 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;键 = 市场短名(发现页
|
|
317
|
+
// 收录显示形态);该插件未就绪时入队,由其启动时排空
|
|
318
|
+
const NAV_ICON = { 'dsh-cron-board': 'plan' }
|
|
319
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
320
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
321
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
322
|
+
|
|
324
323
|
function factory(require) {
|
|
325
324
|
let React = null
|
|
326
325
|
let ReactDOMClient = null
|
|
@@ -679,7 +678,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
679
678
|
h('button', { className: 'cb-button cb-button--danger', onClick: onConfirm }, '删除')))
|
|
680
679
|
}
|
|
681
680
|
|
|
682
|
-
function JobsTab({ jobs, status, now, reload, wsModel }) {
|
|
681
|
+
function JobsTab({ jobs, status, now, reload, applyJobPatch, wsModel }) {
|
|
683
682
|
const [formJob, setFormJob] = useState(null)
|
|
684
683
|
const [notice, setNotice] = useState(null)
|
|
685
684
|
const [kindFilter, setKindFilter] = useState('all')
|
|
@@ -691,7 +690,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
691
690
|
setTimeout(reload, 300)
|
|
692
691
|
}
|
|
693
692
|
const toggleJob = async (job) => {
|
|
694
|
-
|
|
693
|
+
// 乐观翻转:受控开关即时到位;失败回滚并提示,不静默
|
|
694
|
+
applyJobPatch(job.id, { enabled: !job.enabled })
|
|
695
|
+
const outcome = await request('PATCH', 'jobs/' + job.id, { enabled: !job.enabled })
|
|
696
|
+
if (!outcome.ok) {
|
|
697
|
+
applyJobPatch(job.id, { enabled: job.enabled })
|
|
698
|
+
setNotice('切换失败:' + outcome.error)
|
|
699
|
+
}
|
|
695
700
|
reload()
|
|
696
701
|
}
|
|
697
702
|
const deleteJob = async (job) => {
|
|
@@ -811,14 +816,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
811
816
|
h('button', { className: 'cb-button cb-button--danger', onClick: () => doApply(true) }, '覆盖导入')))
|
|
812
817
|
}
|
|
813
818
|
|
|
814
|
-
function EnvsTab({ envs, reload }) {
|
|
819
|
+
function EnvsTab({ envs, reload, applyEnvPatch }) {
|
|
815
820
|
const [formRow, setFormRow] = useState(null)
|
|
816
821
|
const [importing, setImporting] = useState(false)
|
|
817
822
|
const [exportText, setExportText] = useState(null)
|
|
818
823
|
const [notice, setNotice] = useState(null)
|
|
819
824
|
|
|
820
825
|
const toggleEnv = async (row) => {
|
|
821
|
-
|
|
826
|
+
// 乐观翻转:受控开关即时到位;失败回滚并提示,不静默
|
|
827
|
+
applyEnvPatch(row.id, { enabled: !row.enabled })
|
|
828
|
+
const outcome = await request('PATCH', 'envs/' + row.id, { enabled: !row.enabled })
|
|
829
|
+
if (!outcome.ok) {
|
|
830
|
+
applyEnvPatch(row.id, { enabled: row.enabled })
|
|
831
|
+
setNotice('切换失败:' + outcome.error)
|
|
832
|
+
}
|
|
822
833
|
reload()
|
|
823
834
|
}
|
|
824
835
|
const deleteEnv = async (row) => {
|
|
@@ -921,6 +932,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
921
932
|
const [now, setNow] = useState(Date.now())
|
|
922
933
|
const [reloadFlag, setReloadFlag] = useState(0)
|
|
923
934
|
const reload = useCallback(() => setReloadFlag((value) => value + 1), [])
|
|
935
|
+
// 乐观更新:受控开关点击即时到位,PATCH 失败由调用方回滚;reload 拉权威态对账
|
|
936
|
+
const applyJobPatch = useCallback((id, patch) => {
|
|
937
|
+
setJobs((prev) => prev.map((job) => (job.id === id ? { ...job, ...patch } : job)))
|
|
938
|
+
}, [])
|
|
939
|
+
const applyEnvPatch = useCallback((id, patch) => {
|
|
940
|
+
setEnvs((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
|
|
941
|
+
}, [])
|
|
924
942
|
|
|
925
943
|
useEffect(() => {
|
|
926
944
|
if (!active) return undefined
|
|
@@ -949,28 +967,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
949
967
|
status ? h('span', { className: 'cb-meta' },
|
|
950
968
|
'运行中 ' + status.scheduler.active + ' · 队列 ' + status.scheduler.queued +
|
|
951
969
|
(status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
|
|
952
|
-
tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, wsModel }) : null,
|
|
953
|
-
tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload }) : null,
|
|
970
|
+
tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, applyJobPatch, wsModel }) : null,
|
|
971
|
+
tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload, applyEnvPatch }) : null,
|
|
954
972
|
tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
|
|
955
973
|
}
|
|
956
974
|
|
|
957
|
-
// ——
|
|
958
|
-
//
|
|
975
|
+
// —— 主页面挂载:宿主官方全局面板契约(main keyed 插槽 + sidebar.panellist 侧栏入口)——
|
|
976
|
+
// 侧栏行与中央面板均由宿主渲染:点击入口行宿主调 layout.selectPanel 切换,会话保持不动;
|
|
977
|
+
// 切回会话走官方路径(会话行/新会话 openSession → selectPanel(null))。禁 DOM 刮取。
|
|
978
|
+
// better-sidebar 偏好开启时改注册其扩展槽 tab(服务契约,同样非 DOM 刮取),两形态互斥。
|
|
959
979
|
|
|
960
980
|
const TAB_ID = 'cron-board:board'
|
|
961
|
-
const
|
|
962
|
-
const
|
|
963
|
-
const BOARD_ACTIVE_ATTR = 'data-cb-board-active'
|
|
964
|
-
const PANEL_ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
965
|
-
const PANEL_NAME = 'dsh-cron-board'
|
|
966
|
-
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>'
|
|
967
|
-
// 三代布局壳兼容:dev data-pane / 官方 CSS-Module centerCol / DSH Desktop 扩展面
|
|
968
|
-
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"], .dshDesktopConversationSurface'
|
|
969
|
-
const SIDEBAR_ROOT_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface'
|
|
970
|
-
// 家族面板入口条目(taskboard 系),插入时排其前,避免重渲染后顺序漂移
|
|
971
|
-
const FAMILY_ENTRY_SELECTOR = '[data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry], [' + ENTRY_ATTR + ']'
|
|
972
|
-
const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
973
|
-
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
981
|
+
const PANEL_ID = 'cron-board'
|
|
982
|
+
const PANEL_LABEL = '定时任务'
|
|
974
983
|
|
|
975
984
|
// 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
|
|
976
985
|
const CARD_TITLE = '定时任务'
|
|
@@ -981,12 +990,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
981
990
|
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
982
991
|
h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
|
|
983
992
|
|
|
984
|
-
function CronBoardPluginCard() {
|
|
993
|
+
function CronBoardPluginCard({ ctx }) {
|
|
985
994
|
const [enabled, setEnabled] = useState(null)
|
|
986
995
|
const [busy, setBusy] = useState(false)
|
|
987
996
|
const [open, setOpen] = useState(false)
|
|
988
|
-
//
|
|
989
|
-
const sidebarReady =
|
|
997
|
+
// 渲染时重探服务在场:better-sidebar 晚于设置页装载时,重渲染自动纠正禁用态
|
|
998
|
+
const sidebarReady = ctx.get('betterSidebar') !== undefined
|
|
990
999
|
useEffect(() => {
|
|
991
1000
|
let alive = true
|
|
992
1001
|
request('GET', 'status').then((outcome) => {
|
|
@@ -1019,7 +1028,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1019
1028
|
ariaLabel: CARD_TOGGLE_LABEL,
|
|
1020
1029
|
})),
|
|
1021
1030
|
h('p', { className: 'cb-pc__hint' },
|
|
1022
|
-
sidebarReady ? '
|
|
1031
|
+
sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时使用全局面板。' : '需已装载 better-sidebar 后方可切换;未装载时看板始终使用全局面板。'))) : null)
|
|
1023
1032
|
}
|
|
1024
1033
|
|
|
1025
1034
|
// better-sidebar tab:注册即单实例(single);只注册不主动打开,
|
|
@@ -1028,7 +1037,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1028
1037
|
return ctx.effect(() => {
|
|
1029
1038
|
const disposeTab = service.registerTab({
|
|
1030
1039
|
id: TAB_ID,
|
|
1031
|
-
title:
|
|
1040
|
+
title: PANEL_LABEL,
|
|
1032
1041
|
icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
|
|
1033
1042
|
stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
1034
1043
|
h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
|
|
@@ -1041,123 +1050,30 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1041
1050
|
}, 'cron-board sidebar tab')
|
|
1042
1051
|
}
|
|
1043
1052
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
function newSessionButton(root) {
|
|
1052
|
-
const nested = root.querySelector('button[class*="newSession"]')
|
|
1053
|
-
if (nested) return nested
|
|
1054
|
-
for (const child of root.children) {
|
|
1055
|
-
if (child instanceof HTMLButtonElement && !child.matches('[' + ENTRY_ATTR + ']')) return child
|
|
1056
|
-
}
|
|
1057
|
-
return root.querySelector('button[aria-label="新建会话"], button[aria-label*="新会话"], button[aria-label*="new session" i]') || undefined
|
|
1053
|
+
// 面板图标:panellist 渲染契约组件,宿主传 { size, active }
|
|
1054
|
+
function PanelIcon({ size }) {
|
|
1055
|
+
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
|
|
1056
|
+
stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
1057
|
+
h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
|
|
1058
|
+
h('path', { d: 'M6 2v12M10 2v12' }))
|
|
1058
1059
|
}
|
|
1059
1060
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
let entry = null
|
|
1077
|
-
let rootEl = undefined
|
|
1078
|
-
let placed = false
|
|
1079
|
-
let rootObserver = null
|
|
1080
|
-
let viewRoot = null
|
|
1081
|
-
let viewEl = null
|
|
1082
|
-
|
|
1083
|
-
const setOpen = (open) => {
|
|
1084
|
-
if (open) {
|
|
1085
|
-
for (const attr of OTHER_PANEL_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
1086
|
-
document.documentElement.setAttribute(BOARD_ACTIVE_ATTR, '')
|
|
1087
|
-
document.dispatchEvent(new CustomEvent(PANEL_ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
1088
|
-
entry.dataset.active = 'true'
|
|
1089
|
-
} else {
|
|
1090
|
-
document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
|
|
1091
|
-
entry.dataset.active = 'false'
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
const isOpen = () => document.documentElement.hasAttribute(BOARD_ACTIVE_ATTR)
|
|
1095
|
-
|
|
1096
|
-
const ensureEntry = () => {
|
|
1097
|
-
if (rootEl !== undefined && !rootEl.isConnected) { placed = false; rootEl = undefined }
|
|
1098
|
-
if (placed && document.body.contains(entry)) return true
|
|
1099
|
-
placed = false
|
|
1100
|
-
rootEl = sidebarRoot()
|
|
1101
|
-
if (!rootEl) return false
|
|
1102
|
-
placed = placeEntry(rootEl, entry)
|
|
1103
|
-
if (placed && rootObserver) rootObserver.observe(rootEl, { childList: true, subtree: true })
|
|
1104
|
-
return placed
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
const ensureView = () => {
|
|
1108
|
-
if (viewEl && viewEl.isConnected) return true
|
|
1109
|
-
const column = document.querySelector(CONVERSATION_COLUMN_SELECTOR)
|
|
1110
|
-
if (!column) return false
|
|
1111
|
-
viewEl = document.createElement('div')
|
|
1112
|
-
viewEl.setAttribute(VIEW_ATTR, '')
|
|
1113
|
-
viewEl.className = 'cb-view'
|
|
1114
|
-
column.appendChild(viewEl)
|
|
1115
|
-
viewRoot = ReactDOMClient.createRoot(viewEl)
|
|
1116
|
-
viewRoot.render(React.createElement(CronBoardPanel, { visible: true, wsModel }))
|
|
1117
|
-
return true
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
const onClickDocument = (event) => {
|
|
1121
|
-
if (!isOpen()) return
|
|
1122
|
-
const target = event.target
|
|
1123
|
-
if (target && target.closest && target.closest('[' + ENTRY_ATTR + ']')) return
|
|
1124
|
-
if (target && target.closest && target.closest(SIDEBAR_ROW_SELECTOR)) setOpen(false)
|
|
1125
|
-
}
|
|
1126
|
-
const onOtherActivate = (event) => {
|
|
1127
|
-
if (event.detail !== PANEL_NAME && isOpen()) setOpen(false)
|
|
1128
|
-
}
|
|
1129
|
-
entry = document.createElement('button')
|
|
1130
|
-
entry.type = 'button'
|
|
1131
|
-
entry.setAttribute(ENTRY_ATTR, '')
|
|
1132
|
-
entry.className = 'cb-entry'
|
|
1133
|
-
entry.setAttribute('aria-label', '定时任务')
|
|
1134
|
-
entry.innerHTML = '<span class="cb-entry-icon">' + ENTRY_ICON + '</span><span>定时任务</span>'
|
|
1135
|
-
entry.addEventListener('click', () => setOpen(!isOpen()))
|
|
1136
|
-
document.addEventListener('click', onClickDocument, true)
|
|
1137
|
-
document.addEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
|
|
1138
|
-
const waitObserver = new MutationObserver(() => { ensureEntry(); ensureView() })
|
|
1139
|
-
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
1140
|
-
rootObserver = new MutationObserver(() => {
|
|
1141
|
-
if (rootEl === undefined || !rootEl.isConnected) { ensureEntry(); return }
|
|
1142
|
-
if (!rootEl.contains(entry)) placeEntry(rootEl, entry)
|
|
1143
|
-
})
|
|
1144
|
-
const retry = setInterval(() => { ensureEntry(); ensureView() }, 2000)
|
|
1145
|
-
ensureEntry()
|
|
1146
|
-
ensureView()
|
|
1147
|
-
|
|
1148
|
-
return () => {
|
|
1149
|
-
if (disposed) return
|
|
1150
|
-
disposed = true
|
|
1151
|
-
clearInterval(retry)
|
|
1152
|
-
waitObserver.disconnect()
|
|
1153
|
-
if (rootObserver) rootObserver.disconnect()
|
|
1154
|
-
document.removeEventListener('click', onClickDocument, true)
|
|
1155
|
-
document.removeEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
|
|
1156
|
-
document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
|
|
1157
|
-
entry.remove()
|
|
1158
|
-
if (viewRoot) viewRoot.unmount()
|
|
1159
|
-
if (viewEl) viewEl.remove()
|
|
1160
|
-
}
|
|
1061
|
+
// 全局面板形态:main keyed 条目(中央面板)+ sidebar.panellist 条目(侧栏入口行),同 id 配对;
|
|
1062
|
+
// 页签点击切换、激活高亮均由宿主管;effect 工厂必须交付组合清理,否则互斥仲裁拆不掉条目
|
|
1063
|
+
function registerMainPanel(ctx, wsModel) {
|
|
1064
|
+
return ctx.effect(() => {
|
|
1065
|
+
const injectDisposers = []
|
|
1066
|
+
injectDisposers.push(ctx.slots.inject('main', () => ctx.slots.register({
|
|
1067
|
+
name: 'main',
|
|
1068
|
+
key: PANEL_ID,
|
|
1069
|
+
}, () => h('div', { className: 'cb-main' }, h(CronBoardPanel, { wsModel })))))
|
|
1070
|
+
injectDisposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
1071
|
+
name: 'sidebar.panellist',
|
|
1072
|
+
id: PANEL_ID,
|
|
1073
|
+
label: PANEL_LABEL,
|
|
1074
|
+
}, (iconProps) => h(PanelIcon, iconProps))))
|
|
1075
|
+
return () => { for (const dispose of injectDisposers) dispose() }
|
|
1076
|
+
}, 'cron-board main panel')
|
|
1161
1077
|
}
|
|
1162
1078
|
|
|
1163
1079
|
return {
|
|
@@ -1168,45 +1084,50 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1168
1084
|
// 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
|
|
1169
1085
|
const wsService = ctx.get('workspaces')
|
|
1170
1086
|
const wsModel = wsService && wsService.list && typeof wsService.list.getSnapshot === 'function' ? wsService.list : null
|
|
1171
|
-
//
|
|
1172
|
-
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
|
|
1173
|
-
let standalone = mountStandaloneBoard(wsModel)
|
|
1174
|
-
let tabDisposer = null
|
|
1175
|
-
let sidebarTabOn = false
|
|
1176
|
-
// betterSidebar 服务软探测 + 晚注册追踪:出现/变化时按当前偏好重新决策
|
|
1087
|
+
// 挂载权交给用户:默认注册全局面板;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
|
|
1177
1088
|
let currentSidebar = ctx.get('betterSidebar')
|
|
1089
|
+
let sidebarTabOn = false
|
|
1090
|
+
let tabDisposer = null
|
|
1091
|
+
let panelDisposer = null
|
|
1092
|
+
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
|
|
1178
1093
|
const applyPref = () => {
|
|
1179
1094
|
const wantTab = sidebarTabOn && currentSidebar !== undefined
|
|
1180
1095
|
if (wantTab && tabDisposer === null) {
|
|
1181
|
-
|
|
1182
|
-
standalone = null
|
|
1096
|
+
if (panelDisposer) { panelDisposer(); panelDisposer = null }
|
|
1183
1097
|
tabDisposer = attachBoardTab(currentSidebar)
|
|
1184
1098
|
} else if (!wantTab && tabDisposer !== null) {
|
|
1185
1099
|
tabDisposer()
|
|
1186
1100
|
tabDisposer = null
|
|
1187
1101
|
}
|
|
1188
|
-
if (!wantTab &&
|
|
1102
|
+
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx, wsModel)
|
|
1189
1103
|
}
|
|
1190
1104
|
const prefWatch = setInterval(async () => {
|
|
1191
1105
|
try {
|
|
1192
1106
|
const outcome = await request('GET', 'status')
|
|
1193
1107
|
const next = readSidebarTabPref(outcome)
|
|
1194
1108
|
if (next !== sidebarTabOn) {
|
|
1109
|
+
const previous = sidebarTabOn
|
|
1195
1110
|
sidebarTabOn = next
|
|
1196
|
-
|
|
1111
|
+
try {
|
|
1112
|
+
applyPref()
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
// 形态切换失败回滚偏好,下轮轮询重试,避免入口消失后卡死
|
|
1115
|
+
sidebarTabOn = previous
|
|
1116
|
+
console.warn('[cron-board] 看板形态切换失败:', error)
|
|
1117
|
+
}
|
|
1197
1118
|
}
|
|
1198
1119
|
} catch { /* 轮询失败保形态不变,下轮重试 */ }
|
|
1199
1120
|
}, 5 * 1000)
|
|
1200
1121
|
ctx.effect(() => () => {
|
|
1201
1122
|
clearInterval(prefWatch)
|
|
1202
|
-
if (standalone) standalone()
|
|
1203
1123
|
if (tabDisposer) tabDisposer()
|
|
1124
|
+
if (panelDisposer) panelDisposer()
|
|
1204
1125
|
}, 'cron-board mount arbitration')
|
|
1205
1126
|
// 设置>插件页卡片:key 配对 ns,宿主按 describe 命名空间分发;effect 随 fiber 回收
|
|
1206
1127
|
ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
|
|
1207
1128
|
yield ctx.slots.register(
|
|
1208
|
-
{ name: 'settings.plugin.item', key:
|
|
1209
|
-
CronBoardPluginCard,
|
|
1129
|
+
{ name: 'settings.plugin.item', key: PANEL_ID, label: PANEL_LABEL },
|
|
1130
|
+
() => h(CronBoardPluginCard, { ctx }),
|
|
1210
1131
|
)
|
|
1211
1132
|
}), 'cron-board settings card')
|
|
1212
1133
|
ctx.inject(['betterSidebar'], (bsCtx) => {
|
|
@@ -60,28 +60,67 @@ test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
|
|
|
60
60
|
assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test('
|
|
64
|
-
// Given
|
|
65
|
-
//
|
|
66
|
-
// Then
|
|
63
|
+
test('启停开关乐观更新契约:即时翻转 + 失败回滚提示,禁止等待往返才反馈', () => {
|
|
64
|
+
// Given 受控 switch(checked 锁定 props)点击后无本地 state 变化不产生视觉反馈
|
|
65
|
+
// When toggleJob/toggleEnv 发起 PATCH
|
|
66
|
+
// Then 先乐观翻转,失败回滚并 notice,最后 reload 对账权威态(顺序锁定)
|
|
67
|
+
for (const fn of ['toggleJob', 'toggleEnv']) {
|
|
68
|
+
const start = source.indexOf('const ' + fn + ' = async')
|
|
69
|
+
assert.ok(start > 0, `缺少 ${fn}`)
|
|
70
|
+
const body = source.slice(start, source.indexOf('reload()', start))
|
|
71
|
+
assert.match(body, /apply(Job|Env)Patch\([^)]*, \{ enabled: !/m, `${fn} 必须先乐观翻转`)
|
|
72
|
+
assert.match(body, /if \(!outcome\.ok\)/m, `${fn} 必须检查 PATCH 结果`)
|
|
73
|
+
assert.match(body, /apply(Job|Env)Patch\([^)]*, \{ enabled: (job|row)\.enabled \}\)/m, `${fn} 失败必须回滚`)
|
|
74
|
+
assert.match(body, /切换失败:/m, `${fn} 失败必须提示,禁止静默`)
|
|
75
|
+
}
|
|
76
|
+
// 两个 Tab 均接收乐观更新通道(Board 下传)
|
|
77
|
+
assert.match(source, /applyJobPatch, wsModel/)
|
|
78
|
+
assert.match(source, /envs, reload, applyEnvPatch/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('client.js 主页面双形态挂载契约(默认宿主全局面板;设置开关手动移入侧边栏;无自动回退)', () => {
|
|
82
|
+
// Given 定时任务是全局工具,入口必须不依赖会话:main 为 keyed 全局中央面板插槽,
|
|
83
|
+
// sidebar.panellist 为侧栏面板行,点击切换由宿主 layout.selectPanel 承载
|
|
84
|
+
// When 插件以同 id 注册 main 条目 + panellist 条目;sidebarTab 偏好开启且服务在场时改注册 better-sidebar 扩展槽 tab
|
|
85
|
+
// Then 静态锁定:双条目注册形态、服务软探测、tab 单实例、偏好驱动仲裁、设置卡片;
|
|
86
|
+
// DOM 刮取挂载与会话内页签形态(conversation.view)禁止回流
|
|
67
87
|
assert.match(source, /window\.__ModuleLoader__\.load\(\{ id: '@mzzsfy\/dsh-cron-board', factory \}\)/)
|
|
88
|
+
assert.match(source, /ctx\.slots\.inject\('main'/)
|
|
89
|
+
assert.match(source, /name: 'main'/)
|
|
90
|
+
assert.match(source, /key: PANEL_ID/)
|
|
91
|
+
assert.match(source, /const PANEL_ID = 'cron-board'/)
|
|
92
|
+
assert.match(source, /const PANEL_LABEL = '定时任务'/)
|
|
93
|
+
assert.match(source, /ctx\.slots\.inject\('sidebar\.panellist'/)
|
|
94
|
+
assert.match(source, /name: 'sidebar\.panellist'/)
|
|
95
|
+
assert.match(source, /id: PANEL_ID/)
|
|
96
|
+
assert.match(source, /function registerMainPanel/)
|
|
97
|
+
assert.match(source, /cb-main/)
|
|
98
|
+
// 面板贡献必须可拆除:effect 工厂收集 inject disposer 并交付组合清理,
|
|
99
|
+
// 否则互斥仲裁拆不掉 main/panellist 条目,开关往返累积注册(审查阻断项)
|
|
100
|
+
assert.match(source, /injectDisposers\.push\(ctx\.slots\.inject\('main'/)
|
|
101
|
+
assert.match(source, /injectDisposers\.push\(ctx\.slots\.inject\('sidebar\.panellist'/)
|
|
102
|
+
assert.match(source, /return \(\) => \{ for \(const dispose of injectDisposers\) dispose\(\) \}/)
|
|
68
103
|
assert.match(source, /ctx\.get\('betterSidebar'\)/)
|
|
69
104
|
assert.match(source, /single: true/)
|
|
70
105
|
assert.match(source, /ctx\.inject\(\['betterSidebar'\]/)
|
|
71
|
-
|
|
72
|
-
assert.match(source, /dsh-panel-activate/)
|
|
73
|
-
assert.match(source, /function mountStandaloneBoard/)
|
|
74
|
-
// 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 在场可切换,否则禁用仅展示
|
|
106
|
+
// 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 服务在场可切换,否则禁用仅展示
|
|
75
107
|
assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
|
|
76
|
-
assert.match(source, /key:
|
|
108
|
+
assert.match(source, /key: PANEL_ID, label: PANEL_LABEL/)
|
|
77
109
|
assert.match(source, /function CronBoardPluginCard/)
|
|
78
110
|
assert.match(source, /'ui-settings'/)
|
|
79
111
|
assert.match(source, /sidebarReady/)
|
|
80
|
-
//
|
|
81
|
-
assert.match(source, /let standalone = mountStandaloneBoard\(wsModel\)/)
|
|
112
|
+
// 挂载仲裁:偏好(status.ui.sidebarTab)驱动全局面板/扩展槽互斥
|
|
82
113
|
assert.match(source, /applyPref\(\)/)
|
|
83
114
|
assert.match(source, /ui\.sidebarTab/)
|
|
84
115
|
// 不再自动回退:attach 后不订阅页签状态,无 openTabs/subscribeState 联动
|
|
85
116
|
assert.doesNotMatch(source, /subscribeState/)
|
|
86
117
|
assert.doesNotMatch(source, /openTabs/)
|
|
118
|
+
assert.doesNotMatch(source, /mountStandaloneBoard/)
|
|
119
|
+
assert.doesNotMatch(source, /data-cb-board-active/)
|
|
120
|
+
assert.doesNotMatch(source, /dsh-panel-activate/)
|
|
121
|
+
assert.doesNotMatch(source, /data-pane="conversation"/)
|
|
122
|
+
assert.doesNotMatch(source, /centerCol/)
|
|
123
|
+
assert.doesNotMatch(source, /dshDesktop/)
|
|
124
|
+
assert.doesNotMatch(source, /MutationObserver/)
|
|
125
|
+
assert.doesNotMatch(source, /conversation\.view/)
|
|
87
126
|
})
|