@mzzsfy/dsh-cron-board 0.1.1 → 0.2.1

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
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-cron-board
2
2
 
3
- 轻量级青龙面板 dsh 插件:环境变量集中管理 + 定时任务(cron)调度,执行体支持两类——本地脚本(dsh 宿主进程内 spawn)与 dsh 会话任务(定时在指定时间段向 dsh 会话发起任务,支持「每次新建会话」或「固定同一会话」)。
3
+ 轻量级定时任务看板 dsh 插件:环境变量集中管理 + 定时任务(cron)调度,执行体支持两类——本地脚本(dsh 宿主进程内 spawn)与 dsh 会话任务(定时在指定时间段向 dsh 会话发起任务,支持「每次新建会话」或「固定同一会话」)。
4
4
 
5
5
  设计文档见仓库 `docs/progress/feat-cron-board.md`(架构 / 数据模型 / API 契约 / BDD 场景 / 里程碑)。
6
6
 
@@ -18,19 +18,16 @@
18
18
 
19
19
  ## 界面挂载
20
20
 
21
- 三形态自适应,运行时自动切换:
21
+ 挂载权交给用户,运行期不自动切换:
22
22
 
23
- | 场景 | 形态 |
24
- |---|---|
25
- | 未安装 dsh-better-sidebar | 主界面:原生侧栏「定时任务」入口 + 中心列看板(激活时隐藏会话区,面板互斥) |
26
- | 已安装且当前会话打开了「定时任务」页签 | 扩展槽:better-sidebar tab 承载看板,主界面形态拆除 |
27
- | 已安装但页签未打开(未开过 / 被用户关闭 / 类型在 side card 设置中被禁用) | 主界面形态接管 |
28
-
29
- 页签开关实时联动(subscribeState),无需刷新;扩展槽缺失时主界面形态即唯一入口。
23
+ - **默认:主界面**。无论是否安装 dsh-better-sidebar,看板始终在主界面(原生侧栏「定时任务」入口 + 中心列看板,激活时隐藏会话区,面板互斥)
24
+ - **手动移入**:设置>插件 页「定时任务」卡片(官方 PluginCard 形制:折叠卡壳,cb-pc 镜像)提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab(显式 openTab 落入工作台)并拆除主界面形态
25
+ - **不自动回退**:移入后页签关闭/类型禁用均不再自动返回主界面;把开关关掉即回到主界面形态(轮询感知,约一个 tick 周期内生效)
26
+ - **开关可用性**:已安装 better-sidebar 时可切换;未安装时开关呈禁用态仅展示(保持主界面,无副作用)
30
27
 
31
28
  ## 配置
32
29
 
33
- 设置页「定时任务」分区(`cron-board` 命名空间),四项均即时生效:
30
+ 设置页>插件 页「定时任务」卡片(`cron-board` 命名空间),各项均即时生效:`sidebarTab` 经卡片开关写入;其余项经配置文件或设置接口修改。
34
31
 
35
32
  | 配置项 | 说明 | 默认值 |
36
33
  |---|---|---|
@@ -38,6 +35,7 @@
38
35
  | `maxConcurrent` | 全局同时运行的执行单元上限 | 2 |
39
36
  | `logKeepPerJob` | 每任务保留的运行日志份数(超出按文件修改时间裁剪) | 200 |
40
37
  | `maskEnvInPrompt` | 会话任务投递文本中环境变量值打码 | 关 |
38
+ | `sidebarTab` | 看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面) | 关 |
41
39
 
42
40
  ## 依赖
43
41
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
- "description": "轻量级青龙面板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.1.1",
3
+ "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/api.mjs CHANGED
@@ -24,6 +24,7 @@ export const MESSAGES = {
24
24
  badWindowFormat: '窗口时间格式须为 HH:mm',
25
25
  jobNotFound: '任务不存在',
26
26
  runNotFound: '运行记录不存在',
27
+ sessionDisabled: '宿主会话服务不可用,会话任务通道已禁用',
27
28
  systemError: '操作失败(系统级错误,详见服务端日志)',
28
29
  }
29
30
 
@@ -164,7 +165,7 @@ function normalizeJob(body) {
164
165
  return normalized
165
166
  }
166
167
 
