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