@mzzsfy/dsh-cron-board 0.3.2 → 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 CHANGED
@@ -18,12 +18,12 @@
18
18
 
19
19
  ## 界面挂载
20
20
 
21
- 挂载权交给用户,运行期不自动切换:
21
+ 挂载走宿主官方契约(main 全局面板插槽 / sidebar.panellist 侧栏面板行 / better-sidebar 服务),不刮宿主 DOM,宿主 UI 变动由契约消化:
22
22
 
23
- - **默认:主界面**。无论是否安装 dsh-better-sidebar,看板始终在主界面(原生侧栏「定时任务」入口 + 中心列看板,激活时隐藏会话区,面板互斥)
24
- - **手动移入**:设置>插件 页「定时任务」卡片(官方 PluginCard 形制:折叠卡壳,cb-pc 镜像)提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab(显式 openTab 落入工作台)并拆除主界面形态
25
- - **不自动回退**:移入后页签关闭/类型禁用均不再自动返回主界面;把开关关掉即回到主界面形态(轮询感知,约一个 tick 周期内生效)
26
- - **开关可用性**:已安装 better-sidebar 时可切换;未安装时开关呈禁用态仅展示(保持主界面,无副作用)
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
3
  "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.3.2",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
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,.cb-entry:focus-visible{outline:2px solid color-mix(in srgb, var(--cb-accent) 70%, white);outline-offset:2px}
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
- /* 容器查询:better-sidebar tab 与主视图宽度差异大,跟随面板自身宽度而非视口。
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
@@ -973,23 +972,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
973
972
  tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
974
973
  }
975
974
 
976
- // —— 主页面挂载(形态对齐 dsh-taskboard,cb- 属性命名空间)——
977
- // 优先 better-sidebar 扩展槽(单实例 tab);未装回退主界面:侧栏入口行 + 中心列看板容器。
975
+ // —— 主页面挂载:宿主官方全局面板契约(main keyed 插槽 + sidebar.panellist 侧栏入口)——
976
+ // 侧栏行与中央面板均由宿主渲染:点击入口行宿主调 layout.selectPanel 切换,会话保持不动;
977
+ // 切回会话走官方路径(会话行/新会话 openSession → selectPanel(null))。禁 DOM 刮取。
978
+ // better-sidebar 偏好开启时改注册其扩展槽 tab(服务契约,同样非 DOM 刮取),两形态互斥。
978
979
 
979
980
  const TAB_ID = 'cron-board:board'
980
- const ENTRY_ATTR = 'data-cb-entry'
981
- const VIEW_ATTR = 'data-cb-view'
982
- const BOARD_ACTIVE_ATTR = 'data-cb-board-active'
983
- const PANEL_ACTIVATE_EVENT = 'dsh-panel-activate'
984
- const PANEL_NAME = 'dsh-cron-board'
985
- 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>'
986
- // 三代布局壳兼容:dev data-pane / 官方 CSS-Module centerCol / DSH Desktop 扩展面
987
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"], .dshDesktopConversationSurface'
988
- const SIDEBAR_ROOT_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface'
989
- // 家族面板入口条目(taskboard 系),插入时排其前,避免重渲染后顺序漂移
990
- const FAMILY_ENTRY_SELECTOR = '[data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry], [' + ENTRY_ATTR + ']'
991
- const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
992
- const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
981
+ const PANEL_ID = 'cron-board'
982
+ const PANEL_LABEL = '定时任务'
993
983
 
994
984
  // 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
995
985
  const CARD_TITLE = '定时任务'
@@ -1000,12 +990,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1000
990
  stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
1001
991
  h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
1002
992
 
1003
- function CronBoardPluginCard() {
993
+ function CronBoardPluginCard({ ctx }) {
1004
994
  const [enabled, setEnabled] = useState(null)
1005
995
  const [busy, setBusy] = useState(false)
1006
996
  const [open, setOpen] = useState(false)
1007
- // 渲染时重探:侧栏面晚于设置页挂载时,enabled 到达的重渲染会自动纠正禁用态
1008
- const sidebarReady = Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR))
997
+ // 渲染时重探服务在场:better-sidebar 晚于设置页装载时,重渲染自动纠正禁用态
998
+ const sidebarReady = ctx.get('betterSidebar') !== undefined
1009
999
  useEffect(() => {
1010
1000
  let alive = true
1011
1001
  request('GET', 'status').then((outcome) => {
@@ -1038,7 +1028,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1038
1028
  ariaLabel: CARD_TOGGLE_LABEL,
1039
1029
  })),
1040
1030
  h('p', { className: 'cb-pc__hint' },
1041
- sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
1031
+ sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时使用全局面板。' : '需已装载 better-sidebar 后方可切换;未装载时看板始终使用全局面板。'))) : null)
1042
1032
  }
1043
1033
 
1044
1034
  // better-sidebar tab:注册即单实例(single);只注册不主动打开,
@@ -1047,7 +1037,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1047
1037
  return ctx.effect(() => {
1048
1038
  const disposeTab = service.registerTab({
1049
1039
  id: TAB_ID,
1050
- title: '定时任务',
1040
+ title: PANEL_LABEL,
1051
1041
  icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
1052
1042
  stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
1053
1043
  h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
@@ -1060,123 +1050,30 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1060
1050
  }, 'cron-board sidebar tab')
1061
1051
  }
1062
1052
 
1063
- function sidebarRoot() {
1064
- const column = document.querySelector(SIDEBAR_ROOT_SELECTOR)
1065
- if (!column) return undefined
1066
- const logoOwner = column.querySelector('[class*="logoRow"]')
1067
- return (logoOwner && logoOwner.parentElement) || column.firstElementChild || undefined
1068
- }
1069
-
1070
- function newSessionButton(root) {
1071
- const nested = root.querySelector('button[class*="newSession"]')
1072
- if (nested) return nested
1073
- for (const child of root.children) {
1074
- if (child instanceof HTMLButtonElement && !child.matches('[' + ENTRY_ATTR + ']')) return child
1075
- }
1076
- return root.querySelector('button[aria-label="新建会话"], button[aria-label*="新会话"], button[aria-label*="new session" i]') || undefined
1077
- }
1078
-
1079
- function placeEntry(root, entry) {
1080
- const button = newSessionButton(root)
1081
- if (!button) return false
1082
- if (entry.parentElement !== root) {
1083
- const row = button.closest('[class*="logoRow"]')
1084
- const base = (row && row.parentElement === root) ? row : button
1085
- const family = Array.from(root.children).filter((el) => el.matches(FAMILY_ENTRY_SELECTOR))
1086
- const anchor = family.length > 0 ? family[0] : (base.nextElementSibling || null)
1087
- root.insertBefore(entry, anchor)
1088
- }
1089
- return true
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' }))
1090
1059
  }
1091
1060
 
1092
- // 主界面形态:入口行 + 中心列容器 + html 属性显隐 + 面板互斥;返回幂等 disposer
1093
- function mountStandaloneBoard(wsModel) {
1094
- let disposed = false
1095
- let entry = null
1096
- let rootEl = undefined
1097
- let placed = false
1098
- let rootObserver = null
1099
- let viewRoot = null
1100
- let viewEl = null
1101
-
1102
- const setOpen = (open) => {
1103
- if (open) {
1104
- for (const attr of OTHER_PANEL_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
1105
- document.documentElement.setAttribute(BOARD_ACTIVE_ATTR, '')
1106
- document.dispatchEvent(new CustomEvent(PANEL_ACTIVATE_EVENT, { detail: PANEL_NAME }))
1107
- entry.dataset.active = 'true'
1108
- } else {
1109
- document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
1110
- entry.dataset.active = 'false'
1111
- }
1112
- }
1113
- const isOpen = () => document.documentElement.hasAttribute(BOARD_ACTIVE_ATTR)
1114
-
1115
- const ensureEntry = () => {
1116
- if (rootEl !== undefined && !rootEl.isConnected) { placed = false; rootEl = undefined }
1117
- if (placed && document.body.contains(entry)) return true
1118
- placed = false
1119
- rootEl = sidebarRoot()
1120
- if (!rootEl) return false
1121
- placed = placeEntry(rootEl, entry)
1122
- if (placed && rootObserver) rootObserver.observe(rootEl, { childList: true, subtree: true })
1123
- return placed
1124
- }
1125
-
1126
- const ensureView = () => {
1127
- if (viewEl && viewEl.isConnected) return true
1128
- const column = document.querySelector(CONVERSATION_COLUMN_SELECTOR)
1129
- if (!column) return false
1130
- viewEl = document.createElement('div')
1131
- viewEl.setAttribute(VIEW_ATTR, '')
1132
- viewEl.className = 'cb-view'
1133
- column.appendChild(viewEl)
1134
- viewRoot = ReactDOMClient.createRoot(viewEl)
1135
- viewRoot.render(React.createElement(CronBoardPanel, { visible: true, wsModel }))
1136
- return true
1137
- }
1138
-
1139
- const onClickDocument = (event) => {
1140
- if (!isOpen()) return
1141
- const target = event.target
1142
- if (target && target.closest && target.closest('[' + ENTRY_ATTR + ']')) return
1143
- if (target && target.closest && target.closest(SIDEBAR_ROW_SELECTOR)) setOpen(false)
1144
- }
1145
- const onOtherActivate = (event) => {
1146
- if (event.detail !== PANEL_NAME && isOpen()) setOpen(false)
1147
- }
1148
- entry = document.createElement('button')
1149
- entry.type = 'button'
1150
- entry.setAttribute(ENTRY_ATTR, '')
1151
- entry.className = 'cb-entry'
1152
- entry.setAttribute('aria-label', '定时任务')
1153
- entry.innerHTML = '<span class="cb-entry-icon">' + ENTRY_ICON + '</span><span>定时任务</span>'
1154
- entry.addEventListener('click', () => setOpen(!isOpen()))
1155
- document.addEventListener('click', onClickDocument, true)
1156
- document.addEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
1157
- const waitObserver = new MutationObserver(() => { ensureEntry(); ensureView() })
1158
- waitObserver.observe(document.body, { childList: true, subtree: true })
1159
- rootObserver = new MutationObserver(() => {
1160
- if (rootEl === undefined || !rootEl.isConnected) { ensureEntry(); return }
1161
- if (!rootEl.contains(entry)) placeEntry(rootEl, entry)
1162
- })
1163
- const retry = setInterval(() => { ensureEntry(); ensureView() }, 2000)
1164
- ensureEntry()
1165
- ensureView()
1166
-
1167
- return () => {
1168
- if (disposed) return
1169
- disposed = true
1170
- clearInterval(retry)
1171
- waitObserver.disconnect()
1172
- if (rootObserver) rootObserver.disconnect()
1173
- document.removeEventListener('click', onClickDocument, true)
1174
- document.removeEventListener(PANEL_ACTIVATE_EVENT, onOtherActivate)
1175
- document.documentElement.removeAttribute(BOARD_ACTIVE_ATTR)
1176
- entry.remove()
1177
- if (viewRoot) viewRoot.unmount()
1178
- if (viewEl) viewEl.remove()
1179
- }
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')
1180
1077
  }
1181
1078
 
1182
1079
  return {
@@ -1187,45 +1084,50 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1187
1084
  // 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
1188
1085
  const wsService = ctx.get('workspaces')
1189
1086
  const wsModel = wsService && wsService.list && typeof wsService.list.getSnapshot === 'function' ? wsService.list : null
1190
- // 挂载权交给用户:默认永远主界面;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
1191
- const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
1192
- let standalone = mountStandaloneBoard(wsModel)
1193
- let tabDisposer = null
1194
- let sidebarTabOn = false
1195
- // betterSidebar 服务软探测 + 晚注册追踪:出现/变化时按当前偏好重新决策
1087
+ // 挂载权交给用户:默认注册全局面板;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
1196
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)
1197
1093
  const applyPref = () => {
1198
1094
  const wantTab = sidebarTabOn && currentSidebar !== undefined
1199
1095
  if (wantTab && tabDisposer === null) {
1200
- standalone()
1201
- standalone = null
1096
+ if (panelDisposer) { panelDisposer(); panelDisposer = null }
1202
1097
  tabDisposer = attachBoardTab(currentSidebar)
1203
1098
  } else if (!wantTab && tabDisposer !== null) {
1204
1099
  tabDisposer()
1205
1100
  tabDisposer = null
1206
1101
  }
1207
- if (!wantTab && standalone === null) standalone = mountStandaloneBoard(wsModel)
1102
+ if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx, wsModel)
1208
1103
  }
1209
1104
  const prefWatch = setInterval(async () => {
1210
1105
  try {
1211
1106
  const outcome = await request('GET', 'status')
1212
1107
  const next = readSidebarTabPref(outcome)
1213
1108
  if (next !== sidebarTabOn) {
1109
+ const previous = sidebarTabOn
1214
1110
  sidebarTabOn = next
1215
- applyPref()
1111
+ try {
1112
+ applyPref()
1113
+ } catch (error) {
1114
+ // 形态切换失败回滚偏好,下轮轮询重试,避免入口消失后卡死
1115
+ sidebarTabOn = previous
1116
+ console.warn('[cron-board] 看板形态切换失败:', error)
1117
+ }
1216
1118
  }
1217
1119
  } catch { /* 轮询失败保形态不变,下轮重试 */ }
