finch-remote-host 0.0.0-stage → 1.0.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/ui/panel.html ADDED
@@ -0,0 +1,1361 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>远程主机</title>
7
+ <style>
8
+ /* 主题色优先用 Finch 注入的 --finch-* 变量,浏览器直开时退回本地兜底值。 */
9
+ :root {
10
+ /* 面板与聊天区同底色。Finch 只向页面注入 bg-root/main,侧边栏色是主窗口内部的 --bg-sidebar,取不到。 */
11
+ --bg-fallback: #fff;
12
+ --bg: var(--finch-bg-root, var(--bg-fallback));
13
+ --bg-soft: var(--finch-bg-elevated, #f7f8fa);
14
+ --bg-hover: var(--finch-bg-hover, #eef0f4);
15
+ --bg-active: var(--finch-bg-active, #e4e7ec);
16
+ --text: var(--finch-text-primary, #1b1f24);
17
+ --muted: var(--finch-text-secondary, #5c6672);
18
+ --faint: var(--finch-text-tertiary, #8b95a3);
19
+ --border: var(--finch-border, #e3e6ea);
20
+ --border-subtle: var(--finch-border-subtle, #ededf0);
21
+ --accent: var(--finch-accent, #2563eb);
22
+ --danger: var(--finch-danger, #d92d20);
23
+ --positive: var(--finch-positive, #12924a);
24
+ --warning: var(--finch-warning, #b7791f);
25
+ --radius: var(--finch-radius-md, 10px);
26
+ --mono: var(--finch-font-mono, ui-monospace, Consolas, monospace);
27
+ /* 面板里所有纵向间隙只用一个值,免得一块紧一块松。 */
28
+ --gap: 12px;
29
+ /* 按钮、输入框、下拉框统一这个高度,跟顶栏的「新增主机」对齐。 */
30
+ --control-h: 26px;
31
+ }
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ --bg-fallback: #14171c;
35
+ --bg-soft: #1b1f26;
36
+ --bg-hover: #232830;
37
+ --bg-active: #2b313a;
38
+ --text: #e6e9ee;
39
+ --muted: #a3acb9;
40
+ --faint: #78828f;
41
+ --border: #2a3038;
42
+ --border-subtle: #23272e;
43
+ }
44
+ }
45
+ * {
46
+ box-sizing: border-box;
47
+ }
48
+ body {
49
+ margin: 0;
50
+ height: 100vh;
51
+ display: flex;
52
+ flex-direction: column;
53
+ background: var(--bg);
54
+ color: var(--text);
55
+ font: var(--finch-message-font-size, 13px) / 1.45 var(--finch-font-body, system-ui, "Microsoft YaHei", sans-serif);
56
+ -webkit-font-smoothing: antialiased;
57
+ }
58
+ .bar {
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: space-between;
62
+ gap: var(--gap);
63
+ padding: var(--gap) 12px;
64
+ border-bottom: 1px solid var(--border);
65
+ background: var(--bg);
66
+ }
67
+ .bar-title {
68
+ font-weight: 600;
69
+ overflow: hidden;
70
+ text-overflow: ellipsis;
71
+ white-space: nowrap;
72
+ }
73
+ .bar-actions {
74
+ display: flex;
75
+ flex: 0 0 auto;
76
+ gap: var(--gap);
77
+ }
78
+ .bar-actions button {
79
+ padding: 0 10px;
80
+ font-size: 12px;
81
+ border-radius: 6px;
82
+ }
83
+ .bar-actions .icon-btn {
84
+ display: flex;
85
+ align-items: center;
86
+ padding: 0 6px;
87
+ border: 0;
88
+ background: none;
89
+ color: var(--muted);
90
+ }
91
+ .bar-actions .icon-btn:hover {
92
+ background: none;
93
+ color: var(--text);
94
+ }
95
+ button {
96
+ height: var(--control-h);
97
+ padding: 0 10px;
98
+ font: inherit;
99
+ /* 字号跟「新增主机」对齐;font 简写会把字号重置,所以写在它后面。 */
100
+ font-size: 12px;
101
+ border-radius: 8px;
102
+ border: 1px solid var(--border);
103
+ /* 次要按钮用 elevated 底色,否则跟弹框背景粘成一片。 */
104
+ background: var(--bg-soft);
105
+ color: var(--text);
106
+ cursor: pointer;
107
+ /* 窄栏下按钮绝不能自己折成两行,宁可整组换行。 */
108
+ white-space: nowrap;
109
+ }
110
+ button:hover {
111
+ background: var(--bg-hover);
112
+ }
113
+ button.primary {
114
+ background: var(--accent);
115
+ border-color: var(--accent);
116
+ color: #fff;
117
+ }
118
+ button.primary:hover {
119
+ filter: brightness(1.06);
120
+ }
121
+ button.danger {
122
+ color: var(--danger);
123
+ border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
124
+ }
125
+ button.ghost {
126
+ background: transparent;
127
+ }
128
+ .list {
129
+ flex: 1;
130
+ min-height: 0;
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: var(--gap);
134
+ overflow: auto;
135
+ padding: var(--gap);
136
+ }
137
+ .host {
138
+ overflow: hidden;
139
+ border: 1px solid var(--border-subtle);
140
+ border-radius: var(--radius);
141
+ background: var(--bg);
142
+ }
143
+ .host[aria-expanded="true"] {
144
+ background: var(--bg-soft);
145
+ }
146
+ .host:hover:not([aria-expanded="true"]) {
147
+ background: var(--bg-hover);
148
+ }
149
+ .host-head {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: var(--gap);
153
+ /* 行矮一点才不显胖:纵向 6px,横向仍与整体保持 12px。 */
154
+ padding: 6px var(--gap);
155
+ }
156
+ /* 展开后标题行单独铺一层色块,跟下面的详情分开。 */
157
+ .host[aria-expanded="true"] .host-head {
158
+ background: var(--bg-active);
159
+ border-radius: var(--radius) var(--radius) 0 0;
160
+ }
161
+ .host-toggle {
162
+ display: flex;
163
+ flex: 1 1 auto;
164
+ align-items: center;
165
+ gap: 8px;
166
+ min-width: 0;
167
+ padding: 0;
168
+ border: 0;
169
+ background: transparent;
170
+ color: inherit;
171
+ text-align: left;
172
+ }
173
+ .host-icon {
174
+ display: flex;
175
+ flex: 0 0 auto;
176
+ align-items: center;
177
+ color: var(--faint);
178
+ }
179
+ .host-toggle:hover {
180
+ background: transparent;
181
+ }
182
+ .host-actions {
183
+ display: flex;
184
+ flex: 0 0 auto;
185
+ align-items: center;
186
+ gap: var(--gap);
187
+ }
188
+ /* 动作按钮只留文字与颜色,不画背景和边框。 */
189
+ .host-actions button {
190
+ height: var(--control-h);
191
+ padding: 0;
192
+ border: 0;
193
+ background: none;
194
+ font-size: 12px;
195
+ }
196
+ .host-actions button:hover {
197
+ background: none;
198
+ text-decoration: underline;
199
+ }
200
+ .link-accent {
201
+ color: var(--accent);
202
+ }
203
+ .link-muted {
204
+ color: var(--muted);
205
+ }
206
+ .link-danger {
207
+ color: var(--danger);
208
+ }
209
+ .host-name {
210
+ flex: 0 1 auto;
211
+ font-weight: 600;
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ }
216
+ .host-addr {
217
+ flex: 0 0 auto;
218
+ max-width: 42%;
219
+ font-family: var(--mono);
220
+ font-size: 12px;
221
+ color: var(--faint);
222
+ overflow: hidden;
223
+ text-overflow: ellipsis;
224
+ white-space: nowrap;
225
+ }
226
+ .chevron {
227
+ display: flex;
228
+ flex: 0 0 auto;
229
+ align-items: center;
230
+ color: var(--faint);
231
+ transition: transform 0.12s ease;
232
+ }
233
+ .host[aria-expanded="true"] .chevron {
234
+ transform: rotate(90deg);
235
+ }
236
+ .host-body {
237
+ padding: var(--gap);
238
+ }
239
+ .meta {
240
+ display: grid;
241
+ grid-template-columns: auto 1fr;
242
+ gap: 6px;
243
+ margin: 0 0 var(--gap);
244
+ font-size: 12px;
245
+ }
246
+ .meta dt {
247
+ color: var(--faint);
248
+ white-space: nowrap;
249
+ }
250
+ .meta dd {
251
+ margin: 0;
252
+ font-family: var(--mono);
253
+ text-align: right;
254
+ overflow-wrap: anywhere;
255
+ }
256
+ .probe {
257
+ margin: var(--gap) 0 0;
258
+ padding: 8px;
259
+ background: var(--bg);
260
+ border: 1px solid var(--border);
261
+ border-radius: var(--radius);
262
+ font-family: var(--mono);
263
+ font-size: 12px;
264
+ white-space: pre-wrap;
265
+ overflow-wrap: anywhere;
266
+ max-height: 200px;
267
+ overflow: auto;
268
+ }
269
+ /* 操作记录的头与内容都缩在卡片内边距里,色块不贴边。 */
270
+ .audit {
271
+ margin-top: var(--gap);
272
+ }
273
+ .audit-title {
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: space-between;
277
+ gap: var(--gap);
278
+ padding: 0;
279
+ font-size: 12px;
280
+ font-weight: 600;
281
+ color: var(--text);
282
+ }
283
+ .audit-clear {
284
+ height: auto;
285
+ padding: 0;
286
+ border: 0;
287
+ background: none;
288
+ font-size: 11px;
289
+ color: var(--danger);
290
+ }
291
+ .audit-clear:hover {
292
+ background: none;
293
+ text-decoration: underline;
294
+ }
295
+ .audit-list {
296
+ display: grid;
297
+ /* 记录之间(带输出框时更明显)靠更大的间距分界,记录内部保持紧凑。 */
298
+ gap: 16px;
299
+ max-height: 220px;
300
+ padding-top: var(--gap);
301
+ overflow-x: hidden;
302
+ overflow-y: auto;
303
+ font-size: 12px;
304
+ color: var(--faint);
305
+ }
306
+ .audit-row {
307
+ min-width: 0;
308
+ font-size: 12px;
309
+ color: var(--text);
310
+ }
311
+ .audit-head {
312
+ display: flex;
313
+ flex-wrap: wrap;
314
+ gap: 4px var(--gap);
315
+ justify-content: space-between;
316
+ color: var(--muted);
317
+ }
318
+ .audit-when {
319
+ /* 用 flex 居中,圆点跟文字才真正对齐;靠 vertical-align 总会偏下。 */
320
+ display: flex;
321
+ flex: 1 1 auto;
322
+ align-items: center;
323
+ gap: 8px;
324
+ min-width: 0;
325
+ font-family: var(--mono);
326
+ }
327
+ .audit-when::before {
328
+ content: "";
329
+ flex: 0 0 auto;
330
+ width: 7px;
331
+ height: 7px;
332
+ border-radius: 50%;
333
+ background: var(--accent);
334
+ }
335
+ .audit-result {
336
+ flex: 0 0 auto;
337
+ text-align: right;
338
+ }
339
+ .audit-cmd {
340
+ display: block;
341
+ width: 100%;
342
+ /* 命令可能很长,按钮不能沿袭统一控件高度,否则文字会溢出到下一条记录上。 */
343
+ height: auto;
344
+ /* 命令跟自己的时间算一组,间距比记录之间小一半,分组才看得出来。 */
345
+ margin-top: 6px;
346
+ padding: 1px 0;
347
+ border: 0;
348
+ border-radius: 4px;
349
+ background: transparent;
350
+ color: var(--text);
351
+ font-family: var(--mono);
352
+ text-align: left;
353
+ /* 长命令必须换行,否则撑出横向滚动条。 */
354
+ white-space: pre-wrap;
355
+ overflow-wrap: anywhere;
356
+ }
357
+ .audit-cmd:hover {
358
+ background: transparent;
359
+ color: var(--accent);
360
+ }
361
+ .audit-why {
362
+ margin-top: 6px;
363
+ color: var(--faint);
364
+ font-size: 11px;
365
+ line-height: 1.5;
366
+ overflow-wrap: anywhere;
367
+ }
368
+ .audit-out {
369
+ margin: var(--gap) 0 0;
370
+ padding: 8px;
371
+ background: var(--bg);
372
+ border: 1px solid var(--border);
373
+ border-radius: 6px;
374
+ font-family: var(--mono);
375
+ font-size: 12px;
376
+ white-space: pre-wrap;
377
+ overflow-wrap: anywhere;
378
+ max-height: 240px;
379
+ overflow: auto;
380
+ }
381
+ .empty {
382
+ padding: 24px var(--gap);
383
+ color: var(--faint);
384
+ text-align: center;
385
+ }
386
+ .mask {
387
+ position: fixed;
388
+ inset: 0;
389
+ display: flex;
390
+ /* 顶部对齐而不是居中:内容长短不一时位置才不会跳。 */
391
+ align-items: flex-start;
392
+ justify-content: center;
393
+ /* 62px = 顶栏(26px 控件 + 上下 12px)+ 列表上内边距 12px,正好落在第一张主机卡片的位置。 */
394
+ padding: 62px var(--gap) var(--gap);
395
+ background: rgba(0, 0, 0, 0.55);
396
+ backdrop-filter: blur(3px);
397
+ }
398
+ /* 确认框较短,居中更像个提问;层级高于表单弹框。 */
399
+ #confirm-mask {
400
+ z-index: 10;
401
+ align-items: center;
402
+ padding: var(--gap);
403
+ }
404
+ .dialog {
405
+ /* 顶栏与底部按钮固定,中间字段区自己滚,弹框最多占 80% 视口高度。 */
406
+ display: flex;
407
+ flex-direction: column;
408
+ width: min(560px, 100%);
409
+ /* 常态封顶 80vh;窗口很矮时听遮罩层的剩余高度,别把底部按钮顶出去。 */
410
+ max-height: min(80vh, 100%);
411
+ overflow: hidden;
412
+ background: var(--bg);
413
+ border: 1px solid var(--border);
414
+ border-radius: var(--radius);
415
+ padding: 16px;
416
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
417
+ }
418
+ .dialog-head,
419
+ .dialog-actions,
420
+ .dialog > .error {
421
+ flex: 0 0 auto;
422
+ }
423
+ /* 只有字段与规则这一块滚,滚动条不会把标题和按钮带走。 */
424
+ .dialog > .grid {
425
+ flex: 1 1 auto;
426
+ min-height: 0;
427
+ overflow-y: auto;
428
+ }
429
+ .dialog.confirm {
430
+ width: min(360px, 100%);
431
+ }
432
+ .confirm-text {
433
+ margin: 0;
434
+ font-size: 12px;
435
+ line-height: 1.6;
436
+ color: var(--muted);
437
+ }
438
+ .dialog-head {
439
+ display: flex;
440
+ align-items: flex-start;
441
+ justify-content: space-between;
442
+ gap: var(--gap);
443
+ margin-bottom: 12px;
444
+ }
445
+ .dialog .dialog-head h2 {
446
+ margin: 0;
447
+ }
448
+ .dialog-close {
449
+ display: flex;
450
+ height: auto;
451
+ padding: 0;
452
+ border: 0;
453
+ background: none;
454
+ color: var(--faint);
455
+ }
456
+ .dialog-close:hover {
457
+ background: none;
458
+ color: var(--text);
459
+ }
460
+ .dialog h2 {
461
+ margin: 0 0 12px;
462
+ font-size: 15px;
463
+ }
464
+ /* 表单按行分组:一行放几个字段由这一行自己决定,比整体两列好对齐。 */
465
+ .grid {
466
+ display: grid;
467
+ gap: 10px;
468
+ }
469
+ .row {
470
+ display: grid;
471
+ gap: 10px;
472
+ }
473
+ .row.two {
474
+ grid-template-columns: 1fr 1fr;
475
+ }
476
+ /* 用户 / 地址 / 端口:端口只要几个数字,给固定窄列。 */
477
+ .row.three {
478
+ grid-template-columns: 1fr 1.6fr 84px;
479
+ }
480
+ /* 面板拉到很窄时三列会挤成一撮,直接落成单列。 */
481
+ @media (max-width: 420px) {
482
+ .row.two,
483
+ .row.three {
484
+ grid-template-columns: 1fr;
485
+ }
486
+ }
487
+ label {
488
+ display: block;
489
+ font-size: 12px;
490
+ color: var(--muted);
491
+ margin-bottom: 3px;
492
+ }
493
+ /* 标签行:左边字段名,右边一句话提示,跟字段名同高对齐。 */
494
+ .label-row {
495
+ display: flex;
496
+ align-items: baseline;
497
+ gap: 8px;
498
+ margin-bottom: 3px;
499
+ }
500
+ .label-row label {
501
+ flex: 0 0 auto;
502
+ margin-bottom: 0;
503
+ }
504
+ /* 提示靠右;栏宽不够时截断,完整文字在悬停提示里。 */
505
+ .aside {
506
+ flex: 1 1 auto;
507
+ min-width: 0;
508
+ text-align: right;
509
+ color: var(--faint);
510
+ font-size: 11px;
511
+ white-space: nowrap;
512
+ overflow: hidden;
513
+ text-overflow: ellipsis;
514
+ }
515
+ input,
516
+ select,
517
+ textarea {
518
+ width: 100%;
519
+ font: inherit;
520
+ font-size: 12px;
521
+ color: var(--text);
522
+ background: var(--bg-soft);
523
+ border: 1px solid var(--border);
524
+ border-radius: 8px;
525
+ padding: 0 8px;
526
+ }
527
+ input,
528
+ select {
529
+ height: var(--control-h);
530
+ }
531
+ textarea {
532
+ min-height: 56px;
533
+ padding: 6px 8px;
534
+ resize: vertical;
535
+ font-family: var(--mono);
536
+ font-size: 12px;
537
+ }
538
+ .hint {
539
+ color: var(--faint);
540
+ font-size: 11px;
541
+ margin-top: 3px;
542
+ }
543
+ /* 内置强制规则:默认收起,展开后按类分组列出。 */
544
+ .builtin {
545
+ border-top: 1px solid var(--border-subtle);
546
+ padding-top: var(--gap);
547
+ font-size: 12px;
548
+ }
549
+ .builtin summary {
550
+ color: var(--muted);
551
+ cursor: pointer;
552
+ user-select: none;
553
+ }
554
+ .builtin summary:hover {
555
+ color: var(--text);
556
+ }
557
+ .builtin-body {
558
+ display: grid;
559
+ gap: var(--gap);
560
+ margin-top: 8px;
561
+ }
562
+ /* 弹框里的网格与列表都能在窄宽下收缩,不得撑出横向滚动条。 */
563
+ .grid > *,
564
+ .builtin,
565
+ .builtin-body,
566
+ .builtin-part {
567
+ min-width: 0;
568
+ overflow-wrap: anywhere;
569
+ }
570
+ .builtin-title {
571
+ color: var(--text);
572
+ font-size: 12px;
573
+ }
574
+ .builtin-list {
575
+ margin: 3px 0 0;
576
+ padding-left: 16px;
577
+ color: var(--muted);
578
+ line-height: 1.6;
579
+ }
580
+ .builtin-list li {
581
+ min-width: 0;
582
+ /* 路径与参数串没地方断行时也不许溢出,宁可拆开字母。 */
583
+ overflow-wrap: anywhere;
584
+ }
585
+ .builtin-detail {
586
+ color: var(--faint);
587
+ font-family: var(--mono);
588
+ font-size: 11px;
589
+ overflow-wrap: anywhere;
590
+ }
591
+ /* 试运行:输入框占满剩余宽度,按钮不跟着拉伸。 */
592
+ .try-row {
593
+ display: flex;
594
+ gap: 8px;
595
+ }
596
+ .try-row input {
597
+ flex: 1 1 auto;
598
+ min-width: 0;
599
+ }
600
+ .dialog-actions {
601
+ display: flex;
602
+ flex-wrap: wrap;
603
+ justify-content: flex-end;
604
+ gap: var(--gap);
605
+ margin-top: var(--gap);
606
+ }
607
+ .dialog-actions .left {
608
+ margin-right: auto;
609
+ }
610
+ .error {
611
+ color: var(--danger);
612
+ font-size: 12px;
613
+ margin-top: 10px;
614
+ white-space: pre-wrap;
615
+ }
616
+ [hidden] {
617
+ display: none !important;
618
+ }
619
+ /* 滚动条按 Finch 自己的观感调:细、圆角、无轨道底色、无上下箭头。 */
620
+ ::-webkit-scrollbar {
621
+ width: 10px;
622
+ height: 10px;
623
+ }
624
+ ::-webkit-scrollbar-track {
625
+ background: transparent;
626
+ }
627
+ ::-webkit-scrollbar-thumb {
628
+ /* 边框做内缩,滑块本体实际只有 6px 宽。 */
629
+ border: 2px solid transparent;
630
+ border-radius: 999px;
631
+ background: color-mix(in srgb, var(--text) 20%, transparent);
632
+ background-clip: content-box;
633
+ }
634
+ ::-webkit-scrollbar-thumb:hover {
635
+ background: color-mix(in srgb, var(--text) 32%, transparent);
636
+ background-clip: content-box;
637
+ }
638
+ ::-webkit-scrollbar-corner {
639
+ background: transparent;
640
+ }
641
+ ::-webkit-scrollbar-button {
642
+ display: none;
643
+ height: 0;
644
+ }
645
+ </style>
646
+ </head>
647
+ <body>
648
+ <header class="bar">
649
+ <div class="bar-title">远程主机</div>
650
+ <div class="bar-actions">
651
+ <button type="button" id="btn-hide" class="icon-btn" title="隐藏主机地址"></button>
652
+ <button type="button" id="btn-add" class="primary">新增主机</button>
653
+ </div>
654
+ </header>
655
+ <main class="list" id="list"></main>
656
+
657
+ <div class="mask" id="confirm-mask" hidden>
658
+ <div class="dialog confirm">
659
+ <div class="dialog-head">
660
+ <h2 id="confirm-title"></h2>
661
+ <button type="button" class="dialog-close" id="confirm-close" title="关闭">
662
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18" /></svg>
663
+ </button>
664
+ </div>
665
+ <p class="confirm-text" id="confirm-text"></p>
666
+ <div class="dialog-actions">
667
+ <button type="button" id="confirm-cancel">取消</button>
668
+ <button type="button" id="confirm-ok" class="danger">删除</button>
669
+ </div>
670
+ </div>
671
+ </div>
672
+
673
+ <div class="mask" id="mask" hidden>
674
+ <form class="dialog" id="form">
675
+ <div class="dialog-head">
676
+ <h2 id="form-title">新增主机</h2>
677
+ <button type="button" class="dialog-close" id="form-close" title="关闭">
678
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18" /></svg>
679
+ </button>
680
+ </div>
681
+ <div class="grid">
682
+ <div class="row two">
683
+ <div>
684
+ <label for="f-name">名称</label>
685
+ <input id="f-name" name="name" required />
686
+ </div>
687
+ <div>
688
+ <label for="f-auth">认证</label>
689
+ <select id="f-auth" name="auth">
690
+ <option value="key">密钥</option>
691
+ <option value="password">密码</option>
692
+ </select>
693
+ </div>
694
+ </div>
695
+ <div class="row three">
696
+ <div>
697
+ <label for="f-user">用户</label>
698
+ <input id="f-user" name="user" required value="root" />
699
+ </div>
700
+ <div>
701
+ <label for="f-host">地址</label>
702
+ <input id="f-host" name="host" required placeholder="192.168.1.10" />
703
+ </div>
704
+ <div>
705
+ <label for="f-port">端口</label>
706
+ <input id="f-port" name="port" type="number" min="1" max="65535" value="22" />
707
+ </div>
708
+ </div>
709
+ <div>
710
+ <div class="label-row">
711
+ <label for="f-mode">策略</label>
712
+ <span class="aside" id="mode-hint"></span>
713
+ </div>
714
+ <select id="f-mode" name="mode">
715
+ <option value="query">仅查询</option>
716
+ <option value="readonly">只读</option>
717
+ <option value="confirm">每次确认</option>
718
+ <option value="trusted">完全信任</option>
719
+ </select>
720
+ </div>
721
+ <div id="row-key">
722
+ <label for="f-key">私钥路径</label>
723
+ <input id="f-key" name="keyPath" placeholder="C:\Users\me\.ssh\id_ed25519" />
724
+ </div>
725
+ <div id="row-password">
726
+ <div class="label-row">
727
+ <label for="f-password">密码</label>
728
+ <span class="aside" id="password-hint"></span>
729
+ </div>
730
+ <input id="f-password" name="password" type="password" autocomplete="new-password" />
731
+ </div>
732
+ <div>
733
+ <div class="label-row">
734
+ <label for="f-allow">放行规则</label>
735
+ <span class="aside">每行一条 glob</span>
736
+ </div>
737
+ <textarea id="f-allow" name="allowGlob" placeholder="systemctl restart nginx"></textarea>
738
+ </div>
739
+ <div>
740
+ <div class="label-row">
741
+ <label for="f-deny">拒绝规则</label>
742
+ <span class="aside">每行一条 glob</span>
743
+ </div>
744
+ <textarea id="f-deny" name="denyGlob" placeholder="rm -rf /var/**"></textarea>
745
+ <div class="hint">整段匹配一命令;`*` 不跨 /,`**` 跨层级,`?` 单字符。拒绝规则优先于放行规则,两条都不命中时按策略模式处理。</div>
746
+ </div>
747
+ <div>
748
+ <label for="f-try">试运行</label>
749
+ <div class="try-row">
750
+ <input id="f-try" placeholder="粘一条命令,看它会被怎么处理" />
751
+ <button type="button" id="btn-try" class="ghost">判定</button>
752
+ </div>
753
+ <div class="hint" id="try-result" hidden></div>
754
+ </div>
755
+ <details class="builtin">
756
+ <summary>内置强制规则(任何模式下都生效,改不了)</summary>
757
+ <div class="builtin-body" id="builtin-body"></div>
758
+ </details>
759
+ </div>
760
+ <div class="error" id="form-error" hidden></div>
761
+ <div class="dialog-actions">
762
+ <button type="button" id="btn-remove-form" class="danger left" hidden>删除</button>
763
+ <button type="button" id="btn-cancel">取消</button>
764
+ <button type="button" id="btn-probe" class="ghost">测试连接</button>
765
+ <button type="submit" class="primary">保存</button>
766
+ </div>
767
+ </form>
768
+ </div>
769
+
770
+ <script>
771
+ // 面板与后端只通过这条 Bridge 通信:页面发 command,后端回 state / 结果。
772
+ const bridge = window.finch;
773
+ const $ = (id) => document.getElementById(id);
774
+ const MODE_LABEL = { query: "仅查询", readonly: "只读", confirm: "每次确认", trusted: "完全信任" };
775
+ // 每档策略的一句话说明,选到哪档就显示哪句。
776
+ const MODE_HINT = {
777
+ query: "只放行只读命令,其余全拒,凭据文件也不读",
778
+ readonly: "只读命令直接跑,写操作拒绝,读凭据要确认",
779
+ confirm: "只读直接跑,其余每条弹卡片确认",
780
+ trusted: "全部放行,只剩硬拒清单拦着"
781
+ };
782
+ const DECISION_LABEL = { allow: "直接执行", confirm: "需要确认", deny: "拒绝" };
783
+ const AUTH_LABEL = { key: "密钥", password: "密码" };
784
+
785
+ // 后端推来的完整状态;页面本身从不持有密码。
786
+ let state = { hosts: [], builtin: null };
787
+ // 当前展开的主机 id,空串表示全都收起。手风琴一次只开一台。
788
+ let expandedId = "";
789
+ // 表单当前编辑的主机 id,空串表示新增。
790
+ let editingId = "";
791
+ // 最近一次请求审计的主机,用来把回来的结果对上是哪台。
792
+ let pendingAuditId = "";
793
+ // 审计结果按主机缓存:来回展开不用每次都等网络。
794
+ const audits = new Map();
795
+ // 隐私开关:关掉时标题行不放地址,配置列表里的地址显示星号。
796
+ let hideConfig = localStorage.getItem("remote-host.hideConfig") === "1";
797
+
798
+ const EYE_ON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/></svg>';
799
+ const EYE_OFF =
800
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3 21 21"/><path d="M10.6 6.1A9.6 9.6 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-3.2 3.8"/><path d="M6.7 7.9A16.7 16.7 0 0 0 2.5 12S6 18 12 18a9.4 9.4 0 0 0 3.4-.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>';
801
+
802
+ /** 眼睛按钮跟着开关走,图标与提示文案一起换。 */
803
+ function syncHide() {
804
+ const button = $("btn-hide");
805
+ button.innerHTML = hideConfig ? EYE_OFF : EYE_ON;
806
+ button.title = hideConfig ? "显示主机地址" : "隐藏主机地址";
807
+ }
808
+
809
+ /** 发消息给后端。 */
810
+ function send(message) {
811
+ bridge.postMessage(message);
812
+ }
813
+
814
+ /** 轻提示,失败不打断流程。 */
815
+ function toast(title, variant) {
816
+ bridge.ui?.toast?.({ title, variant });
817
+ }
818
+
819
+ /** 面板内确认弹框:删除这种不可逆动作必须自己挡一道,不依赖桥接。 */
820
+ function confirmDialog({ title, text, okLabel }) {
821
+ return new Promise((resolve) => {
822
+ $("confirm-title").textContent = title;
823
+ $("confirm-text").textContent = text;
824
+ $("confirm-ok").textContent = okLabel;
825
+ $("confirm-mask").hidden = false;
826
+ $("confirm-cancel").focus();
827
+
828
+ const finish = (ok) => {
829
+ $("confirm-mask").hidden = true;
830
+ $("confirm-ok").removeEventListener("click", onOk);
831
+ $("confirm-cancel").removeEventListener("click", onCancel);
832
+ $("confirm-close").removeEventListener("click", onCancel);
833
+ resolve(ok);
834
+ };
835
+ const onOk = () => finish(true);
836
+ const onCancel = () => finish(false);
837
+
838
+ $("confirm-ok").addEventListener("click", onOk);
839
+ $("confirm-cancel").addEventListener("click", onCancel);
840
+ $("confirm-close").addEventListener("click", onCancel);
841
+ });
842
+ }
843
+
844
+ /** 建一个带 class 和文字的节点,省掉三行样板。 */
845
+ function el(tag, className, textContent) {
846
+ const node = document.createElement(tag);
847
+ if (className) node.className = className;
848
+ if (textContent !== undefined) node.textContent = textContent;
849
+ return node;
850
+ }
851
+
852
+ /** 当前展开的主机。 */
853
+ function expanded() {
854
+ return state.hosts.find((host) => host.id === expandedId);
855
+ }
856
+
857
+ /** 展开区域内的节点,只在已展开时才有。 */
858
+ function inExpanded(selector) {
859
+ const section = document.querySelector(`.host[data-id="${expandedId}"]`);
860
+ return section ? section.querySelector(selector) : null;
861
+ }
862
+
863
+ /** ISO 时间换成完整的「年-月-日 时:分:秒」。 */
864
+ function when(iso) {
865
+ if (!iso) return "没跑过";
866
+ const at = new Date(iso);
867
+ const pad = (value) => String(value).padStart(2, "0");
868
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
869
+ }
870
+
871
+ /** 主机图标:两层机架,比字符图标清楚且尺寸可控。 */
872
+ function hostIcon() {
873
+ const span = el("span", "host-icon");
874
+ span.innerHTML = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>';
875
+ return span;
876
+ }
877
+
878
+ /** 展开箭头:描边 SVG,比「›」粗,也不会跟文字基线错位。 */
879
+ function chevronIcon() {
880
+ const span = el("span", "chevron");
881
+ span.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>';
882
+ return span;
883
+ }
884
+
885
+ /** 相对时间:自动选最合适的单位,从秒一直到年。 */
886
+ function ago(iso) {
887
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
888
+ if (seconds < 60) return `${seconds} 秒前`;
889
+
890
+ const minutes = Math.floor(seconds / 60);
891
+ if (minutes < 60) return `${minutes} 分钟前`;
892
+
893
+ const hours = Math.floor(minutes / 60);
894
+ if (hours < 24) return `${hours} 小时前`;
895
+
896
+ const days = Math.floor(hours / 24);
897
+ if (days < 30) return `${days} 天前`;
898
+
899
+ const months = Math.floor(days / 30);
900
+ if (months < 12) return `${months} 月前`;
901
+
902
+ return `${Math.floor(months / 12)} 年前`;
903
+ }
904
+
905
+ /** 每台主机的一行标题,点一下展开或收起。 */
906
+ function hostSection(host) {
907
+ const open = host.id === expandedId;
908
+ const section = el("section", "host");
909
+ section.dataset.id = host.id;
910
+ section.setAttribute("aria-expanded", String(open));
911
+
912
+ const head = el("div", "host-head");
913
+ const toggleBtn = el("button", "host-toggle");
914
+ toggleBtn.type = "button";
915
+ toggleBtn.title = open ? "收起" : "展开看详情与操作记录";
916
+ toggleBtn.append(hostIcon(), el("span", "host-name", host.name));
917
+ const addr = hideConfig ? null : el("span", "host-addr", `${host.user}@${host.host}:${host.port}`);
918
+ head.append(...[toggleBtn, addr, hostActions(host), chevronIcon()].filter(Boolean));
919
+ // 整行都能展开;右侧动作按钮自己拦一下,别连带收起。
920
+ head.addEventListener("click", () => toggle(host.id));
921
+ section.append(head);
922
+
923
+ if (open) section.append(hostBody(host));
924
+ return section;
925
+ }
926
+
927
+ /** 标题行右侧的三个动作,收起时也能直接点。 */
928
+ function hostActions(host) {
929
+ const actions = el("div", "host-actions");
930
+ actions.addEventListener("click", (event) => event.stopPropagation());
931
+
932
+ const edit = el("button", "link-accent", "编辑");
933
+ edit.type = "button";
934
+ edit.title = "改这台主机的连接配置与策略";
935
+ edit.addEventListener("click", () => openForm(host));
936
+
937
+ const test = el("button", "link-muted", "测试");
938
+ test.type = "button";
939
+ test.title = "用已存配置连一次,验证密钥或密码还能用";
940
+ test.addEventListener("click", () => probeHost(host));
941
+
942
+ const remove = el("button", "link-danger", "删除");
943
+ remove.type = "button";
944
+ remove.title = "删除这台主机,审计记录保留";
945
+ remove.addEventListener("click", () => removeHost(host));
946
+
947
+ actions.append(edit, test, remove);
948
+ return actions;
949
+ }
950
+
951
+ /** 展开后的详情:连接信息与该机最近审计。 */
952
+ function hostBody(host) {
953
+ const body = el("div", "host-body");
954
+
955
+ const meta = el("dl", "meta");
956
+ const rows = [
957
+ ["地址", hideConfig ? "******" : `${host.user}@${host.host}:${host.port}`],
958
+ ["认证", AUTH_LABEL[host.auth] ?? host.auth],
959
+ ["策略", MODE_LABEL[host.mode] ?? host.mode],
960
+ ["规则", [host.allowGlob.length ? `放行 ${host.allowGlob.length} 条` : "", host.denyGlob.length ? `拒绝 ${host.denyGlob.length} 条` : ""].filter(Boolean).join(",") || "无自定义规则"]
961
+ ];
962
+ for (const [label, value] of rows) meta.append(el("dt", "", label), el("dd", "", value));
963
+ body.append(meta);
964
+
965
+ const probe = el("pre", "probe");
966
+ probe.hidden = true;
967
+ body.append(probe);
968
+
969
+ const audit = el("div", "audit");
970
+ const title = el("div", "audit-title");
971
+ const clear = el("button", "audit-clear", "清空");
972
+ clear.type = "button";
973
+ clear.title = "清空这台主机的全部操作记录";
974
+ clear.addEventListener("click", () => clearAudit(host));
975
+ title.append(el("span", "", "操作记录"), clear);
976
+
977
+ const auditList = el("div", "audit-list");
978
+ fillAudit(auditList, audits.get(host.id));
979
+ audit.append(title, auditList);
980
+ body.append(audit);
981
+
982
+ return body;
983
+ }
984
+
985
+ /** 重画整个列表;展开状态只认 expandedId。 */
986
+ function render() {
987
+ const list = $("list");
988
+ list.innerHTML = "";
989
+
990
+ if (!state.hosts.length) {
991
+ list.append(el("div", "empty", "还没有主机,点右上角「新增主机」加一台。"));
992
+ return;
993
+ }
994
+
995
+ for (const host of state.hosts) list.append(hostSection(host));
996
+ }
997
+
998
+ /** 展开或收起一台主机;展开时顺手拉一次该机的审计。 */
999
+ function toggle(id) {
1000
+ expandedId = expandedId === id ? "" : id;
1001
+ render();
1002
+ if (expandedId) loadAudit(expandedId);
1003
+ }
1004
+
1005
+ /** 请求某台主机的审计记录;回来的结果进缓存,重画时不必再等一次网络。 */
1006
+ function loadAudit(id) {
1007
+ pendingAuditId = id;
1008
+ send({ type: "audit", hostId: id });
1009
+ }
1010
+
1011
+ /** 把审计记录画进一个容器;rows 为空表示还没拿到。 */
1012
+ function fillAudit(box, rows) {
1013
+ box.innerHTML = "";
1014
+
1015
+ if (!rows) {
1016
+ box.textContent = "读取中…";
1017
+ return;
1018
+ }
1019
+
1020
+ if (!rows.length) {
1021
+ box.textContent = "还没有执行记录";
1022
+ return;
1023
+ }
1024
+
1025
+ for (const row of rows) {
1026
+ const item = el("div", "audit-row");
1027
+
1028
+ // 左边完整时间,右边相对时间;判定与退出码放在展开后的理由行。
1029
+ const head = el("div", "audit-head");
1030
+ const result = el("span", "audit-result", ago(row.ts));
1031
+ result.dataset.ts = row.ts;
1032
+ head.append(el("span", "audit-when", when(row.ts)), result);
1033
+
1034
+ const command = el("button", "audit-cmd", row.command);
1035
+ command.type = "button";
1036
+ command.title = "点一下看执行结果";
1037
+
1038
+ const output = el("pre", "audit-out", row.output || "没有输出");
1039
+ output.hidden = true;
1040
+
1041
+ // 展开时才看的判定细节:怎么判的、退出码、逐段理由。
1042
+ const why = el("div", "audit-why");
1043
+ why.hidden = true;
1044
+ why.textContent = [row.decision, row.exitCode === null || row.exitCode === undefined ? "" : `退出码 ${row.exitCode}`, (row.hits ?? []).join(";")].filter(Boolean).join(" · ");
1045
+
1046
+ command.addEventListener("click", () => {
1047
+ output.hidden = !output.hidden;
1048
+ why.hidden = output.hidden;
1049
+ });
1050
+
1051
+ item.append(head, command, why, output);
1052
+ box.append(item);
1053
+ }
1054
+ }
1055
+
1056
+ /** 收到后端审计结果:先入缓存,再只往当前展开的那台里画。 */
1057
+ function renderAudit(rows) {
1058
+ audits.set(pendingAuditId, rows);
1059
+ if (pendingAuditId !== expandedId) return;
1060
+ const box = inExpanded(".audit-list");
1061
+ if (box) fillAudit(box, rows);
1062
+ }
1063
+
1064
+ /** 清空这台主机的操作记录:先确认,再让后端删,删完等后端回一份空列表。 */
1065
+ async function clearAudit(host) {
1066
+ const answer = await bridge.ui.confirm({
1067
+ title: `清空「${host.name}」的操作记录?`,
1068
+ message: "这台主机的全部审计记录会被删除,其他主机不受影响,删了找不回来。",
1069
+ confirmLabel: "清空",
1070
+ variant: "danger"
1071
+ });
1072
+ if (!answer?.confirmed) return;
1073
+ audits.delete(host.id);
1074
+ pendingAuditId = host.id;
1075
+ send({ type: "clearAudit", hostId: host.id });
1076
+ }
1077
+
1078
+ /** 用已存配置测试连接:收起状态先展开,结果才看得见。 */
1079
+ function probeHost(host) {
1080
+ if (expandedId !== host.id) {
1081
+ expandedId = host.id;
1082
+ render();
1083
+ loadAudit(host.id);
1084
+ }
1085
+
1086
+ const probe = inExpanded(".probe");
1087
+ if (!probe) return;
1088
+ probe.hidden = false;
1089
+ probe.textContent = "正在连接…";
1090
+ probe.style.color = "var(--muted)";
1091
+ send({ type: "test", host, password: "" });
1092
+ }
1093
+
1094
+ /** 删除主机:先确认,再让后端清配置与密码。 */
1095
+ async function removeHost(host) {
1096
+ const ok = await confirmDialog({
1097
+ title: `删除「${host.name}」?`,
1098
+ text: "连接配置与已存密码会被移除,这台主机的操作记录保留。",
1099
+ okLabel: "删除"
1100
+ });
1101
+ if (!ok) return;
1102
+ if (expandedId === host.id) expandedId = "";
1103
+ send({ type: "remove", id: host.id });
1104
+ }
1105
+
1106
+ /** 打开表单;传主机就是编辑,不传就是新增。 */
1107
+ function openForm(host) {
1108
+ editingId = host?.id ?? "";
1109
+ $("form-title").textContent = host ? "编辑主机" : "新增主机";
1110
+ $("f-name").value = host?.name ?? "";
1111
+ $("f-host").value = host?.host ?? "";
1112
+ $("f-port").value = host?.port ?? 22;
1113
+ $("f-user").value = host?.user ?? "root";
1114
+ $("f-auth").value = host?.auth ?? "key";
1115
+ $("f-mode").value = host?.mode ?? "confirm";
1116
+ $("f-key").value = host?.keyPath ?? "";
1117
+ $("f-password").value = "";
1118
+ $("f-allow").value = (host?.allowGlob ?? []).join("\n");
1119
+ $("f-deny").value = (host?.denyGlob ?? []).join("\n");
1120
+ const passwordHint = $("password-hint");
1121
+ passwordHint.textContent = host?.hasPassword ? "留空表示不改" : "存进安全存储";
1122
+ passwordHint.title = host?.hasPassword ? "已存过密码,留空表示不改" : "存进系统安全存储,不会出现在配置文件与审计里";
1123
+ $("f-try").value = "";
1124
+ $("try-result").hidden = true;
1125
+ $("btn-remove-form").hidden = !host;
1126
+ $("form-error").hidden = true;
1127
+ syncAuthRows();
1128
+ syncModeHint();
1129
+ $("mask").hidden = false;
1130
+ $("f-name").focus();
1131
+ }
1132
+
1133
+ /** 关闭表单。 */
1134
+ function closeForm() {
1135
+ $("mask").hidden = true;
1136
+ editingId = "";
1137
+ }
1138
+
1139
+ /** 认证方式决定显示私钥还是密码输入框。 */
1140
+ function syncAuthRows() {
1141
+ const auth = $("f-auth").value;
1142
+ $("row-key").hidden = auth !== "key";
1143
+ $("row-password").hidden = auth !== "password";
1144
+ }
1145
+
1146
+ /** 策略选择跟着一句说明,不用去 README 里查。 */
1147
+ function syncModeHint() {
1148
+ const hint = $("mode-hint");
1149
+ hint.textContent = MODE_HINT[$("f-mode").value] ?? "";
1150
+ // 标签行里只放得下一句话,栏窄时靠悬停看全。
1151
+ hint.title = hint.textContent;
1152
+ }
1153
+
1154
+ /** 规则校验:超过 200 字符或含控制字符就拦住,不做静默丢弃。 */
1155
+ function ruleError(label, text) {
1156
+ const lines = text.split(/\r?\n/);
1157
+ for (const [index, line] of lines.entries()) {
1158
+ const rule = line.trim();
1159
+ if (!rule) continue;
1160
+ // eslint-disable-next-line no-control-regex
1161
+ if (/[\u0000-\u001f\u007f]/.test(rule)) return `${label}第 ${index + 1} 行含不可见字符`;
1162
+ if (rule.length > 200) return `${label}第 ${index + 1} 行超过 200 字符`;
1163
+ }
1164
+ return "";
1165
+ }
1166
+
1167
+ /** 把后端判定结果贴到试运行框下。 */
1168
+ function showPreview(verdict) {
1169
+ const box = $("try-result");
1170
+ const color = { allow: "var(--positive)", confirm: "var(--warning)", deny: "var(--danger)" }[verdict.decision] ?? "var(--muted)";
1171
+ box.hidden = false;
1172
+ box.style.color = color;
1173
+ box.textContent = `${DECISION_LABEL[verdict.decision] ?? verdict.decision}:${(verdict.hits ?? []).join(";")}`;
1174
+ }
1175
+
1176
+ /** 把内置规则画进编辑弹框;内容来自后端分类器,面板不另写一份。 */
1177
+ function renderBuiltin() {
1178
+ const box = $("builtin-body");
1179
+ box.innerHTML = "";
1180
+ const builtin = state.builtin;
1181
+ if (!builtin) return;
1182
+
1183
+ /** 一组带标题的列表。 */
1184
+ const part = (title, children) => {
1185
+ const wrap = el("div", "builtin-part");
1186
+ wrap.append(el("div", "builtin-title", title));
1187
+ const list = el("ul", "builtin-list");
1188
+ list.append(...children);
1189
+ wrap.append(list);
1190
+ return wrap;
1191
+ };
1192
+
1193
+ // 硬拒:规则名 + 具体形态
1194
+ const denyItems = builtin.deny.map((rule) => {
1195
+ const item = el("li", "", `${rule.name}:`);
1196
+ item.append(el("span", "builtin-detail", rule.detail));
1197
+ return item;
1198
+ });
1199
+
1200
+ const protectedItems = [el("li", "", `整棵子树:${builtin.protectedTree.join("、")}`), el("li", "", `只到直属子目录:${builtin.protectedChild.join("、")}`), el("li", "", `任何位置的密钥目录:${builtin.protectedNames.join("、")}`)];
1201
+
1202
+ const commandItems = [el("li", "", `${builtin.destructive.join("、")},以及「dd of=」「>」重定向、「find -delete」`)];
1203
+
1204
+ box.append(part("直接拒绝,连确认按钮也不给", denyItems), part("受保护的路径", protectedItems), part("会检查目标的命令", commandItems), el("div", "hint", "自定义拒绝规则可以再收紧;放行规则不能放开这里已经拒掉的。"));
1205
+ }
1206
+
1207
+ /** 收集表单值,字段名与后端 normalizeHost 对齐。 */
1208
+ function formValues() {
1209
+ return {
1210
+ id: editingId || undefined,
1211
+ name: $("f-name").value.trim(),
1212
+ host: $("f-host").value.trim(),
1213
+ port: Number($("f-port").value || 22),
1214
+ user: $("f-user").value.trim(),
1215
+ auth: $("f-auth").value,
1216
+ keyPath: $("f-key").value.trim(),
1217
+ mode: $("f-mode").value,
1218
+ allowGlob: $("f-allow").value,
1219
+ denyGlob: $("f-deny").value
1220
+ };
1221
+ }
1222
+
1223
+ /** 表单内提示,成功绿色、失败红色。 */
1224
+ function showFormError(message, ok = false) {
1225
+ const box = $("form-error");
1226
+ box.hidden = false;
1227
+ box.textContent = message;
1228
+ box.style.color = ok ? "var(--positive)" : "var(--danger)";
1229
+ }
1230
+
1231
+ /** 后端消息的唯一入口。 */
1232
+ function onMessage(message) {
1233
+ if (!message || typeof message !== "object") return;
1234
+
1235
+ switch (message.type) {
1236
+ case "state":
1237
+ state = { hosts: message.hosts ?? [], builtin: message.builtin ?? null };
1238
+ // 主机被删掉后展开的那台可能已不存在,顺手清掉。
1239
+ if (expandedId && !expanded()) expandedId = "";
1240
+ render();
1241
+ renderBuiltin();
1242
+ return;
1243
+ case "saved":
1244
+ closeForm();
1245
+ // 刚存的那台直接展开,省得再找。
1246
+ expandedId = message.id ?? expandedId;
1247
+ render();
1248
+ if (expandedId) loadAudit(expandedId);
1249
+ toast("已保存", "success");
1250
+ return;
1251
+ case "tested": {
1252
+ const text = message.message || (message.ok ? "连上了" : "连不上");
1253
+ if (!$("mask").hidden) {
1254
+ showFormError(text, message.ok);
1255
+ return;
1256
+ }
1257
+ const probe = inExpanded(".probe");
1258
+ if (!probe) return;
1259
+ probe.hidden = false;
1260
+ probe.textContent = text;
1261
+ probe.style.color = message.ok ? "var(--positive)" : "var(--danger)";
1262
+ return;
1263
+ }
1264
+ case "preview":
1265
+ showPreview(message);
1266
+ return;
1267
+ case "audit":
1268
+ // 只认当前展开那台的回复,切得快时旧回复直接丢掉。
1269
+ if (pendingAuditId !== expandedId) return;
1270
+ renderAudit(message.rows ?? []);
1271
+ return;
1272
+ case "error":
1273
+ if (!$("mask").hidden) showFormError(message.message || "出错了");
1274
+ else toast(message.message || "出错了", "error");
1275
+ return;
1276
+ case "finch:env": {
1277
+ // 打开面板时带的 selectId:主机 id 就直接展开它。
1278
+ const selectId = message.payload?.selectId;
1279
+ if (selectId) {
1280
+ expandedId = selectId;
1281
+ render();
1282
+ loadAudit(selectId);
1283
+ }
1284
+ return;
1285
+ }
1286
+ default:
1287
+ return;
1288
+ }
1289
+ }
1290
+
1291
+ // 事件绑定
1292
+ $("btn-hide").addEventListener("click", () => {
1293
+ hideConfig = !hideConfig;
1294
+ localStorage.setItem("remote-host.hideConfig", hideConfig ? "1" : "0");
1295
+ syncHide();
1296
+ render();
1297
+ });
1298
+ $("btn-add").addEventListener("click", () => openForm());
1299
+ $("btn-cancel").addEventListener("click", closeForm);
1300
+ $("form-close").addEventListener("click", closeForm);
1301
+ $("f-auth").addEventListener("change", syncAuthRows);
1302
+ $("f-mode").addEventListener("change", syncModeHint);
1303
+
1304
+ $("form").addEventListener("submit", (event) => {
1305
+ event.preventDefault();
1306
+ const problem = ruleError("放行规则", $("f-allow").value) || ruleError("拒绝规则", $("f-deny").value);
1307
+ if (problem) {
1308
+ showFormError(problem);
1309
+ return;
1310
+ }
1311
+ send({ type: "save", host: formValues(), password: $("f-password").value });
1312
+ });
1313
+
1314
+ // 试运行用表单里当前(可能还没保存)的策略与规则。
1315
+ const preview = () => {
1316
+ const command = $("f-try").value.trim();
1317
+ if (!command) return;
1318
+ send({ type: "preview", command, ...formValues() });
1319
+ };
1320
+ $("btn-try").addEventListener("click", preview);
1321
+ $("f-try").addEventListener("keydown", (event) => {
1322
+ if (event.key === "Enter") {
1323
+ event.preventDefault();
1324
+ preview();
1325
+ }
1326
+ });
1327
+
1328
+ $("btn-probe").addEventListener("click", () => {
1329
+ showFormError("正在连接…", true);
1330
+ send({ type: "test", host: formValues(), password: $("f-password").value });
1331
+ });
1332
+
1333
+ $("btn-remove-form").addEventListener("click", async () => {
1334
+ // 认表单正在编辑的那台,不能拿当前展开的顶替。
1335
+ const host = state.hosts.find((item) => item.id === editingId);
1336
+ if (!host) return;
1337
+ const ok = await confirmDialog({
1338
+ title: `删除「${host.name}」?`,
1339
+ text: "连接配置与已存密码会被移除,这台主机的操作记录保留。",
1340
+ okLabel: "删除"
1341
+ });
1342
+ if (!ok) return;
1343
+ closeForm();
1344
+ send({ type: "remove", id: host.id });
1345
+ });
1346
+
1347
+ // 相对时间会过时,每分钟只刷文案,不重画列表(重画会把展开的输出收回去)。
1348
+ setInterval(() => {
1349
+ for (const node of document.querySelectorAll(".audit-result")) {
1350
+ const ts = node.dataset.ts;
1351
+ if (ts) node.textContent = ago(ts);
1352
+ }
1353
+ }, 60_000);
1354
+
1355
+ // Bridge 先装监听再握手,避免后端回包早于监听。
1356
+ syncHide();
1357
+ bridge.onMessage(onMessage);
1358
+ send({ type: "ready" });
1359
+ </script>
1360
+ </body>
1361
+ </html>