@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

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.
Files changed (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The planning board's popovers: the 建队 name dialog (focus-trapped modal),
3
+ * the node / frame-head context menu (single-level drill-down, full
4
+ * keyboard parity) and the 未入网 dropdown on the toolbar. Split out of
5
+ * planning-view (which keeps gestures, rendering and reconcile); the view
6
+ * hands in the model, its element anchors and the write channel, and asks
7
+ * back only through the returned surface.
8
+ * @module nexus-stage/planning-menus
9
+ */
10
+ import { reorderOps, type CanvasAction } from './canvas-ops'
11
+ import type { PlanningSession, PlanningTeam } from './planning-types'
12
+ import type { WorldModel } from './world'
13
+
14
+ export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
15
+ export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
16
+
17
+ export interface MenusDeps {
18
+ readonly root: HTMLElement
19
+ /** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
20
+ readonly netBtn: HTMLButtonElement
21
+ readonly signal: AddEventListenerOptions
22
+ readonly model: WorldModel
23
+ /** Canvas teams from the latest poll. */
24
+ teams(): ReadonlyArray<PlanningTeam>
25
+ /** The card or frame-head element a menu re-focuses on close. */
26
+ anchorFor(target: MenuTarget): HTMLElement | null
27
+ emit(a: CanvasAction): void
28
+ notice(kind: 'error' | 'info', text: string): void
29
+ }
30
+
31
+ export interface PlanningMenus {
32
+ menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
33
+ isMenuOpen(): boolean
34
+ isDialogOpen(): boolean
35
+ closeMenu(): void
36
+ openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
37
+ /** Context menu from a real contextmenu event or the keyboard equivalent. */
38
+ contextMenuAt(x: number, y: number, target: Element | null): void
39
+ openNameDialog(ids: readonly string[]): void
40
+ startCreateFromSelection(): void
41
+ toggleNetMenu(): void
42
+ /** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
43
+ setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
44
+ }
45
+
46
+ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
47
+ const { root, netBtn, signal, model } = d
48
+
49
+ // ── 建队:selection → 命名对话框 → create-team ──
50
+ function selectedByY(): string[] {
51
+ return model
52
+ .selectedIds()
53
+ .map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
54
+ .sort((a, b) => a.y - b.y)
55
+ .map(r => r.id)
56
+ }
57
+
58
+ function startCreateFromSelection(): void {
59
+ if (dialog !== null) return
60
+ // Peer cards are not joinable sessions: they never enter 建队 ids.
61
+ const ids = selectedByY().filter(id => model.getNode(id)?.remote !== true)
62
+ if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
63
+ openNameDialog(ids)
64
+ }
65
+
66
+ let dialog: {
67
+ wrap: HTMLDivElement
68
+ restoreFocus: HTMLElement | null
69
+ } | null = null
70
+
71
+ function openNameDialog(ids: readonly string[]): void {
72
+ const wrap = document.createElement('div')
73
+ wrap.className = 'p-dialog'
74
+ const panel = document.createElement('div')
75
+ panel.className = 'p-dialog-panel'
76
+ panel.setAttribute('role', 'dialog')
77
+ panel.setAttribute('aria-modal', 'true')
78
+ panel.setAttribute('aria-labelledby', 'p-dialog-title')
79
+ const title = document.createElement('div')
80
+ title.id = 'p-dialog-title'
81
+ title.className = 'p-dialog-title'
82
+ title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
83
+ const input = document.createElement('input')
84
+ input.className = 'p-dialog-input'
85
+ input.maxLength = 40
86
+ input.setAttribute('aria-label', '团队名')
87
+ const err = document.createElement('div')
88
+ err.className = 'p-dialog-err'
89
+ err.setAttribute('aria-live', 'polite')
90
+ const row = document.createElement('div')
91
+ row.className = 'p-dialog-row'
92
+ const okBtn = document.createElement('button')
93
+ okBtn.type = 'button'
94
+ okBtn.textContent = '组建'
95
+ const cancelBtn = document.createElement('button')
96
+ cancelBtn.type = 'button'
97
+ cancelBtn.textContent = '取消'
98
+ row.append(okBtn, cancelBtn)
99
+ panel.append(title, input, err, row)
100
+ wrap.appendChild(panel)
101
+ root.appendChild(wrap)
102
+
103
+ const restoreFocus = document.activeElement as HTMLElement | null
104
+ const close = (): void => {
105
+ wrap.remove()
106
+ dialog = null
107
+ restoreFocus?.focus()
108
+ }
109
+ const nameError = (): string | null => {
110
+ const name = input.value.trim()
111
+ if (name === '' || name.length > 40 || name.includes('/') || /^\d+$/.test(name)) {
112
+ return '队名需 1..40 字,不含“/”,不为纯数字'
113
+ }
114
+ return null
115
+ }
116
+ const confirm = (): void => {
117
+ const problem = nameError()
118
+ if (problem !== null) { err.textContent = problem; input.focus(); return }
119
+ const name = input.value.trim()
120
+ close()
121
+ if (model.getFrame(name) !== undefined) {
122
+ // Duplicate name: host create is idempotent-ok, but the optimistic
123
+ // create-team would WIPE the existing roster and overwrite its saved
124
+ // frame rect. Join instead — same wire result, nothing destroyed.
125
+ d.notice('info', `已加入现有团队「${name}」`)
126
+ d.emit({ type: 'add-member', team: name, ids })
127
+ return
128
+ }
129
+ d.emit({ type: 'create-team', name, ids, created: true })
130
+ }
131
+ okBtn.addEventListener('click', confirm)
132
+ cancelBtn.addEventListener('click', close)
133
+ input.addEventListener('keydown', (ev) => {
134
+ const e = ev as KeyboardEvent
135
+ if (e.key === 'Enter') { e.preventDefault(); confirm() }
136
+ if (e.key === 'Escape') { e.preventDefault(); close() }
137
+ })
138
+ // Focus trap (aria-modal must not be a false promise): Tab cycles the
139
+ // three focusables; Esc anywhere in the panel cancels.
140
+ wrap.addEventListener('keydown', (ev) => {
141
+ const e = ev as KeyboardEvent
142
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
143
+ if (e.key !== 'Tab') return
144
+ e.preventDefault()
145
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, button'))
146
+ const idx = focusables.indexOf(document.activeElement as HTMLElement)
147
+ const next = e.shiftKey
148
+ ? focusables[(idx - 1 + focusables.length) % focusables.length]
149
+ : focusables[(idx + 1) % focusables.length]
150
+ next?.focus()
151
+ }, signal)
152
+ dialog = { wrap, restoreFocus }
153
+ input.focus()
154
+ }
155
+
156
+ // ── context menu(节点/框头;下钻单层;键盘全等)──
157
+ let menu: HTMLDivElement | null = null
158
+ let menuTarget: MenuTarget | undefined
159
+ let menuLevel: MenuLevel = 'root'
160
+ let menuAnchor: HTMLElement | null = null
161
+
162
+ // ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
163
+ let netMenu: HTMLDivElement | null = null
164
+ let lastUnjoined: ReadonlyArray<PlanningSession> = []
165
+ function closeNetMenu(): void {
166
+ netMenu?.remove()
167
+ netMenu = null
168
+ }
169
+ function toggleNetMenu(): void {
170
+ if (netMenu !== null) { closeNetMenu(); return }
171
+ if (lastUnjoined.length === 0) return
172
+ const el = document.createElement('div')
173
+ el.className = 'p-menu p-netmenu'
174
+ el.setAttribute('role', 'menu')
175
+ el.setAttribute('aria-label', '未入网会话')
176
+ for (const s of lastUnjoined) {
177
+ el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
178
+ closeNetMenu()
179
+ d.emit({ type: 'join-network', id: s.id })
180
+ }))
181
+ }
182
+ netMenu = el
183
+ // Menus append to root (not document.body): the .p-menu styles are
184
+ // #dsh-plan-scoped, and root has no transformed ancestor, so fixed
185
+ // positioning stays viewport-relative.
186
+ root.appendChild(el)
187
+ const rect = netBtn.getBoundingClientRect()
188
+ el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
189
+ el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
190
+ el.addEventListener('keydown', (ev) => {
191
+ if ((ev as KeyboardEvent).key === 'Escape') {
192
+ ev.stopPropagation()
193
+ closeNetMenu()
194
+ netBtn.focus()
195
+ }
196
+ })
197
+ el.querySelector('button')?.focus()
198
+ // Outside-click dismissal: the dropdown lives at root level, so canvas
199
+ // presses never reach a menu-local handler — and focus leaves with the
200
+ // first outside click, killing the Escape path too (the stuck-dropdown
201
+ // defect the owner hit). The trigger button stays exempt so its click
202
+ // still toggles.
203
+ document.addEventListener('pointerdown', (ev) => {
204
+ if (netMenu === null) return
205
+ const t = ev.target as Element | null
206
+ if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
207
+ closeNetMenu()
208
+ }, signal)
209
+ }
210
+
211
+ function closeMenu(): void {
212
+ menu?.remove()
213
+ menu = null
214
+ menuTarget = undefined
215
+ menuLevel = 'root'
216
+ // The anchor may have been detached by a poll while the menu was open —
217
+ // focus() on a detached node drops focus to body and the canvas goes deaf.
218
+ if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
219
+ else root.focus()
220
+ menuAnchor = null
221
+ }
222
+
223
+ function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
224
+ const b = document.createElement('button')
225
+ b.type = 'button'
226
+ b.setAttribute('role', 'menuitem')
227
+ b.textContent = label
228
+ if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
229
+ else b.addEventListener('click', () => { closeMenu(); onPick() })
230
+ return b
231
+ }
232
+
233
+ function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
234
+ // Re-seed the anchor after a drill-down: closeMenu focuses whatever this
235
+ // menu was anchored to, or keyboard focus falls to body and the canvas
236
+ // goes deaf.
237
+ if (anchor !== undefined) menuAnchor = anchor
238
+ if (menuAnchor === null) menuAnchor = d.anchorFor(target)
239
+ menu?.remove()
240
+ menuTarget = target
241
+ menuLevel = level
242
+ const el = document.createElement('div')
243
+ el.className = 'p-menu'
244
+ el.setAttribute('role', 'menu')
245
+ el.setAttribute('aria-label', '团队操作')
246
+ let items: HTMLButtonElement[] = []
247
+ if (target.kind === 'frame') {
248
+ items = [menuItem(`解散团队「${target.name}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
249
+ } else {
250
+ const id = target.id
251
+ if (level === 'root') {
252
+ const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
253
+ create.title = '先框选或 Shift 加选至少 2 个节点'
254
+ items.push(create)
255
+ if (d.teams().length > 0) {
256
+ items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
257
+ const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
258
+ if (teamsOf.length > 0) {
259
+ items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
260
+ items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
261
+ }
262
+ }
263
+ // Network-level leave (distinct from canvas-team membership): the
264
+ // panel-slim migration moved the join/leave toggle here. Remote
265
+ // nodes are read-only entries — never a leave target.
266
+ if (model.getNode(id)?.remote !== true) {
267
+ items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
268
+ }
269
+ } else {
270
+ items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
271
+ for (const t of d.teams()) {
272
+ if (level === 'join') {
273
+ const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
274
+ items.push(menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => d.emit({ type: 'add-member', team: t.name, ids: [id] })))
275
+ } else if (level === 'leave') {
276
+ const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
277
+ items.push(menuItem(t.name, member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
278
+ } else {
279
+ const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
280
+ items.push(menuItem(t.name, member, () => {
281
+ const current = model.teamMemberIds(t.name)
282
+ const desired = [id, ...current.filter(x => x !== id)]
283
+ d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
284
+ }))
285
+ }
286
+ }
287
+ }
288
+ }
289
+ el.append(...items)
290
+ el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
291
+ const menuH = items.length * 30 + 8
292
+ el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
293
+ el.addEventListener('keydown', (ev) => {
294
+ const e = ev as KeyboardEvent
295
+ const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
296
+ const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
297
+ if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
298
+ else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
299
+ else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
300
+ else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
301
+ else if (e.key === 'Escape') {
302
+ e.preventDefault()
303
+ e.stopPropagation() // drill-back is menu-internal; root must not double-close
304
+ if (menuLevel !== 'root') openMenu(x, y, target, 'root')
305
+ else closeMenu()
306
+ }
307
+ })
308
+ root.appendChild(el)
309
+ menu = el
310
+ items[0]?.focus()
311
+ }
312
+
313
+ /** Context menu from a real contextmenu event or the keyboard equivalent. */
314
+ function contextMenuAt(x: number, y: number, target: Element | null): void {
315
+ if (dialog !== null) return
316
+ // Peer endpoints are read-only entries: no roster menu for them.
317
+ const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
318
+ if (probeNode !== null && model.getNode(probeNode.dataset.id ?? '')?.remote === true) return
319
+ const nodeEl = probeNode
320
+ const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
321
+ if (nodeEl !== null) {
322
+ const id = nodeEl.dataset.id ?? ''
323
+ if (!model.isSelected(id)) model.setSelection([id])
324
+ menuAnchor = nodeEl
325
+ openMenu(x, y, { kind: 'node', id })
326
+ } else if (headEl !== null) {
327
+ menuAnchor = headEl
328
+ openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
329
+ }
330
+ }
331
+
332
+ function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
333
+ lastUnjoined = sessions
334
+ netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
335
+ netBtn.disabled = lastUnjoined.length === 0
336
+ netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
337
+ if (lastUnjoined.length === 0) closeNetMenu()
338
+ }
339
+
340
+ return {
341
+ menuItem,
342
+ isMenuOpen: () => menu !== null,
343
+ isDialogOpen: () => dialog !== null,
344
+ closeMenu,
345
+ openMenu,
346
+ contextMenuAt,
347
+ openNameDialog,
348
+ startCreateFromSelection,
349
+ toggleNetMenu,
350
+ setUnjoined,
351
+ }
352
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The 节点 drawer (network-membership-display): the canvas shows teamed
3
+ * nodes only, so every other session — teamless joined, unjoined, and each
4
+ * peer's published nodes — is listed and managed here, switched by host.
5
+ * Split out of planning-view; it reads the latest poll facts through
6
+ * `facts()` and writes only through the view's action channel.
7
+ * @module nexus-stage/planning-nodelist
8
+ */
9
+ import type { CanvasAction } from './canvas-ops'
10
+ import type { RemoteTeamRow } from './federation'
11
+ import type { PlanningSession, PlanningTeam } from './planning-types'
12
+ import type { WorldModel } from './world'
13
+
14
+ export interface NodeListFacts {
15
+ readonly sessions: ReadonlyArray<PlanningSession>
16
+ readonly canvasMemberIds: ReadonlySet<string>
17
+ /** Remote team rows grouped by peer host (federation.groupRemoteTeams). */
18
+ readonly remoteTeams: ReadonlyMap<string, RemoteTeamRow[]>
19
+ readonly teams: ReadonlyArray<PlanningTeam>
20
+ }
21
+
22
+ export interface NodeListDeps {
23
+ readonly root: HTMLElement
24
+ /** The toolbar's 节点 button: the drawer anchors to it and returns focus there. */
25
+ readonly trigger: HTMLButtonElement
26
+ readonly signal: AddEventListenerOptions
27
+ readonly model: WorldModel
28
+ facts(): NodeListFacts
29
+ menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
30
+ emit(a: CanvasAction): void
31
+ openNameDialog(ids: readonly string[]): void
32
+ /** Center the board on one canvas node (closes the drawer). */
33
+ locate(id: string): void
34
+ }
35
+
36
+ export interface PlanningNodeList {
37
+ toggle(): void
38
+ close(): void
39
+ /** Re-render an open drawer from the latest facts (no-op when closed). */
40
+ refresh(): void
41
+ }
42
+
43
+ export function createNodeList(d: NodeListDeps): PlanningNodeList {
44
+ const { root, trigger: nodesBtn, signal, model } = d
45
+
46
+ // ── 节点列表 drawer(组队节点上画布;单一节点按 host/对等节点在列表管理)──
47
+ let nodePanel: HTMLDivElement | null = null
48
+ let nodeHost = '' // '' = 本机;否则为 d.facts().remoteTeams 的 host:port 键
49
+ let nodeJoinMenu: HTMLDivElement | null = null
50
+
51
+ function closeNodeJoinMenu(): void {
52
+ nodeJoinMenu?.remove()
53
+ nodeJoinMenu = null
54
+ }
55
+ function closeNodePanel(): void {
56
+ closeNodeJoinMenu()
57
+ nodePanel?.remove()
58
+ nodePanel = null
59
+ }
60
+ function toggleNodePanel(): void {
61
+ if (nodePanel !== null) { closeNodePanel(); return }
62
+ const el = document.createElement('div')
63
+ el.className = 'p-menu p-nodelist'
64
+ el.setAttribute('role', 'dialog')
65
+ el.setAttribute('aria-label', '节点清单')
66
+ nodePanel = el
67
+ root.appendChild(el)
68
+ const rect = nodesBtn.getBoundingClientRect()
69
+ el.style.left = `${String(Math.min(rect.left, window.innerWidth - 340))}px`
70
+ el.style.top = `${String(Math.max(8, rect.top - 320))}px`
71
+ el.addEventListener('keydown', (ev) => {
72
+ if ((ev as KeyboardEvent).key === 'Escape') {
73
+ ev.stopPropagation()
74
+ closeNodePanel()
75
+ nodesBtn.focus()
76
+ }
77
+ })
78
+ renderNodePanel()
79
+ }
80
+
81
+ /** Rebuild the panel's host selector + body from the latest poll facts. */
82
+ function renderNodePanel(): void {
83
+ const el = nodePanel
84
+ if (el === null) return
85
+ closeNodeJoinMenu()
86
+ el.textContent = ''
87
+ const head = document.createElement('div')
88
+ head.className = 'p-nodelist-head'
89
+ const headLabel = document.createElement('span')
90
+ headLabel.className = 'p-nodelist-title'
91
+ headLabel.textContent = '节点'
92
+ const sel = document.createElement('select')
93
+ sel.className = 'p-nodesel'
94
+ sel.setAttribute('aria-label', '选择 host 或对等节点')
95
+ const localOpt = document.createElement('option')
96
+ localOpt.value = ''
97
+ localOpt.textContent = '本机'
98
+ sel.appendChild(localOpt)
99
+ for (const [host, rows] of d.facts().remoteTeams) {
100
+ if (host === '') continue
101
+ const opt = document.createElement('option')
102
+ opt.value = host
103
+ opt.textContent = rows[0]?.origin !== undefined && rows[0].origin !== '' ? `${rows[0].origin}(${host})` : host
104
+ sel.appendChild(opt)
105
+ }
106
+ if (nodeHost !== '' && !d.facts().remoteTeams.has(nodeHost)) nodeHost = ''
107
+ sel.value = nodeHost
108
+ sel.addEventListener('change', () => { nodeHost = sel.value; renderNodePanel() })
109
+ head.append(headLabel, sel)
110
+ const body = document.createElement('div')
111
+ body.className = 'p-nodelist-body'
112
+ el.append(head, body)
113
+ if (nodeHost === '') renderLocalNodeGroups(body)
114
+ else renderRemoteNodeGroups(body, d.facts().remoteTeams.get(nodeHost) ?? [])
115
+ }
116
+
117
+ function nodeRowDot(live: boolean | undefined, joined: boolean): HTMLSpanElement {
118
+ const dot = document.createElement('span')
119
+ dot.className = 'p-nodedot' + (joined !== true ? ' off' : live === false ? ' cold' : '')
120
+ return dot
121
+ }
122
+
123
+ function listBtn(label: string, onPick: () => void): HTMLButtonElement {
124
+ const b = document.createElement('button')
125
+ b.type = 'button'
126
+ b.className = 'p-listbtn'
127
+ b.textContent = label
128
+ b.addEventListener('click', (ev) => { ev.stopPropagation(); onPick() })
129
+ return b
130
+ }
131
+
132
+ function groupTitle(text: string, note?: string): HTMLDivElement {
133
+ const t = document.createElement('div')
134
+ t.className = 'p-nodelist-group'
135
+ t.textContent = text
136
+ if (note !== undefined) {
137
+ const n = document.createElement('span')
138
+ n.className = 'p-nodelist-note'
139
+ n.textContent = note
140
+ t.appendChild(n)
141
+ }
142
+ return t
143
+ }
144
+
145
+ /** 入队▸ submenu: existing canvas teams, plus 新团队… for the bootstrap case. */
146
+ function openNodeJoinMenu(anchor: HTMLElement, id: string): void {
147
+ closeNodeJoinMenu()
148
+ const el = document.createElement('div')
149
+ el.className = 'p-menu'
150
+ el.setAttribute('role', 'menu')
151
+ el.setAttribute('aria-label', '加入团队')
152
+ for (const t of d.facts().teams) {
153
+ const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
154
+ el.appendChild(d.menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => {
155
+ closeNodePanel()
156
+ d.emit({ type: 'add-member', team: t.name, ids: [id] })
157
+ }))
158
+ }
159
+ el.appendChild(d.menuItem('新团队…', true, () => {
160
+ closeNodePanel()
161
+ d.openNameDialog([id])
162
+ }))
163
+ nodeJoinMenu = el
164
+ root.appendChild(el)
165
+ const rect = anchor.getBoundingClientRect()
166
+ el.style.left = `${String(Math.min(rect.right + 4, window.innerWidth - 180))}px`
167
+ el.style.top = `${String(Math.max(8, rect.top - 60))}px`
168
+ el.addEventListener('keydown', (ev) => {
169
+ if ((ev as KeyboardEvent).key === 'Escape') { ev.stopPropagation(); closeNodeJoinMenu(); anchor.focus() }
170
+ })
171
+ el.querySelector('button')?.focus()
172
+ }
173
+
174
+ function renderLocalNodeGroups(body: HTMLDivElement): void {
175
+ const teamed = new Set<string>(d.facts().canvasMemberIds)
176
+ for (const s of d.facts().sessions) if (s.teams !== undefined && s.teams.length > 0) teamed.add(s.id)
177
+ const unteamed = d.facts().sessions.filter(s => s.joined === true && !teamed.has(s.id))
178
+ const unjoined = d.facts().sessions.filter(s => s.joined !== true)
179
+ const teamedRows = d.facts().sessions.filter(s => s.joined === true && teamed.has(s.id))
180
+
181
+ body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力'))
182
+ for (const s of unteamed) {
183
+ const row = document.createElement('div')
184
+ row.className = 'p-noderow'
185
+ const name = document.createElement('span')
186
+ name.className = 'p-nodename'
187
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
188
+ name.title = s.team
189
+ const tag = document.createElement('span')
190
+ tag.className = 'p-nonet'
191
+ tag.textContent = '无网络能力'
192
+ const joinBtn = listBtn('入队▸', () => openNodeJoinMenu(joinBtn, s.id))
193
+ row.append(nodeRowDot(s.live, true), name, tag, joinBtn,
194
+ listBtn('退网', () => { closeNodePanel(); d.emit({ type: 'leave-network', id: s.id }) }))
195
+ body.appendChild(row)
196
+ }
197
+
198
+ body.appendChild(groupTitle(`未入网 (${String(unjoined.length)})`))
199
+ for (const s of unjoined) {
200
+ const row = document.createElement('div')
201
+ row.className = 'p-noderow'
202
+ const name = document.createElement('span')
203
+ name.className = 'p-nodename'
204
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
205
+ name.title = s.id
206
+ row.append(nodeRowDot(s.live, false), name,
207
+ listBtn('入网', () => { closeNodePanel(); d.emit({ type: 'join-network', id: s.id }) }))
208
+ body.appendChild(row)
209
+ }
210
+
211
+ body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '画布展示'))
212
+ for (const s of teamedRows) {
213
+ const row = document.createElement('div')
214
+ row.className = 'p-noderow'
215
+ const name = document.createElement('span')
216
+ name.className = 'p-nodename'
217
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
218
+ name.title = s.team
219
+ const canvasCount = model.getNode(s.id)?.memberships.length ?? 0
220
+ const rosterCount = s.teams?.length ?? 0
221
+ const badge = document.createElement('span')
222
+ badge.className = 'p-teamcount'
223
+ badge.textContent = `队×${String(Math.max(canvasCount, 0) + rosterCount)}`
224
+ row.append(nodeRowDot(s.live, true), name, badge,
225
+ listBtn('定位', () => { d.locate(s.id) }))
226
+ body.appendChild(row)
227
+ }
228
+ if (d.facts().sessions.length === 0) {
229
+ const empty = document.createElement('div')
230
+ empty.className = 'p-nodelist-empty'
231
+ empty.textContent = '无会话节点'
232
+ body.appendChild(empty)
233
+ }
234
+ }
235
+
236
+ function renderRemoteNodeGroups(body: HTMLDivElement, rows: ReadonlyArray<RemoteTeamRow>): void {
237
+ const unteamed = rows.filter(r => r.teams === undefined || r.teams.length === 0)
238
+ const teamedRows = rows.filter(r => r.teams !== undefined && r.teams.length > 0)
239
+ body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力 · 只读'))
240
+ for (const r of unteamed) {
241
+ const row = document.createElement('div')
242
+ row.className = 'p-noderow'
243
+ const name = document.createElement('span')
244
+ name.className = 'p-nodename'
245
+ name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
246
+ name.title = r.team
247
+ const tag = document.createElement('span')
248
+ tag.className = 'p-nonet'
249
+ tag.textContent = '无网络能力'
250
+ row.append(nodeRowDot(true, true), name, tag)
251
+ body.appendChild(row)
252
+ }
253
+ body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '只读'))
254
+ for (const r of teamedRows) {
255
+ const row = document.createElement('div')
256
+ row.className = 'p-noderow'
257
+ const name = document.createElement('span')
258
+ name.className = 'p-nodename'
259
+ name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
260
+ name.title = r.team
261
+ const badge = document.createElement('span')
262
+ badge.className = 'p-teamcount'
263
+ badge.textContent = `队×${String(r.teams?.length ?? 0)}`
264
+ row.append(nodeRowDot(true, true), name, badge)
265
+ body.appendChild(row)
266
+ }
267
+ if (rows.length === 0) {
268
+ const empty = document.createElement('div')
269
+ empty.className = 'p-nodelist-empty'
270
+ empty.textContent = '该节点未发布会话'
271
+ body.appendChild(empty)
272
+ }
273
+ }
274
+
275
+ // Outside-click dismissal for the node panel: the popover appends to root
276
+ // (scoped .p-menu styles apply; no transformed ancestor), so dismissal
277
+ // rides a document listener — the canvas pointer seam never sees presses
278
+ // on body-level chrome. The panel, its 入队 submenu, and the trigger are
279
+ // exempt.
280
+ document.addEventListener('pointerdown', (ev) => {
281
+ if (nodePanel === null) return
282
+ const t = ev.target as Element | null
283
+ if (t === null) { closeNodePanel(); return }
284
+ if (t.closest('.p-nodelist') !== null || t.closest('.p-menu') !== null) return
285
+ if (t === nodesBtn || nodesBtn.contains(t)) return
286
+ closeNodePanel()
287
+ }, signal)
288
+
289
+ return {
290
+ toggle: toggleNodePanel,
291
+ close: closeNodePanel,
292
+ refresh: () => { if (nodePanel !== null) renderNodePanel() },
293
+ }
294
+ }