@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 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.1",
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
@@ -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
- await request('PATCH', 'jobs/' + job.id, { enabled: !job.enabled })
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
- await request('PATCH', 'envs/' + row.id, { enabled: !row.enabled })
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
- // —— 主页面挂载(形态对齐 dsh-taskboard,cb- 属性命名空间)——
958
- // 优先 better-sidebar 扩展槽(单实例 tab);未装回退主界面:侧栏入口行 + 中心列看板容器。
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 ENTRY_ATTR = 'data-cb-entry'
962
- const VIEW_ATTR = 'data-cb-view'
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
- // 渲染时重探:侧栏面晚于设置页挂载时,enabled 到达的重渲染会自动纠正禁用态
989
- const sidebarReady = Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR))
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 ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
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
- function sidebarRoot() {
1045
- const column = document.querySelector(SIDEBAR_ROOT_SELECTOR)
1046
- if (!column) return undefined
1047
- const logoOwner = column.querySelector('[class*="logoRow"]')
1048
- return (logoOwner && logoOwner.parentElement) || column.firstElementChild || undefined
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
- function placeEntry(root, entry) {
1061
- const button = newSessionButton(root)
1062
- if (!button) return false
1063
- if (entry.parentElement !== root) {
1064
- const row = button.closest('[class*="logoRow"]')
1065
- const base = (row && row.parentElement === root) ? row : button
1066
- const family = Array.from(root.children).filter((el) => el.matches(FAMILY_ENTRY_SELECTOR))
1067
- const anchor = family.length > 0 ? family[0] : (base.nextElementSibling || null)
1068
- root.insertBefore(entry, anchor)
1069
- }
1070
- return true
1071
- }
1072
-
1073
- // 主界面形态:入口行 + 中心列容器 + html 属性显隐 + 面板互斥;返回幂等 disposer
1074
- function mountStandaloneBoard(wsModel) {
1075
- let disposed = false
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
- // 挂载权交给用户:默认永远主界面;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
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
- standalone()
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 && standalone === null) standalone = mountStandaloneBoard(wsModel)
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
- applyPref()
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: 'cron-board', label: '定时任务' },
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('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
64
- // Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
65
- // 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)
66
- // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件、偏好驱动仲裁
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
- assert.match(source, /data-cb-board-active/)
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: 'cron-board', label: '定时任务'/)
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
- // 挂载仲裁:apply 无条件先落主界面形态;偏好(status.ui.sidebarTab)驱动接入/退出
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
  })