@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43
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 +2 -1
- package/README_zh.md +2 -1
- package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/cli/a2a.mjs +24 -4
- package/lib/canvas-store.js +249 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/admin.js +103 -63
- package/lib/faces/wire.js +3 -1
- package/lib/index.js +116 -22
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/canvas-store.d.ts +48 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +520 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1294 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
|
@@ -1,294 +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
|
-
}
|
|
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
|
+
}
|