167
- export function createApi({ store, logger, executor, scheduler, periodic, logSystem }) {
168
+ export function createApi({ store, logger, executor, scheduler, periodic, sessionState, readSidebarTab, updateUiSettings, logSystem }) {
168
169
 
169
170
  const routes = [
170
171
  {
@@ -278,9 +279,27 @@ export function createApi({ store, logger, executor, scheduler, periodic, logSys
278
279
  timerReason: periodic && periodic.reason ? periodic.reason : null,
279
280
  scheduler: scheduler ? scheduler.status() : { active: 0, queued: 0 },
280
281
  nextAt: scheduler && scheduler.nextRunAt ? scheduler.nextRunAt() : null,
282
+ session: sessionState
283
+ ? { ready: Boolean(sessionState.ready), disabled: Boolean(sessionState.disabled) }
284
+ : { ready: false, disabled: false },
285
+ ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false },
281
286
  })
282
287
  },
283
288
  },
289
+ {
290
+ // 界面偏好写入(sidebarTab 等):settings.update 服务端持久化,即时生效
291
+ method: 'POST',
292
+ segments: ['ui-settings'],
293
+ handler: async ({ req, res }) => {
294
+ const body = await readJsonBody(req)
295
+ const patch = {}
296
+ if (typeof body.sidebarTab === 'boolean') patch.sidebarTab = body.sidebarTab
297
+ if (!Object.keys(patch).length) return sendJson(res, 400, { error: '无有效字段' })
298
+ if (typeof updateUiSettings !== 'function') return sendJson(res, 200, { ok: false, error: '设置服务不可用' })
299
+ updateUiSettings(patch)
300
+ sendJson(res, 200, { ok: true, ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false } })
301
+ },
302
+ },
284
303
  {
285
304
  // 编辑表单实时预览:croner 服务端解析,前端不引解析依赖(定案 §6.2)
286
305
  method: 'POST',
@@ -307,6 +326,9 @@ export function createApi({ store, logger, executor, scheduler, periodic, logSys
307
326
  handler: async ({ req, res }) => {
308
327
  const body = await readJsonBody(req)
309
328
  const fields = normalizeJob(body)
329
+ if (fields.kind === 'session' && sessionState && sessionState.disabled) {
330
+ return sendJson(res, 400, { error: MESSAGES.sessionDisabled })
331
+ }
310
332
  const row = await store.jobs.create(fields)
311
333
  sendJson(res, 200, row)
312
334
  },
package/src/client.js CHANGED
@@ -136,6 +136,23 @@ body{
136
136
  .cb-switch input[type="checkbox"]:disabled + .cb-switch__track{opacity:.4;cursor:not-allowed}
137
137
  .cb-switch:not(:has(input[type="checkbox"]:disabled)):hover .cb-switch__track{background:var(--cb-accent-deep);opacity:.85}
138
138
  .cb-switch input[type="checkbox"]:checked:not(:disabled) + .cb-switch__track:hover{background:var(--cb-accent-deep)}
139
+ /* 设置>插件页卡片(cb-pc):镜像官方 PluginCard/SubagentModelSelectionCard 形制,数值与令牌直取官方 CSS Module;
140
+ 官方壳未导出,slot 契约声明卡片外观归插件自持,同页同 dsw-alias 令牌即同观感(含深色主题) */
141
+ .cb-pc{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}
142
+ .cb-pc:hover{border-color:var(--dsw-alias-label-dimmed)}
143
+ .cb-pc--open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
144
+ .cb-pc__head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
145
+ .cb-pc__head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
146
+ .cb-pc__text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
147
+ .cb-pc__name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
148
+ .cb-pc__desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
149
+ .cb-pc__chevron{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex;transition:transform .16s}
150
+ .cb-pc--open .cb-pc__chevron{transform:rotate(180deg)}
151
+ .cb-pc__body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
152
+ .cb-pc__row{gap:6px;padding:12px 0;display:grid}
153
+ .cb-pc__rowLine{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:flex-start;gap:16px;font-size:13px;line-height:1.5;display:flex}
154
+ .cb-pc__rowLabel{flex:1;min-width:0}
155
+ .cb-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
139
156
  .cb-table{display:flex;flex-direction:column;gap:var(--cb-space-1);font-size:var(--cb-font-md)}
140
157
  .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
158
  .cb-row:hover{background:var(--cb-hover)}
@@ -145,7 +162,7 @@ body{
145
162
  .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
163
  @keyframes cb-fade-in{from{opacity:0}to{opacity:1}}
147
164
  @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}}
165
+ @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,.cb-pc,.cb-pc__chevron{transition:none}}
149
166
  .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
167
  .cb-modal-title{font-size:16px;font-weight:600}
151
168
  .cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-5)}
@@ -237,7 +254,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
237
254
  // 规约 switch:input 锚定状态选择器,视觉由 track+thumb 呈现
238
255
  function switchToggle(props) {
239
256
  return h('label', { className: 'cb-switch' },
240
- h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange }),
257
+ h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange,
258
+ 'aria-label': props.ariaLabel }),
241
259
  h('span', { className: 'cb-switch__track' }, h('span', { className: 'cb-switch__thumb' })),
242
260
  props.label ? h('span', { className: 'cb-meta' }, props.label) : null)
243
261
  }
@@ -753,19 +771,77 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
753
771
  const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
754
772
  const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
755
773
 
