dsh-isolation-gate 0.0.0-stage → 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/lib/client.js ADDED
@@ -0,0 +1,1329 @@
1
+ /**
2
+ * dsh-isolation-gate · CLIENT half —— 「门禁 · 目录访问管制」整页
3
+ *
4
+ * 捆绑格式(client-modules 协议):经典脚本,向 `window.__ModuleLoader__`
5
+ * 注册工厂;工厂拿到 `require`,返回插件的导出(apply / inject)。无 JSX。
6
+ *
7
+ * 挂载面(与 dsh-skill-panel 同款官方槽位,不依赖任何第三方侧栏插件):
8
+ * ① `sidebar.panellist`(root 作用域 list = 左上应用级导航排)→ 图标行,`order: 6`
9
+ * ⇒ **紧贴在「技能」(order 5)下方**,仍在官方「自动化任务」(order 10)之前;
10
+ * ② `main`(root 作用域 keyed)→ 主区整页,`key` 与行 `id` 同名(`isolation-gate`)。
11
+ *
12
+ * 2026-10-21 界面重构(户主原话「现在太丑了」):
13
+ * · 顶部状态摘要(受管数 / 组数 / 三级分布 / 工具门 / 名单可用性)
14
+ * · 分组卡片 + 组头一键切(组内统一时高亮,不统一时显示"混合")
15
+ * · 每行「级别」用**分段控件**,不再三个独立按钮
16
+ * · 次要动作(归组 / 从名单移除)收进 `⋯` 菜单
17
+ * · 空态设计;添加受管对象走「选择文件夹」原生对话框(不可用则退路)
18
+ *
19
+ * 状态从哪来:**每次渲染都问宿主半要**(`GET /__dsh-isolation-gate/state`),页面自己
20
+ * 不留副本 ⇒ 页面不可能显示一个与工具侧读到的不一样的授权。
21
+ *
22
+ * `GateView` 是**纯函数**(只有 props,没有 hook),自测可以直接调它渲染。
23
+ */
24
+
25
+ window.__ModuleLoader__.load({
26
+ id: 'dsh-isolation-gate',
27
+ factory: (require) => {
28
+ console.log('[dsh-isolation-gate] client bundle evaluated')
29
+
30
+ const React = require('react')
31
+ const { useCallback, useEffect, useState } = React
32
+ const h = React.createElement
33
+
34
+ const BASE = '/__dsh-isolation-gate'
35
+ const PANEL_ID = 'isolation-gate'
36
+ const PANEL_ORDER = 6
37
+
38
+ /** 宿主真实主题 token(`--dsw-alias-*`);旧 `--dsw-color-*` 仅作兜底。 */
39
+ const C = {
40
+ text: 'var(--dsw-alias-label-primary, var(--dsw-color-text, #e8e8e8))',
41
+ dim: 'var(--dsw-alias-label-secondary, var(--dsw-color-text-secondary, #9aa0a6))',
42
+ card: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,0.04))',
43
+ card2: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03))',
44
+ border: 'var(--dsw-alias-border-l1, rgba(255,255,255,0.12))',
45
+ border2: 'var(--dsw-alias-border-l2, rgba(255,255,255,0.2))',
46
+ ok: 'var(--dsw-alias-state-success-primary, #4ade80)',
47
+ warn: 'var(--dsw-alias-state-warn-primary, #fbbf24)',
48
+ bad: 'var(--dsw-alias-state-error-primary, #f87171)',
49
+ idle: 'var(--dsw-alias-state-idle-primary, #9aa0a6)',
50
+ accent: 'var(--dsw-alias-brand-primary, #4d6bfe)',
51
+ overlay: 'var(--dsw-alias-bg-overlay, #1b1d24)',
52
+ }
53
+
54
+ const LEVEL_COLOR = { deny: C.idle, read: C.warn, full: C.bad }
55
+ const LEVEL_TONE = { deny: 'rgba(154,160,166,0.16)', read: 'rgba(251,191,36,0.16)', full: 'rgba(248,113,113,0.16)' }
56
+
57
+ async function call(path, init) {
58
+ const res = await fetch(path, init)
59
+ let body = null
60
+ try {
61
+ body = await res.json()
62
+ } catch {
63
+ body = null
64
+ }
65
+ if (!res.ok || (body && body.ok === false)) {
66
+ throw new Error((body && body.error) || `HTTP ${res.status}`)
67
+ }
68
+ return body
69
+ }
70
+
71
+ function post(path, payload) {
72
+ return call(path, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify(payload),
76
+ })
77
+ }
78
+
79
+ function GateIcon(props) {
80
+ const size = props && typeof props.size === 'number' ? props.size : 16
81
+ return h(
82
+ 'svg',
83
+ {
84
+ 'data-dsh-panel-entry': PANEL_ID,
85
+ viewBox: '0 0 16 16',
86
+ width: size,
87
+ height: size,
88
+ fill: 'none',
89
+ stroke: 'currentColor',
90
+ strokeWidth: '1.5',
91
+ strokeLinecap: 'round',
92
+ strokeLinejoin: 'round',
93
+ 'aria-hidden': 'true',
94
+ },
95
+ h('path', { d: 'M3 2.5h10v11H3z' }),
96
+ h('path', { d: 'M3 8h10' }),
97
+ h('circle', { cx: '8', cy: '8', r: '1' }),
98
+ )
99
+ }
100
+
101
+ function Badge(props) {
102
+ return h(
103
+ 'span',
104
+ {
105
+ style: {
106
+ fontSize: '11px',
107
+ lineHeight: '18px',
108
+ padding: '0 7px',
109
+ borderRadius: '999px',
110
+ border: `1px solid ${props.color || C.border}`,
111
+ background: props.bg || 'transparent',
112
+ color: props.color || C.dim,
113
+ whiteSpace: 'nowrap',
114
+ },
115
+ },
116
+ props.text,
117
+ )
118
+ }
119
+
120
+ function Button(props) {
121
+ const disabled = props.disabled === true
122
+ return h(
123
+ 'button',
124
+ {
125
+ onClick: props.onClick,
126
+ disabled,
127
+ title: props.title,
128
+ 'data-gate-action': props.action || undefined,
129
+ style: {
130
+ cursor: disabled ? 'default' : 'pointer',
131
+ fontSize: props.big ? '13px' : '12px',
132
+ fontWeight: props.primary ? 600 : 500,
133
+ padding: props.big ? '7px 14px' : '4px 10px',
134
+ borderRadius: '8px',
135
+ background: props.background || (props.primary ? C.accent : 'transparent'),
136
+ color: props.color || (props.primary ? '#0b0e14' : C.text),
137
+ border: props.primary ? 'none' : `1px solid ${C.border}`,
138
+ opacity: disabled ? 0.45 : 1,
139
+ },
140
+ },
141
+ props.label,
142
+ )
143
+ }
144
+
145
+ function Line(props) {
146
+ return h(
147
+ 'div',
148
+ {
149
+ style: Object.assign(
150
+ { fontSize: '11px', color: C.dim, marginTop: '2px', wordBreak: 'break-all' },
151
+ props.style || {},
152
+ ),
153
+ },
154
+ props.children,
155
+ )
156
+ }
157
+
158
+ /** 分段控件:一格三档,当前档高亮。 */
159
+ function Segmented(props) {
160
+ const levels = props.levels || []
161
+ return h(
162
+ 'div',
163
+ {
164
+ 'data-gate-segmented': props.scope || 'row',
165
+ role: 'group',
166
+ 'aria-label': '访问级别',
167
+ style: {
168
+ display: 'inline-flex',
169
+ padding: '2px',
170
+ gap: '2px',
171
+ borderRadius: '9px',
172
+ border: `1px solid ${C.border}`,
173
+ background: C.card2,
174
+ },
175
+ },
176
+ levels.map((item) =>
177
+ h(
178
+ 'button',
179
+ {
180
+ key: item.level,
181
+ type: 'button',
182
+ disabled: props.busy === true,
183
+ title: `${item.level} · ${item.hint}`,
184
+ onClick: () => props.onPick(item.level),
185
+ style: {
186
+ cursor: props.busy ? 'default' : 'pointer',
187
+ fontSize: '11px',
188
+ lineHeight: '20px',
189
+ padding: '0 9px',
190
+ borderRadius: '7px',
191
+ border: 'none',
192
+ background: item.level === props.value ? LEVEL_COLOR[item.level] : 'transparent',
193
+ color: item.level === props.value ? '#0b0e14' : C.dim,
194
+ fontWeight: item.level === props.value ? 700 : 500,
195
+ opacity: props.busy ? 0.55 : 1,
196
+ },
197
+ },
198
+ item.label,
199
+ ),
200
+ ),
201
+ )
202
+ }
203
+
204
+ /** 状态摘要里的一块数字。 */
205
+ function Stat(props) {
206
+ return h(
207
+ 'div',
208
+ {
209
+ style: {
210
+ minWidth: '92px',
211
+ padding: '8px 10px',
212
+ borderRadius: '10px',
213
+ border: `1px solid ${props.color ? props.color : C.border}`,
214
+ background: props.bg || C.card,
215
+ },
216
+ },
217
+ h('div', { style: { fontSize: '11px', color: C.dim } }, props.label),
218
+ h('div', { style: { fontSize: '18px', fontWeight: 700, color: props.color || C.text, lineHeight: '24px' } }, props.value),
219
+ )
220
+ }
221
+
222
+ function SectionTitle(props) {
223
+ return h(
224
+ 'div',
225
+ { style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '18px 0 8px' } },
226
+ h('div', { style: { fontSize: '13px', fontWeight: 700, color: C.text } }, props.title),
227
+ props.hint ? h('span', { style: { fontSize: '11px', color: C.dim } }, props.hint) : null,
228
+ h('span', { style: { flex: '1 1 auto' } }),
229
+ props.right || null,
230
+ )
231
+ }
232
+
233
+ /** 行的次要动作菜单。 */
234
+ function RowMenu(props) {
235
+ const groups = props.groups || []
236
+ return h(
237
+ 'div',
238
+ {
239
+ style: {
240
+ marginTop: '8px',
241
+ padding: '8px',
242
+ borderRadius: '8px',
243
+ border: `1px dashed ${C.border2}`,
244
+ background: C.card2,
245
+ display: 'flex',
246
+ flexDirection: 'column',
247
+ gap: '6px',
248
+ },
249
+ },
250
+ h(
251
+ 'div',
252
+ { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } },
253
+ h('span', { style: { fontSize: '11px', color: C.dim } }, '归组:'),
254
+ h(
255
+ 'select',
256
+ {
257
+ value: props.groupDraft && props.groupDraft.select !== undefined ? props.groupDraft.select : props.row.group || '',
258
+ onChange: (event) => props.onGroupSelect(event.target.value),
259
+ style: {
260
+ fontSize: '12px',
261
+ padding: '3px 6px',
262
+ borderRadius: '6px',
263
+ border: `1px solid ${C.border}`,
264
+ background: C.overlay,
265
+ color: C.text,
266
+ },
267
+ },
268
+ h('option', { value: '' }, '(不分组)'),
269
+ groups.map((group) => h('option', { key: group.name, value: group.name }, `${group.name}(${group.count})`)),
270
+ ),
271
+ h(
272
+ 'input',
273
+ {
274
+ value: props.groupDraft && props.groupDraft.text ? props.groupDraft.text : '',
275
+ placeholder: '或新建组名…',
276
+ onChange: (event) => props.onGroupText(event.target.value),
277
+ style: {
278
+ fontSize: '12px',
279
+ padding: '3px 7px',
280
+ width: '130px',
281
+ borderRadius: '6px',
282
+ border: `1px solid ${C.border}`,
283
+ background: C.overlay,
284
+ color: C.text,
285
+ },
286
+ },
287
+ ),
288
+ h(Button, {
289
+ label: '应用',
290
+ disabled: props.busy,
291
+ onClick: props.onApplyGroup,
292
+ }),
293
+ ),
294
+ h(
295
+ 'div',
296
+ { style: { display: 'flex', gap: '6px' } },
297
+ h(Button, {
298
+ label: '从名单移除',
299
+ title: '降低管制范围:移除后该对象不再受管辖;状态条目会同步清掉',
300
+ disabled: props.busy,
301
+ onClick: props.onAskRemove,
302
+ color: C.bad,
303
+ }),
304
+ ),
305
+ )
306
+ }
307
+
308
+ /** 拖:对象行可被拖走(数据放 text/plain,与技能面板同款)。 */
309
+ function rowDragProps(name) {
310
+ return {
311
+ draggable: true,
312
+ 'data-gate-draggable': 'true',
313
+ onDragStart: (event) => {
314
+ try {
315
+ event.dataTransfer.setData('text/plain', name)
316
+ event.dataTransfer.effectAllowed = 'copy'
317
+ } catch (error) {
318
+ /* 忽略:个别环境不给 dataTransfer */
319
+ }
320
+ },
321
+ }
322
+ }
323
+
324
+ /** 放:组卡片(groupName=组名)或「未分组」区(groupName='')。 */
325
+ function makeDropProps(groupName, onDropToGroup) {
326
+ const clear = (event) => {
327
+ if (event && event.currentTarget && event.currentTarget.style) {
328
+ event.currentTarget.style.borderColor = ''
329
+ event.currentTarget.style.boxShadow = ''
330
+ }
331
+ }
332
+ return {
333
+ onDragOver: (event) => {
334
+ event.preventDefault()
335
+ try {
336
+ event.dataTransfer.dropEffect = 'copy'
337
+ } catch (error) {
338
+ /* 忽略 */
339
+ }
340
+ if (event.currentTarget && event.currentTarget.style) {
341
+ event.currentTarget.style.borderColor = C.accent
342
+ event.currentTarget.style.boxShadow = '0 0 0 2px rgba(77,107,254,0.28)'
343
+ }
344
+ },
345
+ onDragLeave: clear,
346
+ onDrop: (event) => {
347
+ event.preventDefault()
348
+ clear(event)
349
+ let name = ''
350
+ try {
351
+ name = event.dataTransfer.getData('text/plain') || ''
352
+ } catch (error) {
353
+ /* 忽略 */
354
+ }
355
+ if (name && typeof onDropToGroup === 'function') onDropToGroup(name, groupName)
356
+ },
357
+ }
358
+ }
359
+
360
+ /** 一行受管对象。 */
361
+ function ObjectRow(props) {
362
+ const row = props.row
363
+ const open = props.menuFor === row.name
364
+ const missing = row.status === 'roster-missing-dir' || row.status === 'path-missing' || row.status === 'path-not-dir'
365
+ return h(
366
+ 'div',
367
+ {
368
+ 'data-gate-object': row.name,
369
+ ...rowDragProps(row.name),
370
+ style: {
371
+ border: `1px solid ${missing ? C.warn : C.border}`,
372
+ borderRadius: '10px',
373
+ padding: '9px 11px',
374
+ marginTop: '6px',
375
+ background: C.card,
376
+ cursor: 'grab',
377
+ },
378
+ },
379
+ h(
380
+ 'div',
381
+ { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
382
+ row.isPath === true ? h(Badge, { text: '[路径]', color: C.accent }) : null,
383
+ h('span', { style: { fontSize: '13px', fontWeight: 600, color: C.text, wordBreak: 'break-all' } }, row.name),
384
+ h(Badge, {
385
+ text: row.levelLabel,
386
+ color: LEVEL_COLOR[row.level],
387
+ bg: LEVEL_TONE[row.level],
388
+ }),
389
+ h(Badge, { text: row.dirExists ? '目录存在' : '目录不存在', color: row.dirExists ? C.ok : C.warn }),
390
+ h('span', { style: { flex: '1 1 auto' } }),
391
+ h(Segmented, {
392
+ scope: `row:${row.name}`,
393
+ levels: props.levels,
394
+ value: row.level,
395
+ busy: props.busy,
396
+ onPick: (level) => props.onPickLevel(row.name, level),
397
+ }),
398
+ h(
399
+ 'button',
400
+ {
401
+ type: 'button',
402
+ title: '更多动作',
403
+ 'aria-label': '更多动作',
404
+ 'data-gate-menu': 'row',
405
+ onClick: () => props.onToggleMenu(row.name),
406
+ style: {
407
+ cursor: 'pointer',
408
+ width: '26px',
409
+ height: '26px',
410
+ borderRadius: '7px',
411
+ border: `1px solid ${open ? C.border2 : C.border}`,
412
+ background: open ? C.card2 : 'transparent',
413
+ color: C.text,
414
+ fontSize: '15px',
415
+ lineHeight: '18px',
416
+ },
417
+ },
418
+ '⋯',
419
+ ),
420
+ ),
421
+ h(
422
+ Line,
423
+ null,
424
+ `${row.hint} · 允许:${(row.allowedOps || []).join(',') || '无'}` + (row.group ? ` · 组:${row.group}` : ''),
425
+ ),
426
+ open
427
+ ? h(RowMenu, {
428
+ row,
429
+ groups: props.groups,
430
+ groupDraft: props.groupDraft,
431
+ busy: props.busy,
432
+ onGroupSelect: props.onGroupSelect,
433
+ onGroupText: props.onGroupText,
434
+ onApplyGroup: () => props.onApplyGroup(row.name),
435
+ onAskRemove: () => props.onAskRemove(row.name),
436
+ })
437
+ : null,
438
+ )
439
+ }
440
+
441
+ /** 组卡片:组头一键切 + 组内成员。 */
442
+ function GroupCard(props) {
443
+ const group = props.group
444
+ const rows = props.rows
445
+ const open = props.menuFor === `group:${group.name}`
446
+ return h(
447
+ 'div',
448
+ {
449
+ 'data-gate-group': group.name,
450
+ ...makeDropProps(group.name, props.onDropToGroup),
451
+ style: {
452
+ marginTop: '10px',
453
+ border: `1px solid ${C.border2}`,
454
+ borderRadius: '12px',
455
+ background: C.card2,
456
+ overflow: 'hidden',
457
+ },
458
+ },
459
+ h(
460
+ 'div',
461
+ {
462
+ style: {
463
+ display: 'flex',
464
+ alignItems: 'center',
465
+ gap: '8px',
466
+ flexWrap: 'wrap',
467
+ padding: '10px 12px',
468
+ borderBottom: `1px solid ${C.border}`,
469
+ },
470
+ },
471
+ h('span', { style: { fontSize: '13px', fontWeight: 700, color: C.text } }, group.name),
472
+ h(Badge, { text: `${rows.length} 个`, color: C.dim }),
473
+ group.uniformLevel
474
+ ? h(Badge, { text: `组内统一:${props.labelOf(group.uniformLevel)}`, color: LEVEL_COLOR[group.uniformLevel] })
475
+ : h(Badge, { text: '组内级别混合', color: C.warn }),
476
+ h('span', { style: { flex: '1 1 auto' } }),
477
+ h(Segmented, {
478
+ scope: `group:${group.name}`,
479
+ levels: props.levels,
480
+ value: group.uniformLevel || '',
481
+ busy: props.busy,
482
+ onPick: (level) => props.onPickGroupLevel(group.name, level),
483
+ }),
484
+ h(
485
+ 'button',
486
+ {
487
+ type: 'button',
488
+ title: '组动作',
489
+ 'aria-label': '组动作',
490
+ 'data-gate-menu': 'group',
491
+ onClick: () => props.onToggleMenu(`group:${group.name}`),
492
+ style: {
493
+ cursor: 'pointer',
494
+ width: '26px',
495
+ height: '26px',
496
+ borderRadius: '7px',
497
+ border: `1px solid ${open ? C.border2 : C.border}`,
498
+ background: open ? C.card : 'transparent',
499
+ color: C.text,
500
+ fontSize: '15px',
501
+ lineHeight: '18px',
502
+ },
503
+ },
504
+ '⋯',
505
+ ),
506
+ ),
507
+ open
508
+ ? h(
509
+ 'div',
510
+ { style: { padding: '8px 12px', borderBottom: `1px solid ${C.border}` } },
511
+ h(Button, {
512
+ label: '删除这个组(成员保留,只清归属)',
513
+ disabled: props.busy,
514
+ onClick: () => props.onDeleteGroup(group.name),
515
+ color: C.bad,
516
+ }),
517
+ )
518
+ : null,
519
+ h(
520
+ 'div',
521
+ { style: { padding: '2px 10px 10px' } },
522
+ rows.length === 0
523
+ ? h(Line, null, '这个组还没有成员 —— 把下面「未分组」里的对象直接拖进这张卡片即可。')
524
+ : rows.map((row) =>
525
+ h(ObjectRow, {
526
+ key: `${row.kind || 'name'}:${row.name}`,
527
+ row,
528
+ levels: props.levels,
529
+ groups: props.groups,
530
+ busy: props.busy,
531
+ menuFor: props.menuFor,
532
+ groupDraft: props.groupDraft,
533
+ onPickLevel: props.onPickLevel,
534
+ onToggleMenu: props.onToggleMenu,
535
+ onGroupSelect: props.onGroupSelect,
536
+ onGroupText: props.onGroupText,
537
+ onApplyGroup: props.onApplyGroup,
538
+ onAskRemove: props.onAskRemove,
539
+ }),
540
+ ),
541
+ ),
542
+ )
543
+ }
544
+
545
+ /**
546
+ * 纯视图:`props.model` 为 null 时是首次加载态。
547
+ * 自测直接调它,不需要 React 运行时。
548
+ */
549
+ function GateView(props) {
550
+ const model = props.model
551
+ const levels = (model && model.levels) || []
552
+
553
+ if (!model) {
554
+ return h(
555
+ 'div',
556
+ { 'data-dsh-plugin': PANEL_ID, style: { padding: '24px', color: C.text } },
557
+ h('div', { style: { fontSize: '16px', fontWeight: 700 } }, '门禁 · 目录访问管制'),
558
+ h(Line, null, props.busy ? '读取中…' : '(尚未读取)'),
559
+ props.error ? h('div', { style: { color: C.bad, fontSize: '12px', marginTop: '8px' } }, `✗ ${props.error}`) : null,
560
+ )
561
+ }
562
+
563
+ const rosterRows = model.roster || []
564
+ const groups = model.groups || []
565
+ const ungrouped = rosterRows.filter((row) => !row.group || !groups.some((group) => group.name === row.group))
566
+ const diffs = model.diffs || {}
567
+ const gateClosed = model.files && model.files.gateClosed === true
568
+ const missingDir = diffs.rosterMissingDir || []
569
+ const suspectUnlisted = diffs.dirNotListedSuspect || []
570
+ const caseMismatch = diffs.caseMismatch || []
571
+ const pathMissing = diffs.pathMissing || []
572
+ const pathNotDir = diffs.pathNotDir || []
573
+ const unlisted = model.unlistedDirs || []
574
+ const stateOnly = model.stateOnly || []
575
+ const counts = model.rosterCounts || {}
576
+ const labelOf = (level) => {
577
+ const found = levels.find((item) => item.level === level)
578
+ return found ? found.label : level
579
+ }
580
+
581
+ const groupProps = {
582
+ levels,
583
+ groups,
584
+ busy: props.busy,
585
+ menuFor: props.menuFor,
586
+ groupDraft: props.groupDraft,
587
+ labelOf,
588
+ onPickLevel: props.onPickLevel,
589
+ onToggleMenu: props.onToggleMenu,
590
+ onGroupSelect: props.onGroupSelect,
591
+ onGroupText: props.onGroupText,
592
+ onApplyGroup: props.onApplyGroup,
593
+ onAskRemove: props.onAskRemove,
594
+ onPickGroupLevel: props.onPickGroupLevel,
595
+ onDeleteGroup: props.onDeleteGroup,
596
+ onDropToGroup: props.onDropToGroup,
597
+ }
598
+
599
+ return h(
600
+ 'div',
601
+ {
602
+ 'data-dsh-plugin': PANEL_ID,
603
+ style: {
604
+ padding: '18px 22px 32px',
605
+ color: C.text,
606
+ fontFamily: 'inherit',
607
+ overflow: 'auto',
608
+ height: '100%',
609
+ boxSizing: 'border-box',
610
+ background: C.card2,
611
+ },
612
+ },
613
+
614
+ // —— 局部样式(主题 token;仅本面板作用域) ——
615
+ h(
616
+ 'style',
617
+ { key: 'gate-style' },
618
+ [
619
+ '.gate-path-input::placeholder{color:var(--dsw-alias-label-secondary,#9aa0a6);opacity:.72}',
620
+ '.gate-path-input:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24))}',
621
+ '.gate-path-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.16)}',
622
+ '.gate-browse>summary{list-style:none;cursor:pointer;user-select:none}',
623
+ '.gate-browse>summary::-webkit-details-marker{display:none}',
624
+ '.gate-browse[open]>summary{border-color:var(--dsw-alias-brand-primary,#4d6bfe)!important;color:var(--dsw-alias-brand-primary,#4d6bfe)!important}',
625
+ ].join('\n'),
626
+ ),
627
+
628
+ // —— 抬头 ——
629
+ h(
630
+ 'div',
631
+ { style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' } },
632
+ h('div', { style: { fontSize: '20px', fontWeight: 750, letterSpacing: '0.2px' } }, '门禁 · 目录访问管制'),
633
+ h('div', { style: { fontSize: '12px', color: C.dim } }, '隔离区授权台 · 默认禁 · 名单制'),
634
+ h('span', { style: { flex: '1 1 auto' } }),
635
+ h(Button, { label: props.busy ? '刷新中…' : '刷新', onClick: props.onRefresh, disabled: props.busy }),
636
+ ),
637
+
638
+ // —— 摘要条 ——
639
+ h(
640
+ 'div',
641
+ { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '12px' } },
642
+ h(Stat, { label: '受管对象', value: counts.total || 0 }),
643
+ h(Stat, { label: '分组', value: groups.length }),
644
+ h(Stat, { label: '禁', value: counts.deny || 0, color: LEVEL_COLOR.deny }),
645
+ h(Stat, { label: '只读', value: counts.read || 0, color: LEVEL_COLOR.read }),
646
+ h(Stat, { label: '可读写删', value: counts.full || 0, color: LEVEL_COLOR.full }),
647
+ h(Stat, {
648
+ label: '工具执行门',
649
+ value: model.features && model.features.toolGate ? '已开' : '未开',
650
+ color: model.features && model.features.toolGate ? C.ok : C.warn,
651
+ }),
652
+ h(Stat, {
653
+ label: `名字 ${counts.names || 0} · 路径 ${counts.paths || 0}`,
654
+ value: gateClosed ? '不可用' : '正常',
655
+ color: gateClosed ? C.bad : C.ok,
656
+ }),
657
+ ),
658
+
659
+ // —— 警示 ——
660
+ gateClosed
661
+ ? h(
662
+ 'div',
663
+ { style: { marginTop: '10px', border: `1px solid ${C.bad}`, borderRadius: '10px', padding: '8px 10px', background: 'rgba(248,113,113,0.08)' } },
664
+ h('div', { style: { fontSize: '12px', color: C.bad, fontWeight: 700 } }, '⚠ 名单不可用 —— 通道与工具门当前一律拒绝'),
665
+ h(Line, null, model.files.rosterGateMessage || '请先恢复名单文件,再刷新本页。'),
666
+ )
667
+ : null,
668
+ model.files && model.files.stateMigratedFrom
669
+ ? h(Line, { style: { color: C.ok, marginTop: '8px' } }, `✓ 状态文件已从 v${model.files.stateMigratedFrom} 自动迁移(级别未丢;下次写盘落成 v${model.version || 2})`)
670
+ : null,
671
+ model.files && model.files.stateError
672
+ ? h(Line, { style: { color: C.bad } }, `⚠ ${model.files.stateError}`)
673
+ : null,
674
+ model.files && (model.files.rosterDuplicates || []).length > 0
675
+ ? h(Line, { style: { color: C.warn } }, `⚠ 名单里有重复行:${model.files.rosterDuplicates.join(' / ')}`)
676
+ : null,
677
+ model.files && (model.files.rosterInvalid || []).length > 0
678
+ ? h(
679
+ Line,
680
+ { style: { color: C.warn } },
681
+ `⚠ 名单里有不合法、**未生效**的行:${model.files.rosterInvalid
682
+ .map((row) => `${row.raw}(${row.error})`)
683
+ .join(' / ')}`,
684
+ )
685
+ : null,
686
+ props.error ? h('div', { style: { color: C.bad, fontSize: '12px', marginTop: '8px' } }, `✗ ${props.error}`) : null,
687
+ props.notice ? h('div', { style: { color: C.ok, fontSize: '12px', marginTop: '8px' } }, `✓ ${props.notice}`) : null,
688
+
689
+ // —— 添加受管对象 ——
690
+ h(
691
+ 'div',
692
+ {
693
+ 'data-gate-add': 'bar',
694
+ style: {
695
+ marginTop: '14px',
696
+ padding: '12px',
697
+ borderRadius: '12px',
698
+ border: `1px solid ${C.border}`,
699
+ background: C.card,
700
+ },
701
+ },
702
+ h('div', { style: { fontSize: '13px', fontWeight: 700 } }, '添加受管对象'),
703
+ h(
704
+ Line,
705
+ null,
706
+ model.features && model.features.picker
707
+ ? '用「选择文件夹」调系统的目录选择器;也可以直接输入绝对路径。新增项一律先按「禁」处理。'
708
+ : '没有系统目录选择器?点「浏览目录」从本机一层目录里挑,或直接输入绝对路径。新增项一律先按「禁」处理。',
709
+ ),
710
+ h(
711
+ 'div',
712
+ { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' } },
713
+ model.features && model.features.picker
714
+ ? h(Button, {
715
+ label: props.picking ? '选择中…' : '选择文件夹…',
716
+ disabled: props.busy || props.picking,
717
+ onClick: props.onPickFolder,
718
+ primary: true,
719
+ })
720
+ : h(
721
+ 'details',
722
+ { className: 'gate-browse', style: { position: 'relative' } },
723
+ h(
724
+ 'summary',
725
+ {
726
+ style: {
727
+ display: 'inline-flex',
728
+ alignItems: 'center',
729
+ gap: '6px',
730
+ padding: '8px 12px',
731
+ borderRadius: '9px',
732
+ border: `1px solid ${C.border2}`,
733
+ background: C.card2,
734
+ color: C.text,
735
+ fontSize: '13px',
736
+ whiteSpace: 'nowrap',
737
+ },
738
+ },
739
+ '浏览目录 ▾',
740
+ ),
741
+ h(
742
+ 'div',
743
+ {
744
+ style: {
745
+ position: 'absolute',
746
+ top: 'calc(100% + 6px)',
747
+ left: 0,
748
+ zIndex: 30,
749
+ width: '320px',
750
+ maxHeight: '220px',
751
+ overflow: 'auto',
752
+ display: 'flex',
753
+ flexWrap: 'wrap',
754
+ gap: '6px',
755
+ padding: '10px',
756
+ borderRadius: '10px',
757
+ border: `1px solid ${C.border2}`,
758
+ background: C.overlay,
759
+ boxShadow: '0 12px 28px rgba(0,0,0,0.35)',
760
+ },
761
+ },
762
+ unlisted.length === 0
763
+ ? h('span', { style: { fontSize: '12px', color: C.dim } }, '一层目录已全部在名单里;可直接输入绝对路径。')
764
+ : unlisted.map((dir) =>
765
+ h(Button, {
766
+ key: dir.name,
767
+ label: dir.suspect ? `${dir.name} ⚠` : dir.name,
768
+ disabled: props.busy,
769
+ onClick: () => props.onAskAdd(dir.name),
770
+ }),
771
+ ),
772
+ ),
773
+ ),
774
+ h('input', {
775
+ className: 'gate-path-input',
776
+ value: props.pathInput || '',
777
+ placeholder: '输入绝对路径,如 D:\\Zcode',
778
+ onChange: (event) => props.onPathInput(event.target.value),
779
+ onKeyDown: (event) => {
780
+ if (event.key === 'Enter') props.onAskAddPath()
781
+ },
782
+ style: {
783
+ flex: '1 1 320px',
784
+ minWidth: '220px',
785
+ fontSize: '13px',
786
+ padding: '9px 12px',
787
+ borderRadius: '9px',
788
+ border: `1px solid ${C.border2}`,
789
+ background: C.card2,
790
+ color: C.text,
791
+ transition: 'border-color .15s ease, box-shadow .15s ease',
792
+ },
793
+ }),
794
+ h(Button, { label: '加入名单', primary: true, disabled: props.busy || !(props.pathInput || '').trim(), onClick: props.onAskAddPath }),
795
+ ),
796
+ ),
797
+
798
+ // —— 待确认条 ——
799
+ props.pending
800
+ ? h(
801
+ 'div',
802
+ { style: { marginTop: '10px', border: `1px solid ${C.warn}`, borderRadius: '10px', padding: '8px 10px', background: 'rgba(251,191,36,0.08)' } },
803
+ h(
804
+ 'div',
805
+ { style: { fontSize: '12px', color: C.warn } },
806
+ props.pending.action === 'add'
807
+ ? `把「${props.pending.name}」加入受管名单?加入后它立即按「禁」处理(新增项不会自动授权)。`
808
+ : `把「${props.pending.name}」从受管名单移除?⚠ 这会**降低管制范围**:移除后它不再受管辖,状态条目也会同步清掉。`,
809
+ ),
810
+ h(
811
+ 'div',
812
+ { style: { display: 'flex', gap: '6px', marginTop: '8px' } },
813
+ h(Button, {
814
+ label: props.pending.action === 'add' ? '确认加入(改前先备份)' : '确认移除(改前先备份)',
815
+ primary: true,
816
+ background: props.pending.action === 'add' ? C.accent : C.bad,
817
+ disabled: props.busy,
818
+ onClick: props.onConfirmPending,
819
+ }),
820
+ h(Button, { label: '取消', onClick: props.onCancelPending, disabled: props.busy }),
821
+ ),
822
+ )
823
+ : null,
824
+
825
+ // —— 空态 ——
826
+ rosterRows.length === 0
827
+ ? h(
828
+ 'div',
829
+ {
830
+ 'data-gate-empty': 'true',
831
+ style: {
832
+ marginTop: '16px',
833
+ padding: '26px 20px',
834
+ textAlign: 'center',
835
+ borderRadius: '14px',
836
+ border: `1px dashed ${C.border2}`,
837
+ background: C.card,
838
+ },
839
+ },
840
+ h('div', { style: { fontSize: '28px', lineHeight: '34px' } }, '🛡'),
841
+ h('div', { style: { fontSize: '14px', fontWeight: 700, marginTop: '6px' } }, '还没有受管对象'),
842
+ h(Line, null, '把要隔离的目录加进来(比如别的 harness 的家目录),它们会先按「禁」处理,再逐项放开。'),
843
+ suspectUnlisted.length > 0
844
+ ? h(
845
+ 'div',
846
+ { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'center', marginTop: '10px' } },
847
+ suspectUnlisted.map((name) =>
848
+ h(Button, { key: name, label: `加入「${name}」`, disabled: props.busy, onClick: () => props.onAskAdd(name) }),
849
+ ),
850
+ )
851
+ : null,
852
+ )
853
+ : null,
854
+
855
+ // —— 分组 ——
856
+ h(
857
+ 'div',
858
+ null,
859
+ h(SectionTitle, {
860
+ title: '分组',
861
+ hint: '组头一键切三档 · 把对象拖进卡片即可归组',
862
+ right: h(Button, {
863
+ label: props.newGroupOpen ? '× 收起' : '+ 新建分组',
864
+ disabled: props.busy,
865
+ onClick: props.onToggleNewGroup,
866
+ }),
867
+ }),
868
+ props.newGroupOpen
869
+ ? h(
870
+ 'div',
871
+ {
872
+ style: {
873
+ marginTop: '8px',
874
+ padding: '10px',
875
+ borderRadius: '10px',
876
+ border: `1px solid ${C.border}`,
877
+ background: C.card,
878
+ display: 'flex',
879
+ gap: '8px',
880
+ flexWrap: 'wrap',
881
+ alignItems: 'center',
882
+ },
883
+ },
884
+ h('input', {
885
+ className: 'gate-path-input',
886
+ value: (props.newGroupDraft && props.newGroupDraft.name) || '',
887
+ placeholder: '新组名,如:harness 隔离 2',
888
+ autoFocus: true,
889
+ onChange: (event) => props.onNewGroupName(event.target.value),
890
+ onKeyDown: (event) => {
891
+ if (event.key === 'Enter') props.onCreateGroup()
892
+ },
893
+ style: {
894
+ flex: '1 1 200px',
895
+ minWidth: '170px',
896
+ fontSize: '13px',
897
+ padding: '6px 9px',
898
+ borderRadius: '8px',
899
+ border: `1px solid ${C.border}`,
900
+ background: C.overlay,
901
+ color: C.text,
902
+ },
903
+ }),
904
+ h('input', {
905
+ className: 'gate-path-input',
906
+ value: (props.newGroupDraft && props.newGroupDraft.note) || '',
907
+ placeholder: '备注(可选)',
908
+ onChange: (event) => props.onNewGroupNote(event.target.value),
909
+ style: {
910
+ flex: '1 1 150px',
911
+ minWidth: '130px',
912
+ fontSize: '13px',
913
+ padding: '6px 9px',
914
+ borderRadius: '8px',
915
+ border: `1px solid ${C.border}`,
916
+ background: C.overlay,
917
+ color: C.text,
918
+ },
919
+ }),
920
+ h(Button, { label: '创建', disabled: props.busy, onClick: props.onCreateGroup }),
921
+ h(
922
+ 'div',
923
+ { style: { fontSize: '11px', color: C.dim, flex: '1 1 100%' } },
924
+ '建出来是空组(0 个成员);把对象放进去:该行 `⋯` →「归组」里选这个组 → 应用。',
925
+ ),
926
+ )
927
+ : null,
928
+ groups.length === 0 && !props.newGroupOpen
929
+ ? h(Line, null, '还没有分组。点右上「+ 新建分组」先起组名,或在某个对象的 `⋯` 里直接归组(组名不存在会自动建)。')
930
+ : null,
931
+ groups.map((group) =>
932
+ h(GroupCard, {
933
+ key: group.name,
934
+ group,
935
+ rows: rosterRows.filter((row) => row.group === group.name),
936
+ ...groupProps,
937
+ }),
938
+ ),
939
+ ),
940
+
941
+ // —— 未分组对象(也是"拖出组"的落点:拖到这里 = 取消分组) ——
942
+ rosterRows.length > 0
943
+ ? h(
944
+ 'div',
945
+ {
946
+ 'data-gate-drop': 'ungrouped',
947
+ ...makeDropProps('', props.onDropToGroup),
948
+ style: { borderRadius: '12px' },
949
+ },
950
+ h(SectionTitle, {
951
+ title: groups.length > 0 ? '未分组' : '受管对象',
952
+ hint:
953
+ groups.length > 0
954
+ ? `${ungrouped.length} 个 · 拖进上面的组卡片即可归组`
955
+ : '每行 `⋯` 可归组 / 从名单移除,也可直接拖进组卡片',
956
+ }),
957
+ ungrouped.length === 0
958
+ ? h(Line, null, '所有对象都已归组。')
959
+ : ungrouped.map((row) =>
960
+ h(ObjectRow, {
961
+ key: `${row.kind || 'name'}:${row.name}`,
962
+ row,
963
+ levels,
964
+ groups,
965
+ busy: props.busy,
966
+ menuFor: props.menuFor,
967
+ groupDraft: props.groupDraft,
968
+ onPickLevel: props.onPickLevel,
969
+ onToggleMenu: props.onToggleMenu,
970
+ onGroupSelect: props.onGroupSelect,
971
+ onGroupText: props.onGroupText,
972
+ onApplyGroup: props.onApplyGroup,
973
+ onAskRemove: props.onAskRemove,
974
+ }),
975
+ ),
976
+ )
977
+ : null,
978
+
979
+ // —— 差异 ——
980
+ h(
981
+ 'div',
982
+ { style: { marginTop: '18px', border: `1px solid ${C.border}`, borderRadius: '12px', padding: '10px 12px', background: C.card } },
983
+ h('div', { style: { fontSize: '13px', fontWeight: 700 } }, '名单 ↔ 磁盘 差异'),
984
+ missingDir.length === 0 && suspectUnlisted.length === 0 && caseMismatch.length === 0 && pathMissing.length === 0 && pathNotDir.length === 0
985
+ ? h(Line, { style: { color: C.ok } }, '✓ 无差异(名单里的目录都在;疑似受管目录都已入名单)')
986
+ : null,
987
+ missingDir.length > 0
988
+ ? h(Line, { style: { color: C.warn } }, `⚠ 已在名单但目录不存在:${missingDir.join(' / ')}(疑似笔误 / 已删 / 盘未挂)`)
989
+ : null,
990
+ pathMissing.length > 0
991
+ ? h(Line, { style: { color: C.warn } }, `⚠ [路径] 受管对象当前目录不存在:${pathMissing.join(' / ')}`)
992
+ : null,
993
+ pathNotDir.length > 0
994
+ ? h(Line, { style: { color: C.warn } }, `⚠ [路径] 受管对象存在但不是目录:${pathNotDir.join(' / ')}`)
995
+ : null,
996
+ suspectUnlisted.length > 0
997
+ ? h(Line, { style: { color: C.warn } }, `⚠ 目录存在但未入名单(疑似受管目录):${suspectUnlisted.join(' / ')} —— 下方可一键加入`)
998
+ : null,
999
+ caseMismatch.length > 0
1000
+ ? h(Line, { style: { color: C.dim } }, `· 大小写不一致:${caseMismatch.map((row) => `${row.roster}→${row.disk}`).join(' / ')}(Windows 不区分,不影响生效)`)
1001
+ : null,
1002
+ ),
1003
+
1004
+ // —— 未入名单目录 ——
1005
+ h(
1006
+ 'div',
1007
+ { style: { marginTop: '12px' } },
1008
+ h(SectionTitle, {
1009
+ title: `未入名单的 ${(model.paths && model.paths.root) || ''} 一层目录`,
1010
+ hint: `${unlisted.length} 个 · 仅目录名,不读任何目录内容`,
1011
+ right: h(Button, { label: props.showUnlisted ? '收起' : '展开', onClick: props.onToggleUnlisted }),
1012
+ }),
1013
+ props.showUnlisted
1014
+ ? h(
1015
+ 'div',
1016
+ { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', maxHeight: '240px', overflow: 'auto' } },
1017
+ unlisted.map((dir) =>
1018
+ h(
1019
+ 'span',
1020
+ { key: dir.name, style: { display: 'inline-flex', alignItems: 'center', gap: '4px' } },
1021
+ h(
1022
+ 'span',
1023
+ { style: { fontSize: '12px', color: dir.suspect ? C.warn : C.dim } },
1024
+ dir.name,
1025
+ ),
1026
+ h(Button, { label: '加入名单', disabled: props.busy, onClick: () => props.onAskAdd(dir.name) }),
1027
+ ),
1028
+ ),
1029
+ )
1030
+ : null,
1031
+ ),
1032
+
1033
+ // —— 孤儿键 ——
1034
+ stateOnly.length > 0
1035
+ ? h(
1036
+ 'div',
1037
+ { style: { marginTop: '12px', border: `1px solid ${C.warn}`, borderRadius: '12px', padding: '10px 12px' } },
1038
+ h(
1039
+ 'div',
1040
+ { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1041
+ h('div', { style: { fontSize: '13px', fontWeight: 700, color: C.warn } }, '孤儿键(状态里有、名单里没有)'),
1042
+ h('span', { style: { flex: '1 1 auto' } }),
1043
+ h(Button, { label: `清理 ${stateOnly.length} 个`, disabled: props.busy, onClick: props.onPrune }),
1044
+ ),
1045
+ stateOnly.map((row) => h(Line, { key: row.name }, `${row.name} = ${row.level}(${row.levelLabel})${row.group ? ` · 组:${row.group}` : ''}`)),
1046
+ )
1047
+ : null,
1048
+
1049
+ // —— 工具 / agent 读法 + 审计(折叠) ——
1050
+ h(
1051
+ 'details',
1052
+ { style: { marginTop: '16px', borderTop: `1px solid ${C.border}`, paddingTop: '8px' } },
1053
+ h('summary', { style: { cursor: 'pointer', fontSize: '12px', fontWeight: 600, color: C.text } }, '工具 / agent 侧怎么读这个状态'),
1054
+ h(Line, null, `状态文件(唯一真身):${model.paths.state} · 级别键:${levels.map((item) => item.level).join(' / ')}`),
1055
+ h(Line, null, '受管辖通道:node <插件目录>\\bin\\gate.mjs status'),
1056
+ h(Line, null, '判定(不动手):… check --name <名> --op read|write|delete|traverse(也可 --path <绝对路径>);被拒时退出码 3'),
1057
+ h(Line, null, '工具执行门:宿主 tools/pre-execute 钩子;判定与通道同一套 checkAccess,拒绝文案会回到模型'),
1058
+ (model.policy || []).map((line) => h(Line, { key: line }, `· ${line}`)),
1059
+ ),
1060
+ h(
1061
+ 'details',
1062
+ { style: { marginTop: '8px' } },
1063
+ h(
1064
+ 'summary',
1065
+ { style: { cursor: 'pointer', fontSize: '12px', fontWeight: 600, color: C.text } },
1066
+ `审计(最近 ${(model.audit && model.audit.entries.length) || 0} 条 / 共 ${(model.audit && model.audit.total) || 0} 条)`,
1067
+ ),
1068
+ !model.audit || model.audit.entries.length === 0
1069
+ ? h(Line, null, '(暂无记录)')
1070
+ : model.audit.entries.map((entry, index) =>
1071
+ h(
1072
+ Line,
1073
+ { key: `${entry.at || index}-${index}`, style: { color: entry.allowed ? C.dim : C.bad } },
1074
+ `${entry.atLocal || entry.at} · ${entry.allowed ? '放行' : '拒绝'} · ${entry.channel} · ${entry.name || '-'} · ${entry.op} · ${entry.detail || ''}`,
1075
+ ),
1076
+ ),
1077
+ ),
1078
+
1079
+ h(
1080
+ Line,
1081
+ { style: { marginTop: '16px' } },
1082
+ '纪律:本页只读写两个配置文件(状态 JSON / 名单 TXT)、列出盘根一层目录名,并对名单里**显式列出的路径**做一次存在性判定 —— **不读任何受管对象内部内容**;' +
1083
+ '改名单前自动备份到回收站;开关即落盘,重启后保持。名单缺失 / 损坏时通道与工具门**一律拒绝**(fail-closed)。列出的目录名不代表已授权访问。',
1084
+ ),
1085
+ )
1086
+ }
1087
+
1088
+ /** 有状态的一层:只负责取数与动作,渲染全交给 GateView。 */
1089
+ function GatePage() {
1090
+ const [model, setModel] = useState(null)
1091
+ const [busy, setBusy] = useState(false)
1092
+ const [picking, setPicking] = useState(false)
1093
+ const [error, setError] = useState(null)
1094
+ const [notice, setNotice] = useState(null)
1095
+ const [showUnlisted, setShowUnlisted] = useState(false)
1096
+ const [pending, setPending] = useState(null)
1097
+ const [pathInput, setPathInput] = useState('')
1098
+ const [menuFor, setMenuFor] = useState(null)
1099
+ const [groupDraft, setGroupDraft] = useState({ select: '', text: '' })
1100
+ const [newGroupOpen, setNewGroupOpen] = useState(false)
1101
+ const [newGroupDraft, setNewGroupDraft] = useState({ name: '', note: '' })
1102
+
1103
+ const load = useCallback(async () => {
1104
+ setBusy(true)
1105
+ setError(null)
1106
+ try {
1107
+ setModel(await call(`${BASE}/state`))
1108
+ } catch (err) {
1109
+ setError(err.message)
1110
+ } finally {
1111
+ setBusy(false)
1112
+ }
1113
+ }, [])
1114
+
1115
+ useEffect(() => {
1116
+ load()
1117
+ }, [load])
1118
+
1119
+ const run = useCallback(async (action, successText) => {
1120
+ setBusy(true)
1121
+ setError(null)
1122
+ setNotice(null)
1123
+ try {
1124
+ const result = await action()
1125
+ if (result && result.report) setModel(result.report)
1126
+ else await load()
1127
+ if (successText) setNotice(typeof successText === 'function' ? successText(result) : successText)
1128
+ return result
1129
+ } catch (err) {
1130
+ setError(err.message)
1131
+ return null
1132
+ } finally {
1133
+ setBusy(false)
1134
+ }
1135
+ }, [load])
1136
+
1137
+ const pickLevel = useCallback(
1138
+ (name, level) =>
1139
+ run(
1140
+ () => post(`${BASE}/level`, { name, level }),
1141
+ (result) => `${result.name} → ${result.levelLabel}(${result.level})· 已落盘,重启后保持`,
1142
+ ),
1143
+ [run],
1144
+ )
1145
+
1146
+ const pickGroupLevel = useCallback(
1147
+ (group, level) =>
1148
+ run(
1149
+ () => post(`${BASE}/group-level`, { group, level }),
1150
+ (result) => `组「${result.group}」${result.count} 个对象 → ${result.levelLabel}(${result.level})`,
1151
+ ),
1152
+ [run],
1153
+ )
1154
+
1155
+ const pickFolder = useCallback(async () => {
1156
+ setPicking(true)
1157
+ setError(null)
1158
+ setNotice(null)
1159
+ try {
1160
+ const result = await post(`${BASE}/pick`, {})
1161
+ if (result && result.ok && result.path) {
1162
+ setPathInput(result.path)
1163
+ setNotice(`已选择:${result.path} —— 点「加入名单」确认`)
1164
+ }
1165
+ } catch (err) {
1166
+ setError(`${err.message}(可改用下方"未入名单的一层目录"或手动输入)`)
1167
+ } finally {
1168
+ setPicking(false)
1169
+ }
1170
+ }, [])
1171
+
1172
+ const applyGroup = useCallback(
1173
+ (name) =>
1174
+ run(
1175
+ () => {
1176
+ const group = (groupDraft.text || '').trim() || groupDraft.select || ''
1177
+ return post(`${BASE}/group`, { action: 'assign', name, group })
1178
+ },
1179
+ (result) => (result.group ? `「${result.name}」已归入组「${result.group}」` : `「${result.name}」已取消分组`),
1180
+ ).then(() => {
1181
+ setMenuFor(null)
1182
+ setGroupDraft({ select: '', text: '' })
1183
+ }),
1184
+ [run, groupDraft],
1185
+ )
1186
+
1187
+ const createGroup = useCallback(
1188
+ () =>
1189
+ run(
1190
+ async () => {
1191
+ try {
1192
+ return await post(`${BASE}/group`, {
1193
+ action: 'create',
1194
+ group: (newGroupDraft.name || '').trim(),
1195
+ note: (newGroupDraft.note || '').trim(),
1196
+ })
1197
+ } catch (err) {
1198
+ const text = String(err && err.message ? err.message : err)
1199
+ // 宿主半还停在旧代码(旧路由只认 assign / delete)⇒ 说人话,别让人猜
1200
+ if (text.includes('assign / delete') || text.includes('assign / delete,收到')) {
1201
+ throw new Error('宿主半还是旧版:请重启一次 dsh,再点「创建」')
1202
+ }
1203
+ throw err
1204
+ }
1205
+ },
1206
+ (result) => `已新建分组「${result.group}」(空组 · 0 个成员)`,
1207
+ ).then((result) => {
1208
+ if (result) {
1209
+ setNewGroupOpen(false)
1210
+ setNewGroupDraft({ name: '', note: '' })
1211
+ }
1212
+ }),
1213
+ [run, newGroupDraft],
1214
+ )
1215
+
1216
+ const dropToGroup = useCallback(
1217
+ (name, group) =>
1218
+ run(
1219
+ () => post(`${BASE}/group`, { action: 'assign', name, group }),
1220
+ (result) => (result.group ? `「${result.name}」已拖入组「${result.group}」` : `「${result.name}」已拖出分组`),
1221
+ ),
1222
+ [run],
1223
+ )
1224
+
1225
+ const deleteGroup = useCallback(
1226
+ (group) =>
1227
+ run(
1228
+ () => post(`${BASE}/group`, { action: 'delete', group }),
1229
+ (result) => `组「${result.group}」已删除(${result.released.length} 个成员保留)`,
1230
+ ).then(() => setMenuFor(null)),
1231
+ [run],
1232
+ )
1233
+
1234
+ const confirmPending = useCallback(async () => {
1235
+ if (!pending) return
1236
+ await run(
1237
+ () => post(`${BASE}/roster`, { action: pending.action, name: pending.name }),
1238
+ (result) =>
1239
+ `名单${result.action === 'add' ? '加入' : '移除'}「${result.name}」已落盘` +
1240
+ `(备份:${result.backup || '原文件不存在,无需备份'})` +
1241
+ (result.stateNote ? ` · ${result.stateNote}` : ''),
1242
+ )
1243
+ setPending(null)
1244
+ }, [pending, run])
1245
+
1246
+ const prune = useCallback(
1247
+ () => run(() => post(`${BASE}/prune`, {}), (result) => `已清理 ${result.removed.length} 个孤儿键`),
1248
+ [run],
1249
+ )
1250
+
1251
+ return h(GateView, {
1252
+ model,
1253
+ busy,
1254
+ picking,
1255
+ error,
1256
+ notice,
1257
+ pending,
1258
+ showUnlisted,
1259
+ pathInput,
1260
+ menuFor,
1261
+ groupDraft,
1262
+ newGroupOpen,
1263
+ newGroupDraft,
1264
+ onRefresh: load,
1265
+ onPickLevel: pickLevel,
1266
+ onPickGroupLevel: pickGroupLevel,
1267
+ onPickFolder: pickFolder,
1268
+ onPathInput: setPathInput,
1269
+ onAskAddPath: () => {
1270
+ const value = (pathInput || '').trim()
1271
+ if (value) setPending({ action: 'add', name: value })
1272
+ },
1273
+ onAskAdd: (name) => setPending({ action: 'add', name }),
1274
+ onAskRemove: (name) => setPending({ action: 'remove', name }),
1275
+ onConfirmPending: confirmPending,
1276
+ onCancelPending: () => setPending(null),
1277
+ onToggleUnlisted: () => setShowUnlisted((value) => !value),
1278
+ onToggleMenu: (key) => {
1279
+ setMenuFor((current) => (current === key ? null : key))
1280
+ setGroupDraft({ select: '', text: '' })
1281
+ },
1282
+ onGroupSelect: (value) => setGroupDraft((draft) => ({ ...draft, select: value, text: '' })),
1283
+ onGroupText: (value) => setGroupDraft((draft) => ({ ...draft, text: value, select: '' })),
1284
+ onApplyGroup: applyGroup,
1285
+ onDeleteGroup: deleteGroup,
1286
+ onDropToGroup: dropToGroup,
1287
+ onToggleNewGroup: () => setNewGroupOpen((open) => !open),
1288
+ onNewGroupName: (value) => setNewGroupDraft((draft) => ({ ...draft, name: value })),
1289
+ onNewGroupNote: (value) => setNewGroupDraft((draft) => ({ ...draft, note: value })),
1290
+ onCreateGroup: createGroup,
1291
+ onPrune: prune,
1292
+ })
1293
+ }
1294
+
1295
+ function apply(ctx) {
1296
+ try {
1297
+ const disposeRow = ctx.slots.inject('sidebar.panellist', () =>
1298
+ ctx.slots.register(
1299
+ {
1300
+ name: 'sidebar.panellist',
1301
+ id: PANEL_ID,
1302
+ order: PANEL_ORDER,
1303
+ label: '门禁',
1304
+ },
1305
+ GateIcon,
1306
+ ),
1307
+ )
1308
+ const disposePage = ctx.slots.inject('main', () =>
1309
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, GatePage),
1310
+ )
1311
+ ctx.effect(() => () => {
1312
+ disposePage()
1313
+ disposeRow()
1314
+ })
1315
+ console.log(
1316
+ `[dsh-isolation-gate] client: 「门禁」行 + 整页已挂到 slots(order ${PANEL_ORDER},紧贴「技能」5 下方)`,
1317
+ )
1318
+ } catch (error) {
1319
+ console.error('[dsh-isolation-gate] client: 槽位注册失败', error)
1320
+ }
1321
+ }
1322
+
1323
+ return {
1324
+ apply,
1325
+ inject: ['slots'],
1326
+ __selftest: { GateView, GateIcon, Segmented, ObjectRow, GroupCard, PANEL_ID, PANEL_ORDER },
1327
+ }
1328
+ },
1329
+ })