1218
1120
  }, 5 * 1000)
1219
1121
  ctx.effect(() => () => {
1220
1122
  clearInterval(prefWatch)
1221
- if (standalone) standalone()
1222
1123
  if (tabDisposer) tabDisposer()
1124
+ if (panelDisposer) panelDisposer()
1223
1125
  }, 'cron-board mount arbitration')
1224
1126
  // 设置>插件页卡片:key 配对 ns,宿主按 describe 命名空间分发;effect 随 fiber 回收
1225
1127
  ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
1226
1128
  yield ctx.slots.register(
1227
- { name: 'settings.plugin.item', key: 'cron-board', label: '定时任务' },
1228
- CronBoardPluginCard,
1129
+ { name: 'settings.plugin.item', key: PANEL_ID, label: PANEL_LABEL },
1130
+ () => h(CronBoardPluginCard, { ctx }),
1229
1131
  )
1230
1132
  }), 'cron-board settings card')
1231
1133
  ctx.inject(['betterSidebar'], (bsCtx) => {
@@ -78,28 +78,49 @@ test('启停开关乐观更新契约:即时翻转 + 失败回滚提示,禁止等
78
78
  assert.match(source, /envs, reload, applyEnvPatch/)
79
79
  })
80
80
 
81
- test('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
82
- // Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
83
- // 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)
84
- // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件、偏好驱动仲裁
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)禁止回流
85
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\(\) \}/)
86
103
  assert.match(source, /ctx\.get\('betterSidebar'\)/)