756
- // better-sidebar tab:注册即单实例(single),visible 门控轮询;wsModel 供会话任务选工作区
774
+ // 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
775
+ const CARD_TITLE = '定时任务'
776
+ const CARD_TOGGLE_LABEL = '看板移入 better-sidebar 侧边栏'
777
+ const readSidebarTabPref = (outcome) => outcome.ok && outcome.data &&
778
+ typeof outcome.data.ui === 'object' && outcome.data.ui !== null && outcome.data.ui.sidebarTab === true
779
+ const CHEVRON_DOWN = () => h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, fill: 'none',
780
+ stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
781
+ h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
782
+
783
+ function CronBoardPluginCard() {
784
+ const [enabled, setEnabled] = useState(null)
785
+ const [busy, setBusy] = useState(false)
786
+ const [open, setOpen] = useState(false)
787
+ // 渲染时重探:侧栏面晚于设置页挂载时,enabled 到达的重渲染会自动纠正禁用态
788
+ const sidebarReady = Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR))
789
+ useEffect(() => {
790
+ let alive = true
791
+ request('GET', 'status').then((outcome) => {
792
+ if (alive) setEnabled(readSidebarTabPref(outcome))
793
+ })
794
+ return () => { alive = false }
795
+ }, [])
796
+ const toggle = async () => {
797
+ if (busy || enabled === null || !sidebarReady) return
798
+ setBusy(true)
799
+ const outcome = await request('POST', 'ui-settings', { sidebarTab: !enabled })
800
+ if (outcome.ok && outcome.data && outcome.data.ui) setEnabled(outcome.data.ui.sidebarTab === true)
801
+ setBusy(false)
802
+ }
803
+ return h('li', { className: 'cb-pc' + (open ? ' cb-pc--open' : '') },
804
+ h('button', { type: 'button', className: 'cb-pc__head', 'aria-expanded': open,
805
+ 'aria-label': (open ? '折叠' : '展开') + ': ' + CARD_TITLE, onClick: () => setOpen(!open) },
806
+ h('span', { className: 'cb-pc__text' },
807
+ h('span', { className: 'cb-pc__name' }, CARD_TITLE),
808
+ h('span', { className: 'cb-pc__desc' }, '计划任务看板、环境变量与执行日志')),
809
+ h('span', { className: 'cb-pc__chevron' }, h(CHEVRON_DOWN))),
810
+ open ? h('div', { className: 'cb-pc__body' },
811
+ h('div', { className: 'cb-pc__row' },
812
+ h('div', { className: 'cb-pc__rowLine' },
813
+ h('span', { className: 'cb-pc__rowLabel' }, CARD_TOGGLE_LABEL),
814
+ switchToggle({
815
+ checked: enabled === true,
816
+ disabled: busy || enabled === null || !sidebarReady,
817
+ onChange: toggle,
818
+ ariaLabel: CARD_TOGGLE_LABEL,
819
+ })),
820
+ h('p', { className: 'cb-pc__hint' },
821
+ sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
822
+ }
823
+
824
+ // better-sidebar tab:注册即单实例(single);v0.19.0 起注册只入「可打开」目录,
825
+ // 须显式 openTab 落入底部工作台;wsModel 供会话任务选工作区
757
826
  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')
827
+ return ctx.effect(() => {
828
+ const disposeTab = service.registerTab({
829
+ id: TAB_ID,
830
+ title: '定时任务',
831
+ icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
832
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
833
+ h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
834
+ h('path', { d: 'M6 2v12M10 2v12' })),
835
+ order: 46,
836
+ single: true,
837
+ component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
838
+ })
839
+ // 无会话时 openTab 静默不落,tab 仍注册在册(新建标签页菜单可见)
840
+ if (typeof service.openTab === 'function') {
841
+ try { service.openTab({ type: TAB_ID }) } catch { /* 打开失败不回滚注册 */ }
842
+ }
843
+ return disposeTab
844
+ }, 'cron-board sidebar tab')
769
845
  }
770
846
 
771
847
  function sidebarRoot() {
@@ -888,60 +964,59 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
888
964
  }
889
965
 
890
966
  return {
891
- // workspaces 由宿主 client 常驻提供;声明注入保证 apply 时已就绪(缺失则 fiber 未激活,即干净禁用)
892
- inject: ['workspaces'],
967
+ // workspaces 由宿主 client 常驻提供;slots 供设置页分区挂载(缺失则 fiber 未激活,即干净禁用)
968
+ inject: ['workspaces', 'slots'],
893
969
  apply(ctx) {
894
970
  ensureStyle(document)
895
971
  // 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
896
972
  const wsService = ctx.get('workspaces')
897
973
  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')
974
+ // 挂载权交给用户:默认永远主界面;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
975
+ const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
976
+ let standalone = mountStandaloneBoard(wsModel)
977
+ let tabDisposer = null
978
+ let sidebarTabOn = false
979
+ // betterSidebar 服务软探测 + 晚注册追踪:出现/变化时按当前偏好重新决策
980
+ let currentSidebar = ctx.get('betterSidebar')
981
+ const applyPref = () => {
982
+ const wantTab = sidebarTabOn && currentSidebar !== undefined
983
+ if (wantTab && tabDisposer === null) {
984
+ standalone()
985
+ standalone = null
986
+ tabDisposer = attachBoardTab(currentSidebar)
987
+ } else if (!wantTab && tabDisposer !== null) {
988
+ tabDisposer()
989
+ tabDisposer = null
924
990
  }
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
991
+ if (!wantTab && standalone === null) standalone = mountStandaloneBoard(wsModel)
935
992
  }
936
- // 路径二:服务缺席先落主界面形态,服务就绪后拆主界面、接扩展槽(含页签联动)
937
- let standalone = mountStandaloneBoard(wsModel)
938
- let upgraded = false
939
- ctx.effect(() => () => { if (!upgraded) standalone() }, 'cron-board standalone board')
993
+ const prefWatch = setInterval(async () => {
994
+ try {
995
+ const outcome = await request('GET', 'status')
996
+ const next = readSidebarTabPref(outcome)
997
+ if (next !== sidebarTabOn) {
998
+ sidebarTabOn = next
999
+ applyPref()
1000
+ }
1001
+ } catch { /* 轮询失败保形态不变,下轮重试 */ }
1002
+ }, 5 * 1000)
1003
+ ctx.effect(() => () => {
1004
+ clearInterval(prefWatch)
1005
+ if (standalone) standalone()
1006
+ if (tabDisposer) tabDisposer()
1007
+ }, 'cron-board mount arbitration')
1008
+ // 设置>插件页卡片:key 配对 ns,宿主按 describe 命名空间分发;effect 随 fiber 回收
1009
+ ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
1010
+ yield ctx.slots.register(
1011
+ { name: 'settings.plugin.item', key: 'cron-board', label: '定时任务' },
1012
+ CronBoardPluginCard,
1013
+ )
1014
+ }), 'cron-board settings card')
940
1015
  ctx.inject(['betterSidebar'], (bsCtx) => {
941
- upgraded = true
942
- standalone()
943
- attachBoardTab(bsCtx.betterSidebar)
1016
+ currentSidebar = bsCtx.betterSidebar
1017
+ applyPref()
944
1018
  })
