dsh-isolation-gate 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,1329 +1,1525 @@
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
- })
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
+ // ==================== 页 2:策略档 ====================
1090
+ function ProfilesPage() {
1091
+ const [data, setData] = useState(null)
1092
+ const [error, setError] = useState(null)
1093
+ const [busy, setBusy] = useState(false)
1094
+
1095
+ const load = useCallback(async () => {
1096
+ setBusy(true)
1097
+ setError(null)
1098
+ try {
1099
+ setData(await call(`${BASE}/profiles`))
1100
+ } catch (err) {
1101
+ setError(err.message)
1102
+ } finally {
1103
+ setBusy(false)
1104
+ }
1105
+ }, [])
1106
+
1107
+ useEffect(() => {
1108
+ load()
1109
+ }, [load])
1110
+
1111
+ const profiles = (data && data.profiles) || []
1112
+ const dirBindings = (data && data.dirBindings) || []
1113
+
1114
+ return h(
1115
+ 'div',
1116
+ {
1117
+ 'data-dsh-plugin': PANEL_ID,
1118
+ style: { padding: '18px 22px 32px', height: '100%', overflow: 'auto', boxSizing: 'border-box', background: C.card2, color: C.text },
1119
+ },
1120
+ h(
1121
+ 'div',
1122
+ { style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' } },
1123
+ h('div', { style: { fontSize: '20px', fontWeight: 750 } }, '门禁 · 策略档'),
1124
+ h('div', { style: { fontSize: '12px', color: C.dim } }, '会话挂哪一档 · 目录 .dsh-gate.json · 只读'),
1125
+ h('span', { style: { flex: '1 1 auto' } }),
1126
+ h(Button, { label: busy ? '刷新中…' : '刷新', onClick: load, disabled: busy }),
1127
+ ),
1128
+ error ? h('div', { style: { color: C.bad, fontSize: '12px', marginTop: '10px' } }, `✗ ${error}`) : null,
1129
+ !data && !error ? h(Line, null, '读取中…') : null,
1130
+ profiles.length
1131
+ ? h(
1132
+ 'div',
1133
+ { style: { display: 'grid', gap: '10px', marginTop: '14px', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' } },
1134
+ profiles.map((profile) =>
1135
+ h(
1136
+ 'div',
1137
+ { key: profile.name, style: { border: `1px solid ${C.border2}`, borderRadius: '12px', padding: '12px 14px', background: C.card } },
1138
+ h(
1139
+ 'div',
1140
+ { style: { display: 'flex', alignItems: 'baseline', gap: '8px' } },
1141
+ h('div', { style: { fontSize: '15px', fontWeight: 700 } }, profile.name),
1142
+ profile.isDefault
1143
+ ? h('span', { style: { fontSize: '11px', color: C.ok, border: `1px solid ${C.ok}`, borderRadius: '999px', padding: '0 6px' } }, '默认')
1144
+ : null,
1145
+ h('span', { style: { flex: '1 1 auto' } }),
1146
+ h('span', { style: { fontSize: '11px', color: C.dim } }, profile.mode),
1147
+ ),
1148
+ h(Line, null, `联网:${profile.network === 'deny' ? '禁' : '允许'} · 未知工具:${profile.unknownTool === 'deny' ? '拒' : '放行'}`),
1149
+ profile.allow.length
1150
+ ? profile.allow.map((entry) =>
1151
+ h('div', { key: entry.path, style: { fontSize: '12px', color: C.dim, marginTop: '4px', wordBreak: 'break-all' } }, `${entry.level} · ${entry.path}`),
1152
+ )
1153
+ : h('div', { style: { fontSize: '12px', color: C.warn, marginTop: '4px' } }, 'allow 为空 ⇒ 有路径的工具调用一律拒'),
1154
+ ),
1155
+ ),
1156
+ )
1157
+ : null,
1158
+ h(SectionTitle, { title: '目录绑定', hint: '在目录里放 .dsh-gate.json,只影响这棵子树;只能加严,不能解锁别处' }),
1159
+ dirBindings.length
1160
+ ? dirBindings.map((item) =>
1161
+ h(
1162
+ 'div',
1163
+ { key: item.dir, style: { border: `1px solid ${C.border2}`, borderRadius: '10px', padding: '8px 10px', marginTop: '6px', background: C.card } },
1164
+ h('div', { style: { fontSize: '13px' } }, item.profile || '(未指定档)'),
1165
+ h('div', { style: { fontSize: '11px', color: C.dim, wordBreak: 'break-all' } }, item.dir),
1166
+ ),
1167
+ )
1168
+ : h(Line, null, '未发现任何 .dsh-gate.json(在目标目录建一个即生效)'),
1169
+ h(Line, null, '示例:{"profile":"strict","allow":[{"path":"D:\\\\某项目","level":"read"}]}'),
1170
+ )
1171
+ }
1172
+
1173
+ // ==================== 页 3:审计与诊断 ====================
1174
+ function AuditPage() {
1175
+ const [model, setModel] = useState(null)
1176
+ const [error, setError] = useState(null)
1177
+ const [busy, setBusy] = useState(false)
1178
+
1179
+ const load = useCallback(async () => {
1180
+ setBusy(true)
1181
+ setError(null)
1182
+ try {
1183
+ setModel(await call(`${BASE}/state`))
1184
+ } catch (err) {
1185
+ setError(err.message)
1186
+ } finally {
1187
+ setBusy(false)
1188
+ }
1189
+ }, [])
1190
+
1191
+ useEffect(() => {
1192
+ load()
1193
+ }, [load])
1194
+
1195
+ const diffs = (model && model.diffs) || {}
1196
+ const audit = (model && model.audit) || { entries: [], total: 0 }
1197
+ const rows = [
1198
+ ['名单有、磁盘没有', (diffs.rosterMissingDir || []).length],
1199
+ ['磁盘有、名单没列', (diffs.dirNotListedSuspect || []).length],
1200
+ ['大小写不一致', (diffs.caseMismatch || []).length],
1201
+ ['路径不存在', (diffs.pathMissing || []).length],
1202
+ ['不是目录', (diffs.pathNotDir || []).length],
1203
+ ]
1204
+
1205
+ return h(
1206
+ 'div',
1207
+ {
1208
+ 'data-dsh-plugin': PANEL_ID,
1209
+ style: { padding: '18px 22px 32px', height: '100%', overflow: 'auto', boxSizing: 'border-box', background: C.card2, color: C.text },
1210
+ },
1211
+ h(
1212
+ 'div',
1213
+ { style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' } },
1214
+ h('div', { style: { fontSize: '20px', fontWeight: 750 } }, '门禁 · 审计与诊断'),
1215
+ h('div', { style: { fontSize: '12px', color: C.dim } }, '判定账本 · 磁盘差异 · 只读'),
1216
+ h('span', { style: { flex: '1 1 auto' } }),
1217
+ h(Button, { label: busy ? '刷新中…' : '刷新', onClick: load, disabled: busy }),
1218
+ h(Button, {
1219
+ label: '清空审计',
1220
+ background: C.bad,
1221
+ disabled: busy || !(model && model.audit && model.audit.total > 0),
1222
+ onClick: async () => {
1223
+ if (!window.confirm('清空审计日志?清空前会整份备份到 D:\\回收站\\isolation-gate-audit。')) return
1224
+ setBusy(true)
1225
+ try {
1226
+ await post(BASE + '/audit/clear', {})
1227
+ await load()
1228
+ } catch (err) {
1229
+ const msg = String(err && err.message ? err.message : err)
1230
+ setError(
1231
+ msg.includes('405')
1232
+ ? '宿主半边还是旧版(0.2.0,没有 /audit/clear 路由)—— 重启 dsh 后此按钮才可用'
1233
+ : msg,
1234
+ )
1235
+ } finally {
1236
+ setBusy(false)
1237
+ }
1238
+ },
1239
+ }),
1240
+ ),
1241
+ error ? h('div', { style: { color: C.bad, fontSize: '12px', marginTop: '10px' } }, `✗ ${error}`) : null,
1242
+ h(SectionTitle, { title: '磁盘差异', hint: '名单与实物对不对得上' }),
1243
+ h(
1244
+ 'div',
1245
+ { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' } },
1246
+ rows.map(([label, value]) => h(Stat, { key: label, label, value, color: value ? C.warn : C.ok })),
1247
+ ),
1248
+ h(SectionTitle, { title: `审计(最近 ${audit.entries.length} 条 / 共 ${audit.total} 条)`, hint: '只记时间 / 对象 / 级别 / 操作 / 结果,不记内容' }),
1249
+ audit.entries.length
1250
+ ? audit.entries.map((entry, index) =>
1251
+ h(
1252
+ 'div',
1253
+ { key: index, style: { fontSize: '12px', color: C.dim, marginTop: '4px', fontFamily: 'ui-monospace, monospace' } },
1254
+ `${entry.at || ''} ${entry.allow === false ? '拒' : '放'} ${entry.op || ''} ${entry.matchedKey || entry.name || entry.path || ''} ${entry.reason || ''}`,
1255
+ ),
1256
+ )
1257
+ : h(Line, null, '还没有审计条目'),
1258
+ h(Line, null, '工具门干跑:node bin/gate.mjs toolcheck --tool read --path "D:\\\\某目录"'),
1259
+ )
1260
+ }
1261
+
1262
+
1263
+ // ==================== 外壳:单页 + 标签(照「技能」面板的样子) ====================
1264
+ function GateShell() {
1265
+ const [tab, setTab] = useState('list')
1266
+ const tabs = [
1267
+ ['list', '对象与分组'],
1268
+ ['profiles', '策略档'],
1269
+ ['audit', '审计与诊断'],
1270
+ ]
1271
+ return h(
1272
+ 'div',
1273
+ { style: { height: '100%', display: 'flex', flexDirection: 'column', background: C.card2, color: C.text } },
1274
+ h(
1275
+ 'div',
1276
+ { style: { padding: '12px 22px 10px', borderBottom: `1px solid ${C.border2}`, display: 'flex', gap: '6px', flexWrap: 'wrap' } },
1277
+ tabs.map(([id, label]) =>
1278
+ h(Button, { key: id, label, primary: tab === id, onClick: () => setTab(id) }),
1279
+ ),
1280
+ ),
1281
+ h(
1282
+ 'div',
1283
+ { style: { flex: '1 1 auto', minHeight: 0 } },
1284
+ tab === 'profiles' ? h(ProfilesPage) : tab === 'audit' ? h(AuditPage) : h(GatePage),
1285
+ ),
1286
+ )
1287
+ }
1288
+
1289
+
1290
+ function GatePage() {
1291
+ const [model, setModel] = useState(null)
1292
+ const [busy, setBusy] = useState(false)
1293
+ const [picking, setPicking] = useState(false)
1294
+ const [error, setError] = useState(null)
1295
+ const [notice, setNotice] = useState(null)
1296
+ const [showUnlisted, setShowUnlisted] = useState(false)
1297
+ const [pending, setPending] = useState(null)
1298
+ const [pathInput, setPathInput] = useState('')
1299
+ const [menuFor, setMenuFor] = useState(null)
1300
+ const [groupDraft, setGroupDraft] = useState({ select: '', text: '' })
1301
+ const [newGroupOpen, setNewGroupOpen] = useState(false)
1302
+ const [newGroupDraft, setNewGroupDraft] = useState({ name: '', note: '' })
1303
+
1304
+ const load = useCallback(async () => {
1305
+ setBusy(true)
1306
+ setError(null)
1307
+ try {
1308
+ setModel(await call(`${BASE}/state`))
1309
+ } catch (err) {
1310
+ setError(err.message)
1311
+ } finally {
1312
+ setBusy(false)
1313
+ }
1314
+ }, [])
1315
+
1316
+ useEffect(() => {
1317
+ load()
1318
+ }, [load])
1319
+
1320
+ const run = useCallback(async (action, successText) => {
1321
+ setBusy(true)
1322
+ setError(null)
1323
+ setNotice(null)
1324
+ try {
1325
+ const result = await action()
1326
+ if (result && result.report) setModel(result.report)
1327
+ else await load()
1328
+ if (successText) setNotice(typeof successText === 'function' ? successText(result) : successText)
1329
+ return result
1330
+ } catch (err) {
1331
+ setError(err.message)
1332
+ return null
1333
+ } finally {
1334
+ setBusy(false)
1335
+ }
1336
+ }, [load])
1337
+
1338
+ const pickLevel = useCallback(
1339
+ (name, level) =>
1340
+ run(
1341
+ () => post(`${BASE}/level`, { name, level }),
1342
+ (result) => `${result.name} → ${result.levelLabel}(${result.level})· 已落盘,重启后保持`,
1343
+ ),
1344
+ [run],
1345
+ )
1346
+
1347
+ const pickGroupLevel = useCallback(
1348
+ (group, level) =>
1349
+ run(
1350
+ () => post(`${BASE}/group-level`, { group, level }),
1351
+ (result) => `组「${result.group}」${result.count} 个对象 → ${result.levelLabel}(${result.level})`,
1352
+ ),
1353
+ [run],
1354
+ )
1355
+
1356
+ const pickFolder = useCallback(async () => {
1357
+ setPicking(true)
1358
+ setError(null)
1359
+ setNotice(null)
1360
+ try {
1361
+ const result = await post(`${BASE}/pick`, {})
1362
+ if (result && result.ok && result.path) {
1363
+ setPathInput(result.path)
1364
+ setNotice(`已选择:${result.path} —— 点「加入名单」确认`)
1365
+ }
1366
+ } catch (err) {
1367
+ setError(`${err.message}(可改用下方"未入名单的一层目录"或手动输入)`)
1368
+ } finally {
1369
+ setPicking(false)
1370
+ }
1371
+ }, [])
1372
+
1373
+ const applyGroup = useCallback(
1374
+ (name) =>
1375
+ run(
1376
+ () => {
1377
+ const group = (groupDraft.text || '').trim() || groupDraft.select || ''
1378
+ return post(`${BASE}/group`, { action: 'assign', name, group })
1379
+ },
1380
+ (result) => (result.group ? `「${result.name}」已归入组「${result.group}」` : `「${result.name}」已取消分组`),
1381
+ ).then(() => {
1382
+ setMenuFor(null)
1383
+ setGroupDraft({ select: '', text: '' })
1384
+ }),
1385
+ [run, groupDraft],
1386
+ )
1387
+
1388
+ const createGroup = useCallback(
1389
+ () =>
1390
+ run(
1391
+ async () => {
1392
+ try {
1393
+ return await post(`${BASE}/group`, {
1394
+ action: 'create',
1395
+ group: (newGroupDraft.name || '').trim(),
1396
+ note: (newGroupDraft.note || '').trim(),
1397
+ })
1398
+ } catch (err) {
1399
+ const text = String(err && err.message ? err.message : err)
1400
+ // 宿主半还停在旧代码(旧路由只认 assign / delete)⇒ 说人话,别让人猜
1401
+ if (text.includes('assign / delete') || text.includes('assign / delete,收到')) {
1402
+ throw new Error('宿主半还是旧版:请重启一次 dsh,再点「创建」')
1403
+ }
1404
+ throw err
1405
+ }
1406
+ },
1407
+ (result) => `已新建分组「${result.group}」(空组 · 0 个成员)`,
1408
+ ).then((result) => {
1409
+ if (result) {
1410
+ setNewGroupOpen(false)
1411
+ setNewGroupDraft({ name: '', note: '' })
1412
+ }
1413
+ }),
1414
+ [run, newGroupDraft],
1415
+ )
1416
+
1417
+ const dropToGroup = useCallback(
1418
+ (name, group) =>
1419
+ run(
1420
+ () => post(`${BASE}/group`, { action: 'assign', name, group }),
1421
+ (result) => (result.group ? `「${result.name}」已拖入组「${result.group}」` : `「${result.name}」已拖出分组`),
1422
+ ),
1423
+ [run],
1424
+ )
1425
+
1426
+ const deleteGroup = useCallback(
1427
+ (group) =>
1428
+ run(
1429
+ () => post(`${BASE}/group`, { action: 'delete', group }),
1430
+ (result) => `组「${result.group}」已删除(${result.released.length} 个成员保留)`,
1431
+ ).then(() => setMenuFor(null)),
1432
+ [run],
1433
+ )
1434
+
1435
+ const confirmPending = useCallback(async () => {
1436
+ if (!pending) return
1437
+ await run(
1438
+ () => post(`${BASE}/roster`, { action: pending.action, name: pending.name }),
1439
+ (result) =>
1440
+ `名单${result.action === 'add' ? '加入' : '移除'}「${result.name}」已落盘` +
1441
+ `(备份:${result.backup || '原文件不存在,无需备份'})` +
1442
+ (result.stateNote ? ` · ${result.stateNote}` : ''),
1443
+ )
1444
+ setPending(null)
1445
+ }, [pending, run])
1446
+
1447
+ const prune = useCallback(
1448
+ () => run(() => post(`${BASE}/prune`, {}), (result) => `已清理 ${result.removed.length} 个孤儿键`),
1449
+ [run],
1450
+ )
1451
+
1452
+ return h(GateView, {
1453
+ model,
1454
+ busy,
1455
+ picking,
1456
+ error,
1457
+ notice,
1458
+ pending,
1459
+ showUnlisted,
1460
+ pathInput,
1461
+ menuFor,
1462
+ groupDraft,
1463
+ newGroupOpen,
1464
+ newGroupDraft,
1465
+ onRefresh: load,
1466
+ onPickLevel: pickLevel,
1467
+ onPickGroupLevel: pickGroupLevel,
1468
+ onPickFolder: pickFolder,
1469
+ onPathInput: setPathInput,
1470
+ onAskAddPath: () => {
1471
+ const value = (pathInput || '').trim()
1472
+ if (value) setPending({ action: 'add', name: value })
1473
+ },
1474
+ onAskAdd: (name) => setPending({ action: 'add', name }),
1475
+ onAskRemove: (name) => setPending({ action: 'remove', name }),
1476
+ onConfirmPending: confirmPending,
1477
+ onCancelPending: () => setPending(null),
1478
+ onToggleUnlisted: () => setShowUnlisted((value) => !value),
1479
+ onToggleMenu: (key) => {
1480
+ setMenuFor((current) => (current === key ? null : key))
1481
+ setGroupDraft({ select: '', text: '' })
1482
+ },
1483
+ onGroupSelect: (value) => setGroupDraft((draft) => ({ ...draft, select: value, text: '' })),
1484
+ onGroupText: (value) => setGroupDraft((draft) => ({ ...draft, text: value, select: '' })),
1485
+ onApplyGroup: applyGroup,
1486
+ onDeleteGroup: deleteGroup,
1487
+ onDropToGroup: dropToGroup,
1488
+ onToggleNewGroup: () => setNewGroupOpen((open) => !open),
1489
+ onNewGroupName: (value) => setNewGroupDraft((draft) => ({ ...draft, name: value })),
1490
+ onNewGroupNote: (value) => setNewGroupDraft((draft) => ({ ...draft, note: value })),
1491
+ onCreateGroup: createGroup,
1492
+ onPrune: prune,
1493
+ })
1494
+ }
1495
+
1496
+ function apply(ctx) {
1497
+ try {
1498
+ const disposeRow = ctx.slots.inject('sidebar.panellist', () =>
1499
+ ctx.slots.register(
1500
+ { name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: '门禁' },
1501
+ GateIcon,
1502
+ ),
1503
+ )
1504
+ const disposePage = ctx.slots.inject('main', () =>
1505
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, GateShell),
1506
+ )
1507
+ ctx.effect(() => () => {
1508
+ disposePage()
1509
+ disposeRow()
1510
+ })
1511
+ console.log(
1512
+ `[dsh-isolation-gate] client: 「门禁」行 + 整页已挂到 slots(order ${PANEL_ORDER},紧贴「技能」5 下方)`,
1513
+ )
1514
+ } catch (error) {
1515
+ console.error('[dsh-isolation-gate] client: 槽位注册失败', error)
1516
+ }
1517
+ }
1518
+
1519
+ return {
1520
+ apply,
1521
+ inject: ['slots'],
1522
+ __selftest: { GateShell, GateView, ProfilesPage, AuditPage, GateIcon, Segmented, ObjectRow, GroupCard, PANEL_ID, PANEL_ORDER },
1523
+ }
1524
+ },
1525
+ })