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/CHANGELOG.md +27 -0
- package/lib/audit.js +37 -1
- package/lib/client.js +1525 -1329
- package/lib/index.js +46 -1
- package/lib/profiles-runtime.js +164 -0
- package/lib/profiles.js +320 -0
- package/package.json +8 -3
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
|
-
|
|
1090
|
-
|
|
1091
|
-
const [
|
|
1092
|
-
const [
|
|
1093
|
-
const [
|
|
1094
|
-
|
|
1095
|
-
const
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
})
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
)
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
)
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
+
})
|