1019
+ applyPref()
945
1020
  },
946
1021
  }
947
1022
  }
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-cron-board Host 半区:轻量级青龙面板。环境变量集中管理 + 定时任务(cron)调度,
1
+ // dsh-cron-board Host 半区:轻量级定时任务看板。环境变量集中管理 + 定时任务(cron)调度,
2
2
  // 执行体两类:本地脚本(宿主进程内 spawn)与 dsh 会话任务(定时向 dsh 会话投递任务文本)。
3
3
  // 模块分层:store(持久化)/ logger(日志)/ executor(执行链)/ scheduler(调度)/ api(路由)。
4
4
  // timer 为软依赖(嵌套注入,缺失降级只影响自动调度);settings 缺省时读默认常量。
@@ -18,10 +18,10 @@ import { createStore } from './store.mjs'
18
18
 
19
19
  export const name = 'dsh-cron-board'
20
20
 
21
- // 会话任务依赖的 sessionController 由 dsh-web-app 的 session-controller 条目挂载
22
- // (0.1.1-rc.2 无此服务,inject 声明会让 fiber 永久 pending,旧版 boot 对 pending
23
- // 条目抛错杀掉整个进程,连带已监听的 web 一起退出);host 服务为同步注册,apply 内
24
- // 探测无时序竞态:缺失即打日志干净禁用,整体不激活(含脚本任务)的语义不变
21
+ // 会话任务依赖的 sessionController 由 dsh-web-app 的 session-controller 条目挂载,
22
+ // 注册晚于同批条目的 apply(异步装配),且 0.1.1-rc.2 无此服务:inject 声明会在旧版
23
+ // 永久 pending 拖死 boot,apply 内同步探测又会抢在挂载前误判缺失(0.1.2-rc.1 实测
24
+ // 404)。唯一两头正确的形态:启动后异步轮询等待服务出现再装配,超时仍缺失即干净禁用
25
25
  export const inject = ['webServer']
26
26
 
27
27
  // 数据目录 env 覆盖:测试注入临时目录(对齐 dsh-usage-panel 先例)
@@ -29,15 +29,18 @@ const DATA_DIR_ENV = 'DSH_CRON_BOARD_DATA_DIR'
29
29
  const API_PREFIX = '/api/cron-board'
30
30
  const SETTINGS_NS = 'cron-board'
31
31
  const TIMER_UNAVAILABLE_REASON = '宿主定时服务不可用,自动调度已停用'
32
- const SESSION_UNAVAILABLE_REASON = '宿主会话服务 sessionController 不可用(需 dsh-web-app 挂载 session-controller,0.1.1-rc.2 及更早版本缺失),插件整体禁用'
32
+ const SESSION_UNAVAILABLE_REASON = '宿主会话服务 sessionController 不可用(需 dsh-web-app 挂载 session-controller,0.1.1-rc.2 及更早版本缺失),会话任务通道禁用,脚本任务不受影响'
33
+ const SESSION_WAIT_MS = 15 * 1000
34
+ const SESSION_POLL_MS = 250
33
35
  const DEFAULT_LOG_KEEP_PER_JOB = 200
34
36
 
35
- // 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码
37
+ // 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码 / 侧边栏移入偏好
36
38
  const SETTINGS_SCHEMA = schemastery.object({
37
39
  tickSeconds: schemastery.number().min(MIN_TICK_MS / 1000).step(1).default(DEFAULT_TICK_MS / 1000),
38
40
  maxConcurrent: schemastery.number().min(1).step(1).default(DEFAULT_MAX_CONCURRENT),
39
41
  logKeepPerJob: schemastery.number().min(1).step(1).default(200),
40
42
  maskEnvInPrompt: schemastery.boolean().default(false).description('会话任务投递文本中环境变量打码'),
43
+ sidebarTab: schemastery.boolean().default(false).description('看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面)'),
41
44
  })
42
45
 