87
104
  assert.match(source, /single: true/)
88
105
  assert.match(source, /ctx\.inject\(\['betterSidebar'\]/)
89
- assert.match(source, /data-cb-board-active/)
90
- assert.match(source, /dsh-panel-activate/)
91
- assert.match(source, /function mountStandaloneBoard/)
92
- // 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 在场可切换,否则禁用仅展示
106
+ // 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 服务在场可切换,否则禁用仅展示
93
107
  assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
94
- assert.match(source, /key: 'cron-board', label: '定时任务'/)
108
+ assert.match(source, /key: PANEL_ID, label: PANEL_LABEL/)
95
109
  assert.match(source, /function CronBoardPluginCard/)
96
110
  assert.match(source, /'ui-settings'/)
97
111
  assert.match(source, /sidebarReady/)
98
- // 挂载仲裁:apply 无条件先落主界面形态;偏好(status.ui.sidebarTab)驱动接入/退出
99
- assert.match(source, /let standalone = mountStandaloneBoard\(wsModel\)/)
112
+ // 挂载仲裁:偏好(status.ui.sidebarTab)驱动全局面板/扩展槽互斥
100
113
  assert.match(source, /applyPref\(\)/)
101
114
  assert.match(source, /ui\.sidebarTab/)
102
115
  // 不再自动回退:attach 后不订阅页签状态,无 openTabs/subscribeState 联动
103
116
  assert.doesNotMatch(source, /subscribeState/)
104
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/)
105
126
  })