43
46
  export function resolveDataDir(env = process.env) {
@@ -47,15 +50,11 @@ export function resolveDataDir(env = process.env) {
47
50
  }
48
51
 
49
52
  export function apply(ctx, config) {
50
- // ctx.get 不受 inject 门控(cordis 明文契约:缺失返回 undefined),属性访问会被拦截抛错
51
- const sessionController = ctx.get('sessionController')
52
- if (!sessionController) {
53
- if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + SESSION_UNAVAILABLE_REASON)
54
- return
55
- }
56
53
  const dataDir = resolveDataDir()
57
54
  // 装配惰性单例:首个请求 / timer 激活触发初始化,各组件只建一次
58
55
  let runtimePromise = null
56
+ // 会话通道就绪状态:等待期 API 已可服务脚本任务,就绪/禁用后状态定格
57
+ const sessionState = { ready: false, disabled: false }
59
58
  // 生命周期解绑引用:惰性装配完成前给安全空实现,装配后指向真实 scheduler
60
59
  const schedulerRef = { current: { dispose() {} } }
61
60
  const getRuntime = () => {
@@ -64,7 +63,7 @@ export function apply(ctx, config) {
64
63
  const store = await createStore({ dir: dataDir })
65
64
  const logger = createLogger({ rootDir: join(dataDir, 'logs') })
66
65
  const sessionDriver = createSessionDriver({
67
- sessionController,
66
+ getSessionController: () => sessionControllerRef.current,
68
67
  agents: ctx.get('agents'),
69
68
  sessionQuery: ctx.get('sessionQuery'),
70
69
  })
@@ -86,6 +85,9 @@ export function apply(ctx, config) {
86
85
  executor,
87
86
  scheduler,
88
87
  periodic,
88
+ sessionState,
89
+ readSidebarTab: () => readBoolean('sidebarTab', false),
90
+ updateUiSettings,
89
91
  logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
90
92
  })
91
93
  return { api, scheduler }
@@ -95,6 +97,31 @@ export function apply(ctx, config) {
95
97
  return runtimePromise
96
98
  }
97
99
 
100
+ // 会话通道异步就绪:轮询等待 dsh-web-app 挂载 sessionController(时序不可赌,
101
+ // 实测 0.1.2-rc.1 冷启动挂载晚于本插件 apply),超时仍缺失即按旧宿主对待
102
+ ctx.effect(() => {
103
+ const startedAt = Date.now()
104
+ const poll = setInterval(() => {
105
+ // ctx.get 不受 inject 门控(cordis 明文契约:缺失返回 undefined)
106
+ const sessionController = ctx.get('sessionController')
107
+ if (sessionController) {
108
+ sessionState.ready = true
109
+ sessionControllerRef.current = sessionController
110
+ clearInterval(poll)
111
+ return
112
+ }
113
+ if (Date.now() - startedAt >= SESSION_WAIT_MS) {
114
+ sessionState.disabled = true
115
+ // rc.2 的 cordis logger 默认 exporter 仅入内存缓冲无输出通道,告警必须直写 stderr 才可见
116
+ process.stderr.write('[cron-board] ' + SESSION_UNAVAILABLE_REASON + '\n')
117
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + SESSION_UNAVAILABLE_REASON)
118
+ clearInterval(poll)
119
+ }
120
+ }, SESSION_POLL_MS)
121
+ return () => clearInterval(poll)
122
+ }, 'cron-board session wait')
123
+ const sessionControllerRef = { current: null }
124
+
98
125
  const periodic = { running: false, reason: null }
99
126
  let armTick = null
100
127
  let disposeTick = null
@@ -107,6 +134,13 @@ export function apply(ctx, config) {
107
134
  const value = readSettingValue(name)
108
135
  return typeof value === 'boolean' ? value : fallback
109
136
  }
137
+ // 客户端设置面板写入通道:settings 服务在场时按命名空间合并补丁
138
+ function updateUiSettings(patch) {
139
+ const settings = ctx.get('settings')
140
+ if (!settings || typeof settings.update !== 'function') return false
141
+ settings.update(SETTINGS_NS, patch)
142
+ return true
143
+ }
110
144
  function readSettingValue(name) {
111
145
  const settings = ctx.get('settings')
112
146
  const scope = settings && settings.get ? settings.get(SETTINGS_NS) : undefined
@@ -153,20 +187,25 @@ export function apply(ctx, config) {
153
187
  })
154
188
 
155
189
  ctx.effect(
156
- () => ctx.webServer.register({
157
- kind: 'prefix',
158
- path: API_PREFIX,
159
- handler: async (req, res) => {
160
- try {
161
- const runtime = await getRuntime()
162
- await runtime.api.handle(req, res)
163
- } catch (error) {
164
- res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
165
- res.end(JSON.stringify({ error: '操作失败(系统级错误,详见服务端日志)' }))
166
- if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + String(error && error.stack || error))
167
- }
168
- },
169
- }),
190
+ () => {
191
+ const disposeRoute = ctx.webServer.register({
192
+ kind: 'prefix',
193
+ path: API_PREFIX,
194
+ handler: async (req, res) => {
195
+ try {
196
+ const runtime = await getRuntime()
197
+ await runtime.api.handle(req, res)
198
+ } catch (error) {
199
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
200
+ res.end(JSON.stringify({ error: '操作失败(系统级错误,详见服务端日志)' }))
201
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + String(error && error.stack || error))
202
+ }
203
+ },
204
+ })
205
+ return () => {
206
+ disposeRoute()
207
+ }
208
+ },
170
209
  'cron-board api',
171
210
  )
172
211
 
@@ -12,7 +12,7 @@ const REJECTED_MESSAGE = '会话输入投递被拒绝'
12
12
 
13
13
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
14
14
 
15
- export function createSessionDriver({ sessionController, agents, sessionQuery, pollIntervalMs = 2 * 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now() }) {
15
+ export function createSessionDriver({ getSessionController, agents, sessionQuery, pollIntervalMs = 2 * 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now() }) {
16
16
  // pinned 会话存在性:持久层 headers 比对(冷会话不在 agents 注册表)
17
17
  async function pinnedExists(sessionId) {
18
18
  if (!sessionQuery || typeof sessionQuery.listSessions !== 'function') return Boolean(agents.get(sessionId))
@@ -21,6 +21,9 @@ export function createSessionDriver({ sessionController, agents, sessionQuery, p
21
21
  }
22
22
 
23
23
  async function run({ job, env, mask, updateRecord }) {
24
+ // 控制器运行时动态解析:装配可能先于宿主挂载 sessionController(异步就绪等待中)
25
+ const sessionController = getSessionController ? getSessionController() : undefined
26
+ if (!sessionController) return { status: 'fail', message: '会话服务不可用' }
24
27
  const session = job.session || {}
25
28
  const isPinned = session.mode === 'pinned'
26
29
  let sessionId = isPinned ? session.pinnedSessionId : null
@@ -1,4 +1,4 @@
1
- // 环境变量同名多值展开测试(青龙多账号语义,设计 §4.2)。
1
+ // 环境变量同名多值展开测试(多账号语义,设计 §4.2)。
2
2
 
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
@@ -19,11 +19,12 @@ test('index:数据目录默认 ~/.dsh/cron-board,env 可覆盖', () => {
19
19
  })
20
20
 
21
21
  test('index:apply 注册 prefix 路由且请求走通(列表接口)', async () => {
22
- // Given webServer 桩:收集注册路由(sessionController 经 get 桩供给,对齐 ctx.get 探测路径)
22
+ // Given webServer 桩:收集注册路由(sessionController 经 get 桩供给,就绪等待同步完成)
23
23
  const routes = new Map()
24
24
  const sessionController = { create: async () => ({ sessionId: 's-x' }), prompt: async () => ({ accepted: true }) }
25
25
  const ctx = {
26
- effect(fn) {
26
+ effect(fn, label) {
27
+ if (label === 'cron-board session wait') { fn(); return }
27
28
  fn()
28
29
  },
29
30
  inject() {},
@@ -83,10 +84,11 @@ function makeFullCtx({ timerAvailable = true } = {}) {
83
84
  }
84
85
  const intervals = []
85
86
  const ctx = {
86
- effect(fn) {
87
+ effect(fn, label) {
88
+ // 会话等待 effect 是真实定时器轮询形态,桩跳过直接执行体;controller 经 get 即可判
89
+ if (label === 'cron-board session wait') { fn(); return }
87
90
  fn()
88
91
  },
89
- sessionController,
90
92
  get(name) {
91
93
  if (name === 'settings') return settingsService
92
94
  if (name === 'sessionController') return sessionController
@@ -117,21 +119,22 @@ function makeFullCtx({ timerAvailable = true } = {}) {
117
119
  return { ctx, routes, registered, intervals, settingsService, sessions }
118
120
  }
119
121
 
120
- test('index:inject 仅声明 webServer(sessionController 走 apply 内探测)', async () => {
121
- // Given sessionController 在旧版宿主(0.1.1-rc.2)不存在,inject 声明会让 fiber 永久
122
- // pending,旧版 boot 对 pending 条目抛错杀掉整个进程;host 服务同步注册,探测无竞态
123
- // Then 静态锁定 inject 声明
122
+ test('index:inject 仅声明 webServer(sessionController 异步就绪等待,不进 inject)', async () => {
123
+ // Given sessionController 由 dsh-web-app 条目异步挂载:inject 声明在 0.1.1-rc.2 上
124
+ // 永久 pending 拖死 boot,apply 内同步探测又抢在挂载前误判(0.1.2-rc.1 实测 404)
125
+ // Then 静态锁定 inject 声明;就绪语义由 effect 内轮询等待 + 超时禁用承担
124
126
  assert.deepEqual(mod.inject, ['webServer'])
125
127
  })
126
128
 
127
- test('index:sessionController 缺失时干净禁用(不注册路由与设置)', async () => {
128
- // Given 无 sessionController 的宿主上下文
129
+ test('index:sessionController 恒缺失时超时干净禁用会话通道(脚本任务不受影响)', async () => {
130
+ // Given 无 sessionController 的宿主上下文(等价 0.1.1-rc.2)
129
131
  const routes = new Map()
130
- const registered = []
131
132
  const warnings = []
133
+ const effects = []
132
134
  const ctx = {
133
- effect(fn) {
134
- fn()
135
+ effect(fn, label) {
136
+ if (label === 'cron-board session wait') effects.push(fn)
137
+ else fn()
135
138
  },
136
139
  inject() {},
137
140
  logger: { warn: (line) => warnings.push(line) },
@@ -143,13 +146,13 @@ test('index:sessionController 缺失时干净禁用(不注册路由与设置)',
143
146
  },
144
147
  },
145
148
  }
146
- // When apply
149
+ // When apply(路由同步注册,会话等待 effect 挂起)
147
150
  apply(ctx)
148
- // Then 无路由注册、无设置注册,且打日志说明原因
149
- assert.equal(routes.size, 0)
150
- assert.deepEqual(registered, [])
151
- assert.equal(warnings.length, 1)
152
- assert.ok(warnings[0].includes('sessionController'))
151
+ // Then 路由已注册(脚本任务可用),等待 effect 在列
152
+ assert.equal(routes.size, 1)
153
+ assert.equal(effects.length, 1)
154
+ // When 超时轮询终结(缩短等待:直接跑完等待窗)
155
+ effects[0]()
153
156
  })
154
157
 
155
158
  test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期 tick', async () => {
@@ -273,11 +273,47 @@ test('status 路由:报告调度器与 timer 可用性', async (t) => {
273
273
  })
274
274
  // When GET /status
275
275
  const res = await call(api, 'GET', '/api/cron-board/status')
276
- // Then 状态齐备
276
+ // Then 状态齐备;ui 偏好缺省为关(默认主界面)
277
277
  assert.equal(res.status, 200)
278
278
  assert.equal(res.payload.timerRunning, true)
279
279
  assert.deepEqual(res.payload.scheduler, { active: 1, queued: 2 })
280
280
  assert.equal(res.payload.nextAt, 1750000000000)
281
+ assert.deepEqual(res.payload.ui, { sidebarTab: false })
282
+ })
283
+
284
+ test('status 路由:ui.sidebarTab 透出用户侧边栏移入偏好', async (t) => {
285
+ // Given readSidebarTab 桩返回开
286
+ const { api } = await makeApi(t, { readSidebarTab: () => true })
287
+ // When GET /status
288
+ const res = await call(api, 'GET', '/api/cron-board/status')
289
+ // Then ui.sidebarTab=true(client 据此移入扩展槽)
290
+ assert.equal(res.payload.ui.sidebarTab, true)
291
+ })
292
+
293
+ test('ui-settings 路由:布尔补丁经 updateUiSettings 写入并回读', async (t) => {
294
+ // Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效)
295
+ let stored = false
296
+ const { api } = await makeApi(t, {
297
+ updateUiSettings: (patch) => { if (typeof patch.sidebarTab === 'boolean') stored = patch.sidebarTab },
298
+ readSidebarTab: () => stored,
299
+ })
300
+ // When POST 开关开
301
+ const res = await call(api, 'POST', '/api/cron-board/ui-settings', { sidebarTab: true })
302
+ // Then ok 且回读 true;写桩收到布尔补丁
303
+ assert.equal(res.status, 200)
304
+ assert.equal(res.payload.ok, true)
305
+ assert.equal(res.payload.ui.sidebarTab, true)
306
+ assert.equal(stored, true)
307
+ })
308
+
309
+ test('ui-settings 路由:无效字段 400,服务缺失降级 ok=false', async (t) => {
310
+ const noHandler = await makeApi(t, { updateUiSettings: undefined })
311
+ const res1 = await call(noHandler.api, 'POST', '/api/cron-board/ui-settings', { sidebarTab: true })
312
+ assert.equal(res1.status, 200)
313
+ assert.equal(res1.payload.ok, false)
314
+ const { api } = await makeApi(t)
315
+ const res2 = await call(api, 'POST', '/api/cron-board/ui-settings', { other: 1 })
316
+ assert.equal(res2.status, 400)
281
317
  })
282
318
 
283
319
  test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', async (t) => {
@@ -330,6 +366,28 @@ test('jobs 路由:会话任务字段校验与默认值', async (t) => {
330
366
  assert.equal(badMiss.status, 400)
331
367
  })
332
368
 
369
+ test('jobs 路由:会话通道禁用时建会话任务 400,脚本任务不受限', async (t) => {
370
+ // Given sessionState.disabled(等价 0.1.1-rc.2 超时禁用态)
371
+ const sessionState = { ready: false, disabled: true }
372
+ const { api } = await makeApi(t, { sessionState })
373
+ const sess = await call(api, 'POST', '/api/cron-board/jobs', {
374
+ name: 'sv', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
375
+ })
376
+ assert.equal(sess.status, 400)
377
+ assert.match(sess.payload.error, /会话任务通道已禁用/)
378
+ const shell = await call(api, 'POST', '/api/cron-board/jobs', {
379
+ name: 'sh', kind: 'shell', command: 'echo x', schedule: '* * * * *', enabled: true,
380
+ })
381
+ assert.equal(shell.status, 200)
382
+ })
383
+
384
+ test('status 路由:透出会话通道就绪状态', async (t) => {
385
+ const sessionState = { ready: true, disabled: false }
386
+ const { api } = await makeApi(t, { sessionState })
387
+ const res = await call(api, 'GET', '/api/cron-board/status')
388
+ assert.deepEqual(res.payload.session, { ready: true, disabled: false })
389
+ })
390
+
333
391
  test('cron preview 路由:人话摘要与下三次触发', async (t) => {
334
392
  const { api } = await makeApi(t)
335
393
  // When 预览「每天 08:30」
@@ -44,7 +44,7 @@ function makeStubs({ autoIdle = true } = {}) {
44
44
 
45
45
  function makeDriver(stubs, overrides = {}) {
46
46
  return createSessionDriver({
47
- sessionController: stubs.sessionController,
47
+ getSessionController: () => stubs.sessionController,
48
48
  agents: stubs.agents,
49
49
  sessionQuery: stubs.sessionQuery,
50
50
  pollIntervalMs: 5,
@@ -70,7 +70,7 @@ test('store:同名环境变量多条合法', async (t) => {
70
70
  await store.envs.create({ name: 'WX_KEY', value: 'a', remarks: '', enabled: true })
71
71
  // When 再创建同名不同值
72
72
  await store.envs.create({ name: 'WX_KEY', value: 'b', remarks: '', enabled: true })
73
- // Then 两条并存(青龙多账号语义)
73
+ // Then 两条并存(多账号语义)
74
74
  const same = store.envs.list().filter((row) => row.name === 'WX_KEY')
75
75
  assert.equal(same.length, 2)
76
76
  })
@@ -41,6 +41,18 @@ test('checked/focus-visible/disabled 三态均以 checkbox 锚定', () => {
41
41
  assert.match(source, /\.cb-switch input\[type="checkbox"\]:disabled \+/)
42
42
  })
43
43
 
44
+ test('插件页卡片为官方 PluginCard 形制:li 卡壳 + 头部折叠 + 令牌同源', () => {
45
+ // Given 官方插件卡片 = ul.cards > li.PluginCard(边框卡壳 + 名称/描述头部 + chevron 折叠)
46
+ // When 本插件卡片注册进同一列表(slot 契约:外观归插件自持,官方壳未导出)
47
+ // Then 静态锁定 cb-pc 镜像形制,防止退化回裸 div 平铺
48
+ assert.match(source, /h\('li', \{ className: 'cb-pc/, '卡片根元素必须是 li(官方列表 ul.cards 的合法子元素)')
49
+ assert.match(source, /\.cb-pc\{border:\.5px solid var\(--dsw-alias-border-l4\)/, '卡壳边框/底色令牌必须与官方 PluginCard 同源')
50
+ assert.match(source, /h\('button', \{ type: 'button', className: 'cb-pc__head'/, '缺少官方形制的头部按钮')
51
+ assert.match(source, /aria-expanded/, '头部必须可折叠并暴露 aria-expanded')
52
+ assert.match(source, /\.cb-pc__hint\{[^}]*var\(--dsw-alias-label-tertiary\)/, '提示文本令牌必须与官方 hint 同源')
53
+ assert.doesNotMatch(source, /'cb-settings'/, '旧裸 div 平铺形态已移除')
54
+ })
55
+
44
56
  test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
45
57
  // IIFE 书挡内允许任意声明;守卫确认整文件被书挡包裹
46
58
  const trimmed = source.trim()
@@ -48,9 +60,10 @@ test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
48
60
  assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
49
61
  })
50
62
 
51
- test('client.js 主页面双形态挂载契约(better-sidebar 优先/主界面回退)', () => {
52
- // Given 用户要求:面板入口迁主页面,优先 dsh-better-sidebar 扩展槽(单实例),未装回退主界面(taskboard 形态)
53
- // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件
63
+ test('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
64
+ // Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
65
+ // 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)
66
+ // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件、偏好驱动仲裁
54
67
  assert.match(source, /window\.__ModuleLoader__\.load\(\{ id: '@mzzsfy\/dsh-cron-board', factory \}\)/)
55
68
  assert.match(source, /ctx\.get\('betterSidebar'\)/)
56
69
  assert.match(source, /single: true/)
@@ -58,6 +71,17 @@ test('client.js 主页面双形态挂载契约(better-sidebar 优先/主界面
58
71
  assert.match(source, /data-cb-board-active/)
59
72
  assert.match(source, /dsh-panel-activate/)
60
73
  assert.match(source, /function mountStandaloneBoard/)
61
- // 不再挂设置页分区(入口唯一)
62
- assert.doesNotMatch(source, /settings\.section/)
74
+ // 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 在场可切换,否则禁用仅展示
75
+ assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
76
+ assert.match(source, /key: 'cron-board', label: '定时任务'/)
77
+ assert.match(source, /function CronBoardPluginCard/)
78
+ assert.match(source, /'ui-settings'/)
79
+ assert.match(source, /sidebarReady/)
80
+ // 挂载仲裁:apply 无条件先落主界面形态;偏好(status.ui.sidebarTab)驱动接入/退出
81
+ assert.match(source, /let standalone = mountStandaloneBoard\(wsModel\)/)
82
+ assert.match(source, /applyPref\(\)/)
83
+ assert.match(source, /ui\.sidebarTab/)
84
+ // 不再自动回退:attach 后不订阅页签状态,无 openTabs/subscribeState 联动
85
+ assert.doesNotMatch(source, /subscribeState/)
86
+ assert.doesNotMatch(source, /openTabs/)
63
87
  })