serialport-tool 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.
@@ -0,0 +1,1931 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Serialport Tool - 串口调试助手</title>
8
+ <style>
9
+ /* ===== Reset & Base ===== */
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ :root {
19
+ --bg: #f7f7f8;
20
+ --bg2: #ffffff;
21
+ --bg3: #f2f2f3;
22
+ --surface: #ececf1;
23
+ --surface2: #f9fafb;
24
+ --border: #e5e5e5;
25
+ --text: #202123;
26
+ --text2: #353740;
27
+ --text3: #8e8ea0;
28
+ --accent: #10a37f;
29
+ --accent-soft: #e6f4ef;
30
+ --accent-border: #b7e1d3;
31
+ --green: #10a37f;
32
+ --red: #d92d20;
33
+ --orange: #b45309;
34
+ --blue: #2563eb;
35
+ --purple: #7c3aed;
36
+ --radius: 6px;
37
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
38
+ --mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
39
+ }
40
+
41
+ body {
42
+ font-family: var(--font);
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ height: 100vh;
46
+ width: 100vw;
47
+ overflow: hidden;
48
+ display: flex;
49
+ }
50
+
51
+ button {
52
+ font-family: var(--font);
53
+ cursor: pointer;
54
+ border: none;
55
+ background: none;
56
+ color: var(--text2);
57
+ font-size: 12px;
58
+ }
59
+
60
+ input,
61
+ select,
62
+ textarea {
63
+ font-family: var(--font);
64
+ }
65
+
66
+ ::-webkit-scrollbar {
67
+ width: 8px;
68
+ height: 8px;
69
+ }
70
+
71
+ ::-webkit-scrollbar-track {
72
+ background: transparent;
73
+ }
74
+
75
+ ::-webkit-scrollbar-thumb {
76
+ background: #d9d9e3;
77
+ border-radius: 999px;
78
+ border: 2px solid transparent;
79
+ background-clip: content-box;
80
+ }
81
+
82
+ ::-webkit-scrollbar-thumb:hover {
83
+ background: #c5c5d2;
84
+ border: 2px solid transparent;
85
+ background-clip: content-box;
86
+ }
87
+
88
+ /* ===== Layout ===== */
89
+ #app {
90
+ display: flex;
91
+ width: 100%;
92
+ height: 100%;
93
+ min-height: 0;
94
+ background: var(--bg);
95
+ }
96
+
97
+ #center-panel {
98
+ display: flex;
99
+ flex-direction: column;
100
+ flex: 1;
101
+ min-width: 0;
102
+ min-height: 0;
103
+ overflow: hidden;
104
+ background: var(--bg2);
105
+ }
106
+
107
+ /* ===== Pane Header ===== */
108
+ .pane-header {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 8px;
112
+ padding: 7px 12px;
113
+ background: var(--bg2);
114
+ border-bottom: 1px solid var(--border);
115
+ min-height: 38px;
116
+ flex-shrink: 0;
117
+ }
118
+
119
+ .pane-header .title {
120
+ font-size: 12px;
121
+ font-weight: 650;
122
+ color: var(--text2);
123
+ white-space: nowrap;
124
+ }
125
+
126
+ .pane-header .spacer {
127
+ flex: 1;
128
+ }
129
+
130
+ .pane-footer {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 8px;
134
+ padding: 6px 12px;
135
+ background: var(--surface2);
136
+ border-top: 1px solid var(--border);
137
+ font-size: 11px;
138
+ flex-shrink: 0;
139
+ min-height: 32px;
140
+ }
141
+
142
+ .btn-icon {
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ width: 26px;
147
+ height: 26px;
148
+ border-radius: 6px;
149
+ color: var(--text3);
150
+ font-size: 13px;
151
+ flex-shrink: 0;
152
+ }
153
+
154
+ .btn-icon:hover {
155
+ background: var(--surface);
156
+ color: var(--text2);
157
+ }
158
+
159
+ /* ===== Session Sidebar ===== */
160
+ #session-sidebar {
161
+ width: 240px;
162
+ min-width: 100px;
163
+ max-width: none;
164
+ display: flex;
165
+ flex-direction: column;
166
+ background: var(--surface2);
167
+ border-right: 1px solid var(--border);
168
+ overflow: hidden;
169
+ flex-shrink: 0;
170
+ }
171
+
172
+ #session-tree {
173
+ flex: 1;
174
+ overflow-y: auto;
175
+ padding: 6px 4px;
176
+ min-height: 0;
177
+ }
178
+
179
+ .tree-row {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 6px;
183
+ padding: 5px 8px;
184
+ cursor: pointer;
185
+ user-select: none;
186
+ font-size: 12px;
187
+ white-space: nowrap;
188
+ border-radius: 6px;
189
+ margin: 1px 4px;
190
+ color: var(--text2);
191
+ }
192
+
193
+ .tree-row:hover {
194
+ background: var(--surface);
195
+ }
196
+
197
+ .tree-row.selected {
198
+ background: var(--accent-soft);
199
+ color: var(--text);
200
+ outline: 1px solid var(--accent-border);
201
+ }
202
+
203
+ .tree-row.active {
204
+ background: #f1f8f5;
205
+ color: var(--text);
206
+ }
207
+
208
+ .tree-row.selected.active {
209
+ background: var(--accent-soft);
210
+ color: var(--text);
211
+ outline: 1px solid var(--accent-border);
212
+ }
213
+
214
+ .tree-row.active .tree-icon {
215
+ color: var(--accent);
216
+ }
217
+
218
+ .tree-row .tree-icon {
219
+ width: 16px;
220
+ text-align: center;
221
+ font-size: 12px;
222
+ color: var(--text3);
223
+ flex-shrink: 0;
224
+ }
225
+
226
+ .tree-row .tree-name {
227
+ flex: 1;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ }
231
+
232
+ .tree-row .tree-badge {
233
+ font-size: 10px;
234
+ color: var(--accent);
235
+ flex-shrink: 0;
236
+ }
237
+
238
+ .tree-row input {
239
+ background: var(--bg2);
240
+ border: 1px solid var(--accent);
241
+ color: var(--text);
242
+ border-radius: 6px;
243
+ padding: 3px 6px;
244
+ font-size: 12px;
245
+ width: 100%;
246
+ outline: none;
247
+ box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.12);
248
+ }
249
+
250
+ .tree-row.drag-over {
251
+ outline: 2px dashed var(--accent);
252
+ outline-offset: -2px;
253
+ }
254
+
255
+ .empty-tree {
256
+ display: flex;
257
+ flex-direction: column;
258
+ align-items: center;
259
+ justify-content: center;
260
+ padding: 40px 20px;
261
+ color: var(--text3);
262
+ font-size: 13px;
263
+ gap: 6px;
264
+ text-align: center;
265
+ }
266
+
267
+ /* ===== Connection Bar ===== */
268
+ #connection-bar {
269
+ background: var(--bg2);
270
+ border-bottom: 1px solid var(--border);
271
+ padding: 8px 14px;
272
+ flex-shrink: 0;
273
+ }
274
+
275
+ .conn-row {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 8px;
279
+ flex-wrap: wrap;
280
+ }
281
+
282
+ .conn-row+.conn-row {
283
+ margin-top: 6px;
284
+ padding-top: 6px;
285
+ border-top: 1px solid var(--border);
286
+ }
287
+
288
+ .conn-label {
289
+ font-size: 10px;
290
+ font-weight: 600;
291
+ color: var(--text3);
292
+ white-space: nowrap;
293
+ }
294
+
295
+ .conn-select {
296
+ background: var(--bg2);
297
+ color: var(--text);
298
+ border: 1px solid var(--border);
299
+ border-radius: 6px;
300
+ padding: 4px 8px;
301
+ font-size: 12px;
302
+ max-width: 170px;
303
+ }
304
+
305
+ .conn-select:focus {
306
+ border-color: var(--accent);
307
+ outline: none;
308
+ box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.12);
309
+ }
310
+
311
+ .btn {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ gap: 5px;
315
+ padding: 5px 12px;
316
+ border-radius: 6px;
317
+ font-size: 12px;
318
+ font-weight: 650;
319
+ transition: all 0.15s;
320
+ white-space: nowrap;
321
+ }
322
+
323
+ .btn-primary {
324
+ background: var(--accent);
325
+ color: #ffffff;
326
+ }
327
+
328
+ .btn-primary:hover {
329
+ background: #0e8f70;
330
+ }
331
+
332
+ .btn-primary:disabled {
333
+ opacity: 0.45;
334
+ cursor: default;
335
+ }
336
+
337
+ .btn-danger {
338
+ background: var(--red);
339
+ color: #ffffff;
340
+ }
341
+
342
+ .btn-danger:hover {
343
+ background: #b42318;
344
+ }
345
+
346
+ .status-dot {
347
+ width: 8px;
348
+ height: 8px;
349
+ border-radius: 50%;
350
+ display: inline-block;
351
+ flex-shrink: 0;
352
+ }
353
+
354
+ .status-dot.green {
355
+ background: var(--green);
356
+ }
357
+
358
+ .status-dot.gray {
359
+ background: #c5c5d2;
360
+ }
361
+
362
+ .status-text {
363
+ font-size: 11px;
364
+ color: var(--text3);
365
+ white-space: nowrap;
366
+ }
367
+
368
+ .error-bar {
369
+ background: #fef3f2;
370
+ color: var(--red);
371
+ border-top: 1px solid #fee4e2;
372
+ font-size: 11px;
373
+ padding: 5px 14px;
374
+ display: none;
375
+ align-items: center;
376
+ gap: 6px;
377
+ }
378
+
379
+ .checkbox-row {
380
+ display: flex;
381
+ align-items: center;
382
+ gap: 5px;
383
+ font-size: 11px;
384
+ color: var(--text2);
385
+ white-space: nowrap;
386
+ }
387
+
388
+ .checkbox-row input[type="checkbox"] {
389
+ accent-color: var(--accent);
390
+ }
391
+
392
+ /* ===== Preset Panel ===== */
393
+ #preset-panel {
394
+ width: 260px;
395
+ min-width: 100px;
396
+ max-width: none;
397
+ display: flex;
398
+ flex-direction: column;
399
+ background: var(--surface2);
400
+ border-left: 1px solid var(--border);
401
+ overflow: hidden;
402
+ flex-shrink: 0;
403
+ }
404
+
405
+ #preset-list {
406
+ flex: 1;
407
+ overflow-y: auto;
408
+ padding: 6px;
409
+ min-height: 0;
410
+ }
411
+
412
+ .preset-group {
413
+ margin-bottom: 6px;
414
+ }
415
+
416
+ .preset-group-header {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 5px;
420
+ padding: 5px 7px;
421
+ border-radius: 6px;
422
+ cursor: pointer;
423
+ font-size: 12px;
424
+ font-weight: 650;
425
+ color: var(--text2);
426
+ }
427
+
428
+ .preset-group-header:hover {
429
+ background: var(--surface);
430
+ }
431
+
432
+ .preset-cmd {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 7px;
436
+ padding: 7px 9px;
437
+ margin: 3px 0 3px 16px;
438
+ border-radius: 7px;
439
+ cursor: pointer;
440
+ font-size: 11px;
441
+ background: var(--bg2);
442
+ border: 1px solid var(--border);
443
+ transition: all 0.1s;
444
+ }
445
+
446
+ .preset-cmd:hover {
447
+ border-color: var(--accent-border);
448
+ background: #fbfefd;
449
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
450
+ }
451
+
452
+ .preset-cmd .cmd-icon {
453
+ font-size: 11px;
454
+ flex-shrink: 0;
455
+ }
456
+
457
+ .preset-cmd .cmd-info {
458
+ flex: 1;
459
+ min-width: 0;
460
+ }
461
+
462
+ .preset-cmd .cmd-name {
463
+ font-size: 11px;
464
+ line-height: 1.35;
465
+ color: var(--text);
466
+ }
467
+
468
+ .preset-cmd .cmd-payload {
469
+ font-size: 10px;
470
+ color: var(--text3);
471
+ font-family: var(--mono);
472
+ overflow: hidden;
473
+ text-overflow: ellipsis;
474
+ white-space: nowrap;
475
+ }
476
+
477
+ .preset-cmd .cmd-hotkey {
478
+ font-size: 9px;
479
+ padding: 1px 5px;
480
+ background: var(--surface);
481
+ border-radius: 4px;
482
+ color: var(--text3);
483
+ }
484
+
485
+ /* ===== Receive Area ===== */
486
+ #receive-area {
487
+ display: flex;
488
+ flex-direction: column;
489
+ flex: 1;
490
+ min-height: 0;
491
+ overflow: hidden;
492
+ background: var(--bg2);
493
+ }
494
+
495
+ #receive-log {
496
+ flex: 1;
497
+ overflow-y: auto;
498
+ padding: 8px;
499
+ background: var(--bg2);
500
+ font-family: var(--mono);
501
+ font-size: 12px;
502
+ min-height: 0;
503
+ }
504
+
505
+ .log-row {
506
+ display: flex;
507
+ gap: 8px;
508
+ padding: 3px 5px;
509
+ align-items: flex-start;
510
+ line-height: 1.45;
511
+ border-radius: 5px;
512
+ }
513
+
514
+ .log-row:hover {
515
+ background: var(--surface2);
516
+ }
517
+
518
+ .log-ts {
519
+ color: var(--text3);
520
+ font-size: 10px;
521
+ flex-shrink: 0;
522
+ width: 80px;
523
+ }
524
+
525
+ .log-dir {
526
+ font-size: 10px;
527
+ font-weight: 700;
528
+ flex-shrink: 0;
529
+ width: 42px;
530
+ }
531
+
532
+ .log-dir.rx {
533
+ color: var(--blue);
534
+ }
535
+
536
+ .log-dir.tx {
537
+ color: var(--accent);
538
+ }
539
+
540
+ .log-ascii {
541
+ color: var(--text);
542
+ white-space: pre-wrap;
543
+ word-break: break-all;
544
+ }
545
+
546
+ .log-hex {
547
+ color: var(--text3);
548
+ font-size: 10px;
549
+ }
550
+
551
+ .log-mixed {
552
+ display: flex;
553
+ flex-direction: column;
554
+ min-width: 0;
555
+ }
556
+
557
+ /* ===== Send Area ===== */
558
+ #send-area {
559
+ display: flex;
560
+ flex-direction: column;
561
+ flex-shrink: 0;
562
+ border-top: 1px solid var(--border);
563
+ background: var(--bg2);
564
+ }
565
+
566
+ #send-input {
567
+ height: 104px;
568
+ min-height: 60px;
569
+ background: var(--bg2);
570
+ color: var(--text);
571
+ border: none;
572
+ padding: 10px 12px;
573
+ font-family: var(--mono);
574
+ font-size: 13px;
575
+ resize: vertical;
576
+ outline: none;
577
+ }
578
+
579
+ #send-input::placeholder {
580
+ color: var(--text3);
581
+ }
582
+
583
+ /* Hidden bar when sidebar collapsed */
584
+ .hidden-bar {
585
+ writing-mode: vertical-lr;
586
+ display: flex;
587
+ align-items: center;
588
+ justify-content: center;
589
+ width: 32px;
590
+ min-width: 32px;
591
+ background: var(--surface2);
592
+ cursor: pointer;
593
+ font-size: 11px;
594
+ color: var(--text3);
595
+ user-select: none;
596
+ flex-shrink: 0;
597
+ border-right: 1px solid var(--border);
598
+ }
599
+
600
+ .hidden-bar.right-edge {
601
+ border-right: none;
602
+ border-left: 1px solid var(--border);
603
+ }
604
+
605
+ .hidden-bar:hover {
606
+ background: var(--surface);
607
+ color: var(--text2);
608
+ }
609
+
610
+ .resize-handle {
611
+ width: 6px;
612
+ min-width: 6px;
613
+ cursor: col-resize;
614
+ flex-shrink: 0;
615
+ background: transparent;
616
+ position: relative;
617
+ z-index: 5;
618
+ }
619
+
620
+ .resize-handle::after {
621
+ content: "";
622
+ position: absolute;
623
+ top: 0;
624
+ bottom: 0;
625
+ left: 2px;
626
+ width: 1px;
627
+ background: transparent;
628
+ }
629
+
630
+ .resize-handle:hover::after,
631
+ .resize-handle.resizing::after {
632
+ background: var(--accent);
633
+ }
634
+
635
+ body.resizing-panels {
636
+ cursor: col-resize;
637
+ user-select: none;
638
+ }
639
+
640
+ /* Empty State */
641
+ #empty-state {
642
+ display: flex;
643
+ flex-direction: column;
644
+ align-items: center;
645
+ justify-content: center;
646
+ flex: 1;
647
+ gap: 12px;
648
+ color: var(--text3);
649
+ background: var(--bg2);
650
+ }
651
+
652
+ /* File Editor */
653
+ #file-editor {
654
+ display: flex;
655
+ flex-direction: column;
656
+ flex: 1;
657
+ min-height: 0;
658
+ background: var(--bg2);
659
+ }
660
+
661
+ #file-editor textarea {
662
+ flex: 1;
663
+ background: var(--bg2);
664
+ color: var(--text);
665
+ border: none;
666
+ padding: 10px 12px;
667
+ font-family: var(--mono);
668
+ font-size: 13px;
669
+ resize: none;
670
+ outline: none;
671
+ min-height: 0;
672
+ }
673
+
674
+ /* Modal */
675
+ .modal-overlay {
676
+ position: fixed;
677
+ inset: 0;
678
+ background: rgba(0, 0, 0, 0.28);
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: center;
682
+ z-index: 100;
683
+ }
684
+
685
+ .modal {
686
+ background: var(--bg2);
687
+ border: 1px solid var(--border);
688
+ border-radius: 10px;
689
+ padding: 24px;
690
+ min-width: 420px;
691
+ max-width: 520px;
692
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18);
693
+ }
694
+
695
+ .modal h3 {
696
+ font-size: 16px;
697
+ margin-bottom: 16px;
698
+ color: var(--text);
699
+ }
700
+
701
+ .modal .form-group {
702
+ margin-bottom: 12px;
703
+ }
704
+
705
+ .modal label {
706
+ display: block;
707
+ font-size: 11px;
708
+ color: var(--text3);
709
+ margin-bottom: 4px;
710
+ }
711
+
712
+ .modal input[type="text"],
713
+ .modal textarea,
714
+ .modal select {
715
+ width: 100%;
716
+ background: var(--bg2);
717
+ color: var(--text);
718
+ border: 1px solid var(--border);
719
+ border-radius: 6px;
720
+ padding: 7px 9px;
721
+ font-size: 13px;
722
+ }
723
+
724
+ .modal input[type="text"]:focus,
725
+ .modal textarea:focus,
726
+ .modal select:focus {
727
+ border-color: var(--accent);
728
+ outline: none;
729
+ box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.12);
730
+ }
731
+
732
+ .modal textarea {
733
+ min-height: 80px;
734
+ font-family: var(--mono);
735
+ resize: vertical;
736
+ }
737
+
738
+ .modal .form-row {
739
+ display: flex;
740
+ gap: 12px;
741
+ align-items: center;
742
+ }
743
+
744
+ .modal .btn-row {
745
+ display: flex;
746
+ justify-content: flex-end;
747
+ gap: 8px;
748
+ margin-top: 16px;
749
+ }
750
+
751
+ /* Context Menu */
752
+ .ctx-menu {
753
+ position: fixed;
754
+ z-index: 200;
755
+ background: var(--bg2);
756
+ border: 1px solid var(--border);
757
+ border-radius: 8px;
758
+ padding: 5px 0;
759
+ min-width: 150px;
760
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14);
761
+ }
762
+
763
+ .ctx-item {
764
+ display: block;
765
+ width: 100%;
766
+ text-align: left;
767
+ padding: 7px 14px;
768
+ font-size: 12px;
769
+ color: var(--text);
770
+ }
771
+
772
+ .ctx-item:hover {
773
+ background: var(--surface2);
774
+ }
775
+
776
+ .ctx-item.danger {
777
+ color: var(--red);
778
+ }
779
+
780
+ .ctx-divider {
781
+ height: 1px;
782
+ background: var(--border);
783
+ margin: 5px 0;
784
+ }
785
+
786
+ /* Segment control */
787
+ .segmented {
788
+ display: inline-flex;
789
+ border: 1px solid var(--border);
790
+ border-radius: 6px;
791
+ overflow: hidden;
792
+ background: var(--surface2);
793
+ }
794
+
795
+ .segmented button {
796
+ padding: 3px 10px;
797
+ font-size: 11px;
798
+ background: transparent;
799
+ color: var(--text2);
800
+ border-right: 1px solid var(--border);
801
+ }
802
+
803
+ .segmented button:last-child {
804
+ border-right: none;
805
+ }
806
+
807
+ .segmented button.active {
808
+ background: var(--accent);
809
+ color: #ffffff;
810
+ }
811
+
812
+ /* Toast notification */
813
+ .toast {
814
+ position: fixed;
815
+ bottom: 20px;
816
+ left: 50%;
817
+ transform: translateX(-50%);
818
+ background: var(--text);
819
+ color: #ffffff;
820
+ border: 1px solid rgba(0, 0, 0, 0.08);
821
+ padding: 9px 20px;
822
+ border-radius: 999px;
823
+ font-size: 13px;
824
+ z-index: 300;
825
+ opacity: 0;
826
+ transition: opacity 0.3s;
827
+ pointer-events: none;
828
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
829
+ }
830
+
831
+ .toast.show {
832
+ opacity: 1;
833
+ }
834
+
835
+ .toast.warn {
836
+ background: #fff7ed;
837
+ border-color: #fed7aa;
838
+ color: var(--orange);
839
+ }
840
+
841
+ .toast.error {
842
+ background: #fef3f2;
843
+ border-color: #fee4e2;
844
+ color: var(--red);
845
+ }
846
+
847
+ .toast.success {
848
+ background: var(--accent-soft);
849
+ border-color: var(--accent-border);
850
+ color: #0f766e;
851
+ }
852
+ </style>
853
+ </head>
854
+
855
+ <body>
856
+ <div id="app">
857
+ <!-- Left: Session Sidebar -->
858
+ <div id="session-sidebar">
859
+ <div class="pane-header">
860
+ <span class="title">📁 会话管理</span>
861
+ <span class="spacer"></span>
862
+ <button class="btn-icon" title="刷新会话树" onclick="refreshSessionTree()">↻</button>
863
+ <button class="btn-icon" title="新建目录" onclick="newFolder()">📂</button>
864
+ <button class="btn-icon" title="新建会话" onclick="newSession()">+</button>
865
+ <button class="btn-icon" title="隐藏面板 (⌘⇧S)" onclick="toggleSessionSidebar()">◀</button>
866
+ </div>
867
+ <div id="session-tree"></div>
868
+ <div class="pane-footer">
869
+ <span id="session-status" style="font-size:11px;color:var(--text3)">未加载会话</span>
870
+ <span class="spacer"></span>
871
+ <button class="btn-icon" style="font-size:16px" title="打开存储目录"
872
+ onclick="rpc('open_config_dir').then(r=>toast(r.path,'')).catch(console.error)">📂</button>
873
+ </div>
874
+ </div>
875
+ <div id="session-resizer" class="resize-handle" title="拖拽调整会话管理宽度"></div>
876
+
877
+ <!-- Session hidden bar -->
878
+ <div id="session-hidden-bar" class="hidden-bar" style="display:none" onclick="toggleSessionSidebar()"
879
+ title="显示会话面板 (⌘⇧S)">会话</div>
880
+
881
+ <!-- Center: Serial config + Receive/Send/File editor -->
882
+ <div id="center-panel">
883
+ <!-- Connection Bar: initially hidden until session loaded -->
884
+ <div id="connection-bar" style="display:none">
885
+ <div class="conn-row">
886
+ <span class="conn-label">设备</span>
887
+ <select id="device-select" class="conn-select" style="min-width:180px">
888
+ <option value="">— 正在扫描串口 —</option>
889
+ </select>
890
+ <button class="btn-icon" onclick="refreshDevices()" title="刷新设备">🔄</button>
891
+ <span style="width:8px"></span>
892
+ <span class="conn-label">波特率</span>
893
+ <select id="baudrate-select" class="conn-select">
894
+ <option value="1200">1200</option>
895
+ <option value="2400">2400</option>
896
+ <option value="4800">4800</option>
897
+ <option value="9600">9600</option>
898
+ <option value="19200">19200</option>
899
+ <option value="38400">38400</option>
900
+ <option value="57600">57600</option>
901
+ <option value="115200" selected>115200</option>
902
+ <option value="230400">230400</option>
903
+ <option value="460800">460800</option>
904
+ <option value="921600">921600</option>
905
+ </select>
906
+ <span class="conn-label">数据位</span>
907
+ <select id="databits-select" class="conn-select">
908
+ <option value="5">5</option>
909
+ <option value="6">6</option>
910
+ <option value="7">7</option>
911
+ <option value="8" selected>8</option>
912
+ </select>
913
+ <span class="conn-label">校验</span>
914
+ <select id="parity-select" class="conn-select">
915
+ <option value="none" selected>None</option>
916
+ <option value="even">Even</option>
917
+ <option value="odd">Odd</option>
918
+ </select>
919
+ <span class="conn-label">停止</span>
920
+ <select id="stopbits-select" class="conn-select">
921
+ <option value="1" selected>1</option>
922
+ <option value="2">2</option>
923
+ </select>
924
+ <span class="conn-label">流控</span>
925
+ <select id="flowctrl-select" class="conn-select">
926
+ <option value="none" selected>None</option>
927
+ <option value="rtscts">RTS/CTS</option>
928
+ <option value="xonxoff">XON/XOFF</option>
929
+ </select>
930
+ </div>
931
+ <div class="conn-row">
932
+ <label class="checkbox-row"><input type="checkbox" id="auto-connect"> 自动打开串口</label>
933
+ <span class="spacer"></span>
934
+ <button id="connect-btn" class="btn btn-primary" onclick="toggleConnect()" disabled>
935
+ <span class="status-dot" style="background:#a6e3a1"></span> 连接
936
+ </button>
937
+ <span id="conn-status" class="status-text">
938
+ <span class="status-dot gray"></span> 未连接
939
+ </span>
940
+ <span id="conn-device" class="status-text"></span>
941
+ </div>
942
+ <div id="error-bar" class="error-bar"></div>
943
+ </div>
944
+
945
+ <!-- Empty State: shown by default until session loaded -->
946
+ <div id="empty-state" class="empty-state">
947
+ <div style="font-size:48px">🔌</div>
948
+ <div style="font-size:16px;font-weight:600">未选择会话</div>
949
+ <div style="font-size:13px">从左侧选择一个会话开始,或新建一个</div>
950
+ <div style="display:flex;gap:8px;margin-top:8px">
951
+ <button class="btn btn-primary" onclick="newSession()">+ 新建会话</button>
952
+ <button class="btn" style="border:1px solid var(--border)" onclick="refreshSessionTree(true)">▶
953
+ 加载首个会话</button>
954
+ </div>
955
+ </div>
956
+
957
+ <!-- Receive: hidden initially -->
958
+ <div id="receive-area" style="display:none">
959
+ <div class="pane-header">
960
+ <span class="title">📥 接收区</span>
961
+ <div class="segmented" id="receive-mode">
962
+ <button class="active" data-mode="text">Text</button>
963
+ <button data-mode="hex">HEX</button>
964
+ </div>
965
+ <label class="checkbox-row"><input type="checkbox" id="receive-mixed" checked>混显</label>
966
+ <span class="spacer"></span>
967
+ <span id="log-count" style="font-size:11px;color:var(--text3)">0 条</span>
968
+ <button class="btn-icon" onclick="clearLogs()" title="清空接收区">🗑</button>
969
+ <button class="btn-icon" onclick="exportLogs()" title="导出日志">💾</button>
970
+ </div>
971
+ <div id="receive-log"></div>
972
+ <div class="pane-footer">
973
+ <label class="checkbox-row"><input type="checkbox" id="auto-scroll" checked>自动滚动</label>
974
+ <label class="checkbox-row"><input type="checkbox" id="show-timestamp" checked>时间戳</label>
975
+ <span class="spacer"></span>
976
+ <span id="serial-error-text" style="color:var(--orange);font-size:11px"></span>
977
+ </div>
978
+ </div>
979
+
980
+ <!-- Send: hidden initially -->
981
+ <div id="send-area" style="display:none">
982
+ <div class="pane-header">
983
+ <span class="title">📤 发送区</span>
984
+ <div class="segmented" id="send-mode">
985
+ <button class="active" data-mode="ascii">Text</button>
986
+ <button data-mode="hex">HEX</button>
987
+ </div>
988
+ <label class="checkbox-row"><input type="checkbox" id="send-crlf" checked>自动回车</label>
989
+ <span class="spacer"></span>
990
+ <span id="hex-valid" style="font-size:11px;color:var(--text3)"></span>
991
+ </div>
992
+ <textarea id="send-input" placeholder="输入要发送的文本... (⌘↩ 发送)"></textarea>
993
+ <div class="pane-footer">
994
+ <button class="btn-icon" onclick="document.getElementById('send-input').value=''" title="清空">🗑</button>
995
+ <span class="spacer"></span>
996
+ <label class="checkbox-row"><input type="checkbox" id="local-echo" checked>本地回显</label>
997
+ <button class="btn btn-primary" onclick="sendData()">📨 发送</button>
998
+ </div>
999
+ </div>
1000
+
1001
+ <!-- File Editor: hidden initially -->
1002
+ <div id="file-editor" style="display:none">
1003
+ <div class="pane-header">
1004
+ <span class="title">📄</span><span id="file-editor-name" style="font-size:12px;font-weight:600"></span>
1005
+ <span class="spacer"></span>
1006
+ <button class="btn btn-primary" onclick="saveFile()">💾 保存 (⌘S)</button>
1007
+ </div>
1008
+ <textarea id="file-editor-textarea"></textarea>
1009
+ <div class="pane-footer">
1010
+ <span id="file-editor-info" style="font-size:11px;color:var(--text3)"></span>
1011
+ <span class="spacer"></span>
1012
+ <span style="font-size:11px;color:var(--text3)">自动保存已开启</span>
1013
+ </div>
1014
+ </div>
1015
+ </div>
1016
+
1017
+ <!-- Preset hidden bar -->
1018
+ <div id="preset-hidden-bar" class="hidden-bar right-edge" style="display:none" onclick="togglePresetSidebar()"
1019
+ title="显示预设面板 (⌘⇧P)">预设</div>
1020
+ <div id="preset-resizer" class="resize-handle" style="display:none" title="拖拽调整预设指令宽度"></div>
1021
+
1022
+ <!-- Right: Preset Panel -->
1023
+ <div id="preset-panel" style="display:none">
1024
+ <div class="pane-header">
1025
+ <span class="title">📋 预设指令</span>
1026
+ <span class="spacer"></span>
1027
+ <button class="btn-icon" title="新建分组" onclick="addGroup()">📂</button>
1028
+ <button class="btn-icon" title="隐藏面板 (⌘⇧P)" onclick="togglePresetSidebar()">▶</button>
1029
+ </div>
1030
+ <div id="preset-list"></div>
1031
+ <div style="padding:4px; border-top:1px solid var(--border)">
1032
+ <button style="width:100%; padding:4px; font-size:12px; color:var(--text2); border-radius:4px"
1033
+ onclick="addCommand()">+ 新增预设</button>
1034
+ </div>
1035
+ </div>
1036
+ </div>
1037
+
1038
+ <!-- Context Menu -->
1039
+ <div id="ctx-menu" class="ctx-menu" style="display:none"></div>
1040
+
1041
+ <!-- Toast -->
1042
+ <div id="toast" class="toast"></div>
1043
+
1044
+ <script>
1045
+ // ============================================================
1046
+ // Toast
1047
+ // ============================================================
1048
+ let toastTimer;
1049
+ function toast(msg, type) {
1050
+ const el = document.getElementById('toast');
1051
+ el.textContent = msg;
1052
+ el.className = 'toast ' + (type || '');
1053
+ el.classList.add('show');
1054
+ clearTimeout(toastTimer);
1055
+ toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
1056
+ }
1057
+
1058
+ // ============================================================
1059
+ // WebSocket
1060
+ // ============================================================
1061
+ const WS_URL = `ws://${location.host}/ws`;
1062
+ let ws, msgId = 0;
1063
+ const pending = {};
1064
+
1065
+ function connect() {
1066
+ ws = new WebSocket(WS_URL);
1067
+ // 暴露给测试
1068
+ window.__testWs = ws;
1069
+ window.__rpcId = window.__rpcId || 0;
1070
+ window.__rpcPending = window.__rpcPending || {};
1071
+ ws.onopen = () => {
1072
+ console.log('[WS] connected');
1073
+ refreshDevices();
1074
+ };
1075
+ ws.onmessage = (e) => {
1076
+ const msg = JSON.parse(e.data);
1077
+ if (msg.id !== undefined) {
1078
+ // 应用层 RPC 响应
1079
+ const p = pending[msg.id];
1080
+ if (p) { if (msg.error) p.reject(new Error(msg.error)); else p.resolve(msg.result); delete pending[msg.id]; }
1081
+ // 测试层 RPC 响应
1082
+ const tp = window.__rpcPending?.[msg.id];
1083
+ if (tp) { if (msg.error) tp.reject(new Error(msg.error)); else tp.resolve(msg.result); delete window.__rpcPending[msg.id]; }
1084
+ } else { handleEvent(msg); }
1085
+ };
1086
+ ws.onclose = () => { console.log('[WS] disconnected, reconnecting...'); setTimeout(connect, 2000); };
1087
+ ws.onerror = () => { };
1088
+ }
1089
+
1090
+ function rpc(method, params) {
1091
+ return new Promise((resolve, reject) => {
1092
+ const id = ++msgId;
1093
+ pending[id] = { resolve, reject };
1094
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id, method, params }));
1095
+ else reject(new Error('WebSocket not connected'));
1096
+ setTimeout(() => { if (pending[id]) { delete pending[id]; reject(new Error('Timeout')); } }, 10000);
1097
+ });
1098
+ }
1099
+
1100
+ // ============================================================
1101
+ // State
1102
+ // ============================================================
1103
+ let state = {
1104
+ config: {}, presets: [], tree: [], logEntries: [],
1105
+ activeSessionId: null, activeFilePath: null,
1106
+ serialOpen: false, serialDevice: null, serialError: null,
1107
+ serialPortStatus: { available: false, reason: '' },
1108
+ };
1109
+ let selectedNodeId = null, selectedGroupId = null;
1110
+ const PANEL_MIN_WIDTH = 100;
1111
+ const CENTER_MIN_WIDTH = 360;
1112
+
1113
+ function clampPanelWidth(width) {
1114
+ const max = Math.max(PANEL_MIN_WIDTH, window.innerWidth - CENTER_MIN_WIDTH);
1115
+ return Math.min(Math.max(PANEL_MIN_WIDTH, Math.round(width || PANEL_MIN_WIDTH)), max);
1116
+ }
1117
+
1118
+ function setPanelWidth(id, width) {
1119
+ document.getElementById(id).style.width = clampPanelWidth(width) + 'px';
1120
+ }
1121
+
1122
+ function handleEvent(msg) {
1123
+ switch (msg.type) {
1124
+ case 'initial_state':
1125
+ state.config = msg.config || {}; state.presets = msg.presets || [];
1126
+ state.tree = msg.tree || []; state.logEntries = msg.logEntries || [];
1127
+ state.activeSessionId = msg.activeSessionId; state.activeFilePath = msg.activeFilePath;
1128
+ state.serialOpen = msg.serialOpen; state.serialDevice = msg.serialDevice; state.serialError = msg.serialError;
1129
+ state.serialPortStatus = msg.serialPortStatus || { available: false, reason: '' };
1130
+ applyState();
1131
+ // 启动时自动加载首个会话
1132
+ if (!state.activeSessionId && state.tree.length > 0) {
1133
+ const first = findFirstSession(state.tree);
1134
+ if (first) loadSession(first.id);
1135
+ }
1136
+ // 标记测试就绪
1137
+ window.__test_ready = true;
1138
+ break;
1139
+ case 'log_entry':
1140
+ state.logEntries.push(msg.entry);
1141
+ if (state.logEntries.length > 5000) state.logEntries = state.logEntries.slice(-5000);
1142
+ if (document.getElementById('receive-area').style.display !== 'none') renderLogs();
1143
+ break;
1144
+ case 'logs_cleared': state.logEntries = []; renderLogs(); break;
1145
+ case 'config_updated': state.config = msg.config; applyConfig(); break;
1146
+ case 'presets_updated': state.presets = msg.groups; renderPresets(); break;
1147
+ case 'tree_updated': state.tree = msg.tree; clearEditingState(); renderTree(); break;
1148
+ case 'session_loaded':
1149
+ state.config = msg.config; state.presets = msg.presets;
1150
+ state.logEntries = msg.logEntries || [];
1151
+ state.activeSessionId = msg.activeSessionId; state.activeFilePath = msg.activeFilePath;
1152
+ state.serialOpen = msg.serialOpen || false; state.serialDevice = msg.serialDevice || null; state.serialError = msg.serialError || null;
1153
+ document.getElementById('receive-log').innerHTML = '';
1154
+ if (msg.tree) state.tree = msg.tree;
1155
+ if (editingNodeId) {
1156
+ applyConfig(); renderPresets(); renderLogs(); updateConnectionUI(); updateWorkspaceVisibility();
1157
+ } else {
1158
+ clearEditingState();
1159
+ applyState();
1160
+ }
1161
+ if (msg.autoOpenError) toast('自动打开串口失败: ' + msg.autoOpenError, 'error');
1162
+ break;
1163
+ case 'serial_opened': state.serialOpen = true; state.serialDevice = msg.device; state.serialError = null; updateConnectionUI(); break;
1164
+ case 'serial_closed': state.serialOpen = false; state.serialDevice = null; updateConnectionUI(); break;
1165
+ case 'serial_error': state.serialError = msg.message; updateConnectionUI(); break;
1166
+ }
1167
+ }
1168
+
1169
+ function applyState() { applyConfig(); renderTree(); renderPresets(); renderLogs(); updateConnectionUI(); updateWorkspaceVisibility(); }
1170
+
1171
+ function applyConfig() {
1172
+ const c = state.config;
1173
+ if (c.baudRate) document.getElementById('baudrate-select').value = c.baudRate;
1174
+ if (c.dataBits !== undefined) document.getElementById('databits-select').value = c.dataBits;
1175
+ if (c.stopBits !== undefined) document.getElementById('stopbits-select').value = c.stopBits;
1176
+ if (c.parity) document.getElementById('parity-select').value = c.parity;
1177
+ if (c.flowControl) document.getElementById('flowctrl-select').value = c.flowControl;
1178
+ document.getElementById('auto-connect').checked = c.autoConnect || false;
1179
+ document.getElementById('send-crlf').checked = c.sendAppendCRLF !== false;
1180
+ document.getElementById('local-echo').checked = c.localEcho !== false;
1181
+ document.getElementById('receive-mixed').checked = c.receiveShowMixed !== false;
1182
+ document.getElementById('auto-scroll').checked = c.receiveAutoScroll !== false;
1183
+ document.getElementById('show-timestamp').checked = c.receiveShowTimestamp !== false;
1184
+ document.getElementById('send-input').value = c.sendInputText || '';
1185
+ document.querySelectorAll('#receive-mode button').forEach(b => b.classList.toggle('active', b.dataset.mode === (c.receiveDisplayMode || 'text')));
1186
+ document.querySelectorAll('#send-mode button').forEach(b => b.classList.toggle('active', b.dataset.mode === (c.sendMode || 'ascii')));
1187
+ if (c.sessionPanelWidth) setPanelWidth('session-sidebar', c.sessionPanelWidth);
1188
+ if (c.presetPanelWidth) setPanelWidth('preset-panel', c.presetPanelWidth);
1189
+ // session sidebar visibility
1190
+ const sv = c.sessionPanelVisible !== false;
1191
+ document.getElementById('session-sidebar').style.display = sv ? 'flex' : 'none';
1192
+ document.getElementById('session-resizer').style.display = sv ? 'block' : 'none';
1193
+ document.getElementById('session-hidden-bar').style.display = sv ? 'none' : 'flex';
1194
+ }
1195
+
1196
+ function updateConnectionUI() {
1197
+ const btn = document.getElementById('connect-btn');
1198
+ const status = document.getElementById('conn-status');
1199
+ const devSpan = document.getElementById('conn-device');
1200
+ const errBar = document.getElementById('error-bar');
1201
+ const devSel = document.getElementById('device-select');
1202
+
1203
+ if (state.serialOpen) {
1204
+ btn.innerHTML = '<span class="status-dot" style="background:#f38ba8"></span> 断开';
1205
+ btn.className = 'btn btn-danger';
1206
+ status.innerHTML = '<span class="status-dot green"></span> 已连接';
1207
+ devSpan.textContent = state.serialDevice ? '· ' + (state.serialDevice.name || state.serialDevice.path || '') : '';
1208
+ errBar.style.display = 'none';
1209
+ btn.disabled = false;
1210
+ } else {
1211
+ btn.innerHTML = '<span class="status-dot" style="background:#a6e3a1"></span> 连接';
1212
+ btn.className = 'btn btn-primary';
1213
+ status.innerHTML = '<span class="status-dot gray"></span> 未连接';
1214
+ devSpan.textContent = '';
1215
+ btn.disabled = !devSel.value;
1216
+ if (state.serialError) {
1217
+ errBar.style.display = 'flex';
1218
+ errBar.innerHTML = '⚠ ' + state.serialError;
1219
+ } else {
1220
+ errBar.style.display = 'none';
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ /** 控制中间工作区和右侧预设栏的可见性 */
1226
+ function updateWorkspaceVisibility() {
1227
+ const preset = document.getElementById('preset-panel');
1228
+ const presetHidden = document.getElementById('preset-hidden-bar');
1229
+ const presetResizer = document.getElementById('preset-resizer');
1230
+ const empty = document.getElementById('empty-state');
1231
+ const receive = document.getElementById('receive-area');
1232
+ const send = document.getElementById('send-area');
1233
+ const fileEditor = document.getElementById('file-editor');
1234
+ const connection = document.getElementById('connection-bar');
1235
+
1236
+ if (state.activeFilePath) {
1237
+ // 文件编辑模式
1238
+ connection.style.display = 'none';
1239
+ preset.style.display = 'none'; presetHidden.style.display = 'none'; presetResizer.style.display = 'none';
1240
+ empty.style.display = 'none';
1241
+ receive.style.display = 'none'; send.style.display = 'none';
1242
+ fileEditor.style.display = 'flex';
1243
+ } else if (state.activeSessionId) {
1244
+ // 会话模式
1245
+ connection.style.display = 'block';
1246
+ const pv = state.config.presetPanelVisible !== false;
1247
+ preset.style.display = pv ? 'flex' : 'none';
1248
+ presetResizer.style.display = pv ? 'block' : 'none';
1249
+ presetHidden.style.display = pv ? 'none' : 'flex';
1250
+ empty.style.display = 'none';
1251
+ receive.style.display = 'flex'; send.style.display = 'flex';
1252
+ fileEditor.style.display = 'none';
1253
+ } else {
1254
+ // 无会话: 只显示空状态
1255
+ connection.style.display = 'none';
1256
+ preset.style.display = 'none'; presetHidden.style.display = 'none'; presetResizer.style.display = 'none';
1257
+ empty.style.display = 'flex';
1258
+ receive.style.display = 'none'; send.style.display = 'none';
1259
+ fileEditor.style.display = 'none';
1260
+ }
1261
+ }
1262
+
1263
+ // ============================================================
1264
+ // Tree
1265
+ // ============================================================
1266
+ // ============================================================
1267
+ // Session Tree (VS Code 风格内联编辑)
1268
+ // ============================================================
1269
+ let editingNodeId = null; // 正在重命名的节点 ID
1270
+ let insertingType = null; // 'session' | 'folder' — 正在新建
1271
+ let insertParentId = null; // 新建时的父节点 ID (null=根)
1272
+ let treeClickTimer = null;
1273
+ let renameGuardUntil = 0;
1274
+
1275
+ function beginRename(nodeId) {
1276
+ clearTimeout(treeClickTimer);
1277
+ selectedNodeId = nodeId;
1278
+ editingNodeId = nodeId;
1279
+ renameGuardUntil = Date.now() + 800;
1280
+ renderTree();
1281
+ }
1282
+
1283
+ function renderTree() {
1284
+ const container = document.getElementById('session-tree');
1285
+ container.innerHTML = '';
1286
+ if (!state.tree || state.tree.length === 0) {
1287
+ container.innerHTML = '<div class="empty-tree"><div style="font-size:28px">📭</div><div>还没有会话</div><div>点 + 创建第一个会话</div></div>';
1288
+ // 如果正在根下新建,即使树为空也显示输入行
1289
+ if (insertingType && insertParentId === null) {
1290
+ container.innerHTML = '';
1291
+ container.appendChild(createTreeRow({ __ghost: true, kind: insertingType }, 0));
1292
+ }
1293
+ return;
1294
+ }
1295
+ for (const node of state.tree) container.appendChild(createTreeRow(node, 0));
1296
+ // 在根层级插入
1297
+ if (insertingType && insertParentId === null) {
1298
+ container.appendChild(createTreeRow({ __ghost: true, kind: insertingType }, 0));
1299
+ }
1300
+ }
1301
+
1302
+ function createTreeRow(node, depth) {
1303
+ const isGhost = node.__ghost === true;
1304
+
1305
+ // Ghost: 内联新建行
1306
+ if (isGhost) {
1307
+ const wrapper = document.createElement('div');
1308
+ const row = document.createElement('div');
1309
+ row.className = 'tree-row';
1310
+ row.style.paddingLeft = (8 + depth * 12) + 'px';
1311
+ // 图标
1312
+ const icon = document.createElement('span');
1313
+ icon.className = 'tree-icon';
1314
+ icon.textContent = node.kind === 'folder' ? '📁' : '📄';
1315
+ row.appendChild(icon);
1316
+
1317
+ const input = document.createElement('input');
1318
+ input.type = 'text';
1319
+ input.placeholder = node.kind === 'folder' ? '目录名称' : '会话名称';
1320
+ input.style.cssText = 'flex:1;min-width:0';
1321
+ row.appendChild(input);
1322
+ wrapper.appendChild(row);
1323
+
1324
+ // 自动聚焦
1325
+ setTimeout(() => input.focus(), 50);
1326
+
1327
+ let committed = false;
1328
+ const commit = () => {
1329
+ if (committed) return; // 防止 Enter + blur 双重触发
1330
+ committed = true;
1331
+ const name = input.value.trim();
1332
+ if (!name) { clearEditingState(); renderTree(); return; }
1333
+ if (node.kind === 'session') {
1334
+ rpc('new_session', { name, parentId: insertParentId })
1335
+ .then(() => { clearEditingState(); })
1336
+ .catch(e => { toast('创建失败: ' + e.message, 'error'); clearEditingState(); renderTree(); });
1337
+ } else {
1338
+ rpc('new_folder', { name, parentId: insertParentId })
1339
+ .then(() => { clearEditingState(); })
1340
+ .catch(e => { toast('创建失败: ' + e.message, 'error'); clearEditingState(); renderTree(); });
1341
+ }
1342
+ };
1343
+
1344
+ input.addEventListener('keydown', (e) => {
1345
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
1346
+ if (e.key === 'Escape') { e.preventDefault(); committed = true; clearEditingState(); renderTree(); }
1347
+ });
1348
+ input.addEventListener('blur', () => {
1349
+ setTimeout(() => {
1350
+ if (!committed && insertingType !== null) { commit(); }
1351
+ }, 100);
1352
+ });
1353
+ // 阻止点击冒泡
1354
+ row.onclick = (e) => e.stopPropagation();
1355
+ // 阻止拖拽
1356
+ row.draggable = false;
1357
+ return wrapper;
1358
+ }
1359
+
1360
+ // ---- 普通节点 ----
1361
+ const isEditing = node.id === editingNodeId;
1362
+
1363
+ const div = document.createElement('div');
1364
+ div.className = 'tree-row';
1365
+ div.style.paddingLeft = (8 + depth * 12) + 'px';
1366
+ div.draggable = !isEditing;
1367
+
1368
+ // 箭头 (仅目录)
1369
+ if (node.kind === 'folder') {
1370
+ const arrow = document.createElement('span');
1371
+ arrow.textContent = node.expanded ? '▾' : '▸';
1372
+ arrow.style.cssText = 'width:14px;font-size:10px;color:var(--text3);flex-shrink:0;cursor:pointer';
1373
+ arrow.onclick = (e) => { e.stopPropagation(); node.expanded = !node.expanded; renderTree(); };
1374
+ div.appendChild(arrow);
1375
+ }
1376
+
1377
+ // 图标
1378
+ const icon = document.createElement('span');
1379
+ icon.className = 'tree-icon';
1380
+ icon.textContent = node.kind === 'folder' ? (node.expanded ? '📂' : '📁') : node.kind === 'session' ? '📄' : '📝';
1381
+ const isActive = (node.kind === 'session' && node.sessionId === state.activeSessionId) || (node.kind === 'file' && node.filePath === state.activeFilePath);
1382
+ if (isActive) icon.style.color = node.kind === 'session' ? 'var(--green)' : 'var(--orange)';
1383
+ div.appendChild(icon);
1384
+
1385
+ // 名称 或 编辑框
1386
+ if (isEditing) {
1387
+ const input = document.createElement('input');
1388
+ input.type = 'text';
1389
+ input.value = node.name;
1390
+ input.style.cssText = 'flex:1;min-width:0';
1391
+ div.appendChild(input);
1392
+
1393
+ const commitRename = () => {
1394
+ const newName = input.value.trim();
1395
+ const oldName = node.name;
1396
+ clearEditingState();
1397
+ if (newName && newName !== oldName) {
1398
+ rpc('rename_node', { nodeId: node.id, newName }).catch(console.error);
1399
+ }
1400
+ renderTree();
1401
+ };
1402
+
1403
+ input.addEventListener('keydown', (e) => {
1404
+ if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
1405
+ if (e.key === 'Escape') { e.preventDefault(); clearEditingState(); renderTree(); }
1406
+ });
1407
+ input.addEventListener('blur', () => {
1408
+ setTimeout(() => {
1409
+ if (editingNodeId !== node.id) return;
1410
+ if (Date.now() < renameGuardUntil) {
1411
+ input.focus();
1412
+ input.select();
1413
+ return;
1414
+ }
1415
+ commitRename();
1416
+ }, 100);
1417
+ });
1418
+ // 阻止行点击事件
1419
+ div.onclick = (e) => e.stopPropagation();
1420
+ requestAnimationFrame(() => {
1421
+ input.focus();
1422
+ input.select();
1423
+ });
1424
+ } else {
1425
+ const name = document.createElement('span');
1426
+ name.className = 'tree-name';
1427
+ name.textContent = node.name;
1428
+ div.appendChild(name);
1429
+
1430
+ // 活动标记
1431
+ if (isActive) {
1432
+ const badge = document.createElement('span');
1433
+ badge.className = 'tree-badge'; badge.textContent = '●';
1434
+ div.appendChild(badge);
1435
+ }
1436
+
1437
+ // 单击事件
1438
+ div.onclick = (e) => {
1439
+ clearTimeout(treeClickTimer);
1440
+ if (e.detail > 1) {
1441
+ e.preventDefault();
1442
+ e.stopPropagation();
1443
+ if (node.kind !== 'file') {
1444
+ beginRename(node.id);
1445
+ }
1446
+ return;
1447
+ }
1448
+ treeClickTimer = setTimeout(() => {
1449
+ treeClickTimer = null;
1450
+ if (editingNodeId) return;
1451
+ selectedNodeId = node.id;
1452
+ if (node.kind === 'session') loadSession(node.id);
1453
+ else if (node.kind === 'file') openFile(node.id);
1454
+ else { node.expanded = !node.expanded; renderTree(); }
1455
+ }, 280);
1456
+ };
1457
+ // 双击由 click.detail 分流处理;这里仅阻止浏览器默认选中文本。
1458
+ div.ondblclick = (e) => {
1459
+ e.preventDefault();
1460
+ e.stopPropagation();
1461
+ };
1462
+ // 右键菜单
1463
+ div.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e, node); };
1464
+ }
1465
+
1466
+ if (node.id === selectedNodeId) div.classList.add('selected');
1467
+ if (isActive && !isEditing) div.classList.add('active');
1468
+
1469
+ // 拖拽
1470
+ if (!isEditing) {
1471
+ div.ondragstart = (e) => { e.dataTransfer.setData('text/plain', node.id); };
1472
+ if (node.kind === 'folder') {
1473
+ div.ondragover = (e) => { e.preventDefault(); e.stopPropagation(); div.classList.add('drag-over'); };
1474
+ div.ondragleave = () => div.classList.remove('drag-over');
1475
+ div.ondrop = (e) => {
1476
+ e.preventDefault(); e.stopPropagation(); div.classList.remove('drag-over');
1477
+ const draggedId = e.dataTransfer.getData('text/plain');
1478
+ if (draggedId && draggedId !== node.id) rpc('move_node', { nodeId: draggedId, targetId: node.id }).catch(console.error);
1479
+ };
1480
+ }
1481
+ }
1482
+
1483
+ const wrapper = document.createElement('div');
1484
+ wrapper.appendChild(div);
1485
+
1486
+ // 子节点
1487
+ if (node.kind === 'folder' && node.expanded && node.children) {
1488
+ for (const child of node.children) wrapper.appendChild(createTreeRow(child, depth + 1));
1489
+ // 如果正在此目录下新建
1490
+ if (insertingType && insertParentId === node.id) {
1491
+ wrapper.appendChild(createTreeRow({ __ghost: true, kind: insertingType }, depth + 1));
1492
+ }
1493
+ }
1494
+
1495
+ return wrapper;
1496
+ }
1497
+
1498
+ function clearEditingState() {
1499
+ editingNodeId = null;
1500
+ insertingType = null;
1501
+ insertParentId = null;
1502
+ renameGuardUntil = 0;
1503
+ }
1504
+
1505
+ function findFirstSession(nodes) {
1506
+ for (const n of nodes) { if (n.kind === 'session') return n; const r = findFirstSession(n.children || []); if (r) return r; }
1507
+ return null;
1508
+ }
1509
+
1510
+ function findNode(id) {
1511
+ function _find(nodes) { for (const n of nodes) { if (n.id === id) return n; const r = _find(n.children || []); if (r) return r; } return null; }
1512
+ return _find(state.tree);
1513
+ }
1514
+
1515
+ function loadSession(nodeId) { rpc('load_session', { nodeId }).catch(e => toast('加载会话失败: ' + e.message, 'error')); }
1516
+
1517
+ function refreshSessionTree(loadFirst) {
1518
+ rpc('get_tree').then(res => {
1519
+ state.tree = res.tree || [];
1520
+ state.activeSessionId = res.activeSessionId || null;
1521
+ state.activeFilePath = res.activeFilePath || null;
1522
+ state.logEntries = res.logEntries || state.logEntries;
1523
+ clearEditingState();
1524
+ applyState();
1525
+ if (loadFirst) {
1526
+ const first = findFirstSession(state.tree);
1527
+ if (first) loadSession(first.id);
1528
+ }
1529
+ toast('会话树已刷新', 'success');
1530
+ }).catch(e => toast('刷新会话树失败: ' + e.message, 'error'));
1531
+ }
1532
+
1533
+ /** 开始新建 — 设置插入状态并刷新树 */
1534
+ function startInsert(type) {
1535
+ clearEditingState();
1536
+ insertingType = type;
1537
+ // 确定父节点:如果选中的是文件夹 → 在文件夹内创建;否则在根下
1538
+ const sel = selectedNodeId ? findNode(selectedNodeId) : null;
1539
+ insertParentId = (sel && sel.kind === 'folder') ? sel.id : null;
1540
+ // 确保父目录展开
1541
+ if (sel && sel.kind === 'folder') sel.expanded = true;
1542
+ renderTree();
1543
+ }
1544
+
1545
+ function newSession() { startInsert('session'); }
1546
+ function newFolder() { startInsert('folder'); }
1547
+
1548
+ function deleteNode(node) {
1549
+ const msg = node.kind === 'folder'
1550
+ ? `确定删除目录 "${node.name}" 及其所有内容?`
1551
+ : `确定删除 "${node.name}"?`;
1552
+ if (confirm(msg)) rpc('delete_node', { nodeId: node.id }).catch(console.error);
1553
+ }
1554
+
1555
+ function showContextMenu(e, node) {
1556
+ const menu = document.getElementById('ctx-menu');
1557
+ let html = '';
1558
+ if (node.kind === 'folder') {
1559
+ html += `<button class="ctx-item" onclick="selectedNodeId='${node.id}';startInsert('session');hideCtxMenu()">📄 新建会话</button>`;
1560
+ html += `<button class="ctx-item" onclick="selectedNodeId='${node.id}';startInsert('folder');hideCtxMenu()">📁 新建子目录</button>`;
1561
+ html += '<div class="ctx-divider"></div>';
1562
+ }
1563
+ html += `<button class="ctx-item" onclick="beginRename('${node.id}');hideCtxMenu()">✏️ 重命名</button>`;
1564
+ html += '<div class="ctx-divider"></div>';
1565
+ html += `<button class="ctx-item danger" onclick="hideCtxMenu();deleteNode(findNode('${node.id}'));">🗑 删除</button>`;
1566
+ menu.innerHTML = html;
1567
+ menu.style.display = 'block'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px';
1568
+ setTimeout(() => document.addEventListener('click', hideCtxMenu, { once: true }), 0);
1569
+ }
1570
+ function hideCtxMenu() { document.getElementById('ctx-menu').style.display = 'none'; }
1571
+
1572
+ // Root drop target
1573
+ (function () {
1574
+ const tree = document.getElementById('session-tree');
1575
+ tree.ondragover = (e) => e.preventDefault();
1576
+ tree.ondrop = (e) => { e.preventDefault(); const id = e.dataTransfer.getData('text/plain'); if (id) rpc('move_node', { nodeId: id, targetId: null }).catch(console.error); };
1577
+ })();
1578
+
1579
+ // ============================================================
1580
+ // Presets
1581
+ // ============================================================
1582
+ function renderPresets() {
1583
+ const container = document.getElementById('preset-list');
1584
+ container.innerHTML = '';
1585
+ if (!state.presets || state.presets.length === 0) {
1586
+ container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3);font-size:13px">暂无预设分组</div>';
1587
+ return;
1588
+ }
1589
+ for (const g of state.presets) {
1590
+ const gd = document.createElement('div'); gd.className = 'preset-group';
1591
+ const h = document.createElement('div'); h.className = 'preset-group-header';
1592
+ h.innerHTML = `<span style="font-size:10px;color:var(--text3)">▾</span><span>📁 ${escHtml(g.name)}</span><span style="flex:1"></span><span style="font-size:10px;color:var(--text3)">${g.commands.length}</span><button class="btn-icon" style="font-size:10px" onclick="event.stopPropagation();addCommandToGroup('${g.id}')">+</button>`;
1593
+ h.onclick = () => { selectedGroupId = g.id; };
1594
+ h.oncontextmenu = (e) => { e.preventDefault(); if (confirm('删除分组 "' + g.name + '"?')) rpc('remove_group', { id: g.id }).catch(console.error); };
1595
+ gd.appendChild(h);
1596
+ for (const cmd of g.commands) {
1597
+ const cd = document.createElement('div'); cd.className = 'preset-cmd';
1598
+ cd.innerHTML = `<span style="font-size:11px;flex-shrink:0">${cmd.displayMode === 'hex' ? '🔢' : '📝'}</span><div class="cmd-info"><div class="cmd-name">${escHtml(cmd.name)}${cmd.hotkey ? `<span class="cmd-hotkey">⌘${cmd.hotkey}</span>` : ''}</div><div class="cmd-payload">${escHtml(cmd.payload)}</div></div><span style="font-size:11px;color:var(--text3)">📨</span>`;
1599
+ cd.onclick = () => sendPreset(cmd);
1600
+ cd.oncontextmenu = (e) => { e.preventDefault(); showCommandEditor(cmd, g.id); };
1601
+ gd.appendChild(cd);
1602
+ }
1603
+ container.appendChild(gd);
1604
+ }
1605
+ }
1606
+
1607
+ function addGroup() { const name = prompt('分组名称:'); if (name && name.trim()) rpc('add_group', { name: name.trim() }).catch(console.error); }
1608
+ function addCommand() {
1609
+ if (!state.presets || state.presets.length === 0) { toast('请先创建分组', 'warn'); return; }
1610
+ addCommandToGroup(selectedGroupId || state.presets[0].id);
1611
+ }
1612
+ function addCommandToGroup(gid) { selectedGroupId = gid; showCommandEditor(null, gid); }
1613
+
1614
+ function showCommandEditor(existing, groupId) {
1615
+ const isNew = !existing;
1616
+ const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
1617
+ overlay.innerHTML = `
1618
+ <div class="modal">
1619
+ <h3>${isNew ? '新增指令' : '编辑指令'}</h3>
1620
+ <div class="form-group"><label>名称</label><input type="text" id="cmd-name" value="${escHtml(existing?.name || '')}"></div>
1621
+ <div class="form-group"><label>格式</label><select id="cmd-mode"><option value="ascii" ${(!existing || existing.displayMode === 'ascii') ? 'selected' : ''}>ASCII</option><option value="hex" ${existing && existing.displayMode === 'hex' ? 'selected' : ''}>HEX</option></select></div>
1622
+ <div class="form-group"><label>指令内容</label><textarea id="cmd-payload">${escHtml(existing?.payload || '')}</textarea></div>
1623
+ <div class="form-row"><label class="checkbox-row"><input type="checkbox" id="cmd-newline" ${existing?.appendNewline ? 'checked' : ''}>发送时追加 \\r\\n</label></div>
1624
+ <div class="form-row"><label class="checkbox-row"><input type="checkbox" id="cmd-use-hotkey" ${existing?.hotkey ? 'checked' : ''}>绑定快捷键</label><select id="cmd-hotkey" ${existing?.hotkey ? '' : 'disabled'}>${[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => `<option value="${n}" ${existing?.hotkey === n ? 'selected' : ''}>⌘${n}</option>`).join('')}</select></div>
1625
+ <div class="btn-row"><button class="btn" onclick="this.closest('.modal-overlay').remove()">取消</button><button class="btn btn-primary" id="cmd-save-btn">${isNew ? '新增' : '保存'}</button></div>
1626
+ </div>`;
1627
+ document.body.appendChild(overlay);
1628
+ overlay.querySelector('#cmd-use-hotkey').onchange = function () { overlay.querySelector('#cmd-hotkey').disabled = !this.checked; };
1629
+ overlay.querySelector('#cmd-save-btn').onclick = () => {
1630
+ const name = overlay.querySelector('#cmd-name').value.trim(), payload = overlay.querySelector('#cmd-payload').value;
1631
+ if (!name || !payload.trim()) return;
1632
+ const cmd = { name, payload, displayMode: overlay.querySelector('#cmd-mode').value, appendNewline: overlay.querySelector('#cmd-newline').checked, hotkey: overlay.querySelector('#cmd-use-hotkey').checked ? parseInt(overlay.querySelector('#cmd-hotkey').value) : null };
1633
+ if (isNew) rpc('add_command', { command: cmd, groupId }).then(() => overlay.remove()).catch(console.error);
1634
+ else { cmd.id = existing.id; rpc('update_command', { command: cmd, groupId }).then(() => overlay.remove()).catch(console.error); }
1635
+ };
1636
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
1637
+ }
1638
+
1639
+ function sendPreset(cmd) {
1640
+ if (!state.serialOpen) { toast('请先连接串口', 'warn'); return; }
1641
+ rpc('send_preset', { command: cmd }).catch(console.error);
1642
+ }
1643
+
1644
+ // ============================================================
1645
+ // Receive Log
1646
+ // ============================================================
1647
+ function renderLogs() {
1648
+ const container = document.getElementById('receive-log');
1649
+ const mode = document.querySelector('#receive-mode button.active')?.dataset?.mode || 'text';
1650
+ const showMixed = document.getElementById('receive-mixed').checked;
1651
+ const showTs = document.getElementById('show-timestamp').checked;
1652
+ const autoScroll = document.getElementById('auto-scroll').checked;
1653
+ document.getElementById('log-count').textContent = state.logEntries.length + ' 条';
1654
+
1655
+ // 全量重渲染检测
1656
+ if (container.children.length > state.logEntries.length || (container.children.length > 0 && state.logEntries.length === 0)) {
1657
+ container.innerHTML = '';
1658
+ }
1659
+ const startIdx = container.children.length;
1660
+ for (let i = startIdx; i < state.logEntries.length; i++) {
1661
+ const entry = state.logEntries[i];
1662
+ const row = document.createElement('div'); row.className = 'log-row';
1663
+ if (showTs) { const ts = document.createElement('span'); ts.className = 'log-ts'; ts.textContent = new Date(entry.timestamp).toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(new Date(entry.timestamp).getMilliseconds()).padStart(3, '0'); row.appendChild(ts); }
1664
+ const dir = document.createElement('span'); dir.className = 'log-dir ' + (entry.direction === 'RX' ? 'rx' : 'tx'); dir.textContent = entry.direction === 'RX' ? '◀ RX' : '▶ TX'; row.appendChild(dir);
1665
+ const hexStr = entry.data.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
1666
+ if (mode === 'hex') {
1667
+ const hx = document.createElement('span'); hx.className = 'log-ascii'; hx.textContent = hexStr; row.appendChild(hx);
1668
+ } else {
1669
+ const asciiStr = entry.data.map(b => (b >= 0x20 && b <= 0x7E) ? String.fromCharCode(b) : '.').join('');
1670
+ const wrap = document.createElement('div'); wrap.className = 'log-mixed';
1671
+ const a = document.createElement('span'); a.className = 'log-ascii'; a.textContent = asciiStr; wrap.appendChild(a);
1672
+ if (showMixed) { const h = document.createElement('span'); h.className = 'log-hex'; h.textContent = hexStr; wrap.appendChild(h); }
1673
+ row.appendChild(wrap);
1674
+ }
1675
+ container.appendChild(row);
1676
+ }
1677
+ while (container.children.length > 5000) container.removeChild(container.firstChild);
1678
+ if (autoScroll && container.lastChild) container.lastChild.scrollIntoView({ behavior: 'smooth', block: 'end' });
1679
+ }
1680
+
1681
+ function clearLogs() { rpc('clear_logs').catch(console.error); }
1682
+ function exportLogs() {
1683
+ rpc('export_logs').then(res => {
1684
+ let text = '';
1685
+ for (const e of (res.entries || [])) {
1686
+ const ts = new Date(e.timestamp).toISOString();
1687
+ const ascii = e.data.map(b => (b >= 0x20 && b <= 0x7E) ? String.fromCharCode(b) : '.').join('');
1688
+ const hex = e.data.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
1689
+ text += `[${ts}] ${e.direction} | ASCII: ${ascii}\n HEX: ${hex}\n`;
1690
+ }
1691
+ const blob = new Blob([text], { type: 'text/plain' });
1692
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
1693
+ a.download = `serialport-tool-log-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.txt`; a.click();
1694
+ toast('日志已导出', 'success');
1695
+ }).catch(console.error);
1696
+ }
1697
+
1698
+ // ============================================================
1699
+ // Connection
1700
+ // ============================================================
1701
+ async function refreshDevices() {
1702
+ const sel = document.getElementById('device-select');
1703
+ sel.innerHTML = '<option value="">— 正在扫描串口 —</option>';
1704
+ sel.title = '';
1705
+ try {
1706
+ const res = await rpc('list_devices');
1707
+ const devices = res.devices || [];
1708
+ state.serialPortStatus = res.serialPortStatus || state.serialPortStatus;
1709
+
1710
+ // 检查串口模块可用性
1711
+ if (state.serialPortStatus && !state.serialPortStatus.available) {
1712
+ sel.innerHTML = '<option value="">⚠ 串口模块不可用 (需 Node.js)</option>';
1713
+ sel.title = state.serialPortStatus.reason || '';
1714
+ } else if (devices.length === 0) {
1715
+ sel.innerHTML = '<option value="">— 未找到设备 —</option>';
1716
+ } else {
1717
+ sel.innerHTML = '<option value="">— 选择设备 —</option>';
1718
+ for (const d of devices) {
1719
+ const opt = document.createElement('option');
1720
+ opt.value = d.path;
1721
+ opt.textContent = d.path.replace('/dev/', '');
1722
+ sel.appendChild(opt);
1723
+ }
1724
+ }
1725
+ if (state.config.lastDevicePath && devices.some(d => d.path === state.config.lastDevicePath)) {
1726
+ sel.value = state.config.lastDevicePath;
1727
+ }
1728
+ updateConnectionUI();
1729
+ } catch (e) {
1730
+ console.error('list_devices failed:', e);
1731
+ sel.innerHTML = '<option value="">— 扫描暂未完成 —</option>';
1732
+ sel.title = e.message || '';
1733
+ }
1734
+ }
1735
+
1736
+ function toggleConnect() {
1737
+ if (state.serialOpen) {
1738
+ rpc('close_port').catch(e => toast('断开失败: ' + e.message, 'error'));
1739
+ } else {
1740
+ const path = document.getElementById('device-select').value;
1741
+ if (!path) {
1742
+ if (state.serialPortStatus && !state.serialPortStatus.available) {
1743
+ toast('串口模块不可用: ' + (state.serialPortStatus.reason || '请使用 Node.js 运行'), 'error');
1744
+ } else {
1745
+ toast('请先选择串口设备', 'warn');
1746
+ }
1747
+ return;
1748
+ }
1749
+ const config = {
1750
+ baudRate: parseInt(document.getElementById('baudrate-select').value),
1751
+ dataBits: parseInt(document.getElementById('databits-select').value),
1752
+ stopBits: parseInt(document.getElementById('stopbits-select').value),
1753
+ parity: document.getElementById('parity-select').value,
1754
+ flowControl: document.getElementById('flowctrl-select').value,
1755
+ lineEnding: document.getElementById('send-crlf').checked ? 'crlf' : 'none',
1756
+ localEcho: document.getElementById('local-echo').checked,
1757
+ showTimestamp: document.getElementById('show-timestamp').checked,
1758
+ };
1759
+ rpc('open_port', { device: { path }, config }).catch(e => toast('连接失败: ' + e.message, 'error'));
1760
+ }
1761
+ }
1762
+
1763
+ // ============================================================
1764
+ // Send
1765
+ // ============================================================
1766
+ function sendData() {
1767
+ const input = document.getElementById('send-input');
1768
+ const text = input.value;
1769
+ if (!text.trim()) return;
1770
+ const mode = document.querySelector('#send-mode button.active')?.dataset?.mode || 'ascii';
1771
+ if (mode === 'hex') {
1772
+ const tokens = text.split(/[\s,\n\t]+/).filter(Boolean);
1773
+ if (!tokens.every(t => /^[0-9a-fA-F]{1,2}$/.test(t))) { toast('HEX 格式错误', 'error'); return; }
1774
+ }
1775
+ const appendNewline = document.getElementById('send-crlf').checked;
1776
+ const localEcho = document.getElementById('local-echo').checked;
1777
+ rpc('send_data', { text, mode, appendNewline, localEcho }).then(res => {
1778
+ if (res && res.error) toast(res.error, 'error');
1779
+ }).catch(console.error);
1780
+ }
1781
+
1782
+ // ============================================================
1783
+ // File Editor
1784
+ // ============================================================
1785
+ function openFile(nodeId) {
1786
+ rpc('open_file', { nodeId }).then(res => {
1787
+ if (res.content !== undefined) {
1788
+ document.getElementById('file-editor-name').textContent = res.fileName;
1789
+ document.getElementById('file-editor-textarea').value = res.content;
1790
+ document.getElementById('file-editor-info').textContent = `${res.content.split('\n').length} 行 · ${res.content.length} 字节`;
1791
+ state.activeFilePath = res.activeFilePath; state.activeSessionId = null;
1792
+ updateWorkspaceVisibility();
1793
+ }
1794
+ }).catch(console.error);
1795
+ }
1796
+ function saveFile() {
1797
+ const content = document.getElementById('file-editor-textarea').value;
1798
+ rpc('save_file', { content }).then(() => {
1799
+ document.getElementById('file-editor-info').textContent = `${content.split('\n').length} 行 · ${content.length} 字节`;
1800
+ }).catch(console.error);
1801
+ }
1802
+ // File auto-save
1803
+ let fileSaveTimer;
1804
+ document.addEventListener('DOMContentLoaded', () => {
1805
+ const ta = document.getElementById('file-editor-textarea');
1806
+ if (ta) ta.addEventListener('input', () => { clearTimeout(fileSaveTimer); fileSaveTimer = setTimeout(() => { if (state.activeFilePath) saveFile(); }, 500); });
1807
+ });
1808
+
1809
+ // ============================================================
1810
+ // Sidebar Toggle
1811
+ // ============================================================
1812
+ function toggleSessionSidebar() {
1813
+ const visible = document.getElementById('session-sidebar').style.display !== 'none';
1814
+ document.getElementById('session-sidebar').style.display = visible ? 'none' : 'flex';
1815
+ document.getElementById('session-resizer').style.display = visible ? 'none' : 'block';
1816
+ document.getElementById('session-hidden-bar').style.display = visible ? 'flex' : 'none';
1817
+ rpc('update_config', { sessionPanelVisible: !visible }).catch(console.error);
1818
+ }
1819
+ function togglePresetSidebar() {
1820
+ const visible = document.getElementById('preset-panel').style.display !== 'none';
1821
+ document.getElementById('preset-panel').style.display = visible ? 'none' : 'flex';
1822
+ document.getElementById('preset-resizer').style.display = visible ? 'none' : 'block';
1823
+ document.getElementById('preset-hidden-bar').style.display = visible ? 'flex' : 'none';
1824
+ rpc('update_config', { presetPanelVisible: !visible }).catch(console.error);
1825
+ }
1826
+
1827
+ function initPanelResizer(resizerId, panelId, configKey, direction) {
1828
+ const resizer = document.getElementById(resizerId);
1829
+ const panel = document.getElementById(panelId);
1830
+ resizer.addEventListener('mousedown', (e) => {
1831
+ e.preventDefault();
1832
+ const startX = e.clientX;
1833
+ const startWidth = panel.getBoundingClientRect().width;
1834
+ resizer.classList.add('resizing');
1835
+ document.body.classList.add('resizing-panels');
1836
+
1837
+ const onMove = (ev) => {
1838
+ const delta = ev.clientX - startX;
1839
+ const width = direction === 'left' ? startWidth + delta : startWidth - delta;
1840
+ setPanelWidth(panelId, width);
1841
+ };
1842
+ const onUp = () => {
1843
+ resizer.classList.remove('resizing');
1844
+ document.body.classList.remove('resizing-panels');
1845
+ document.removeEventListener('mousemove', onMove);
1846
+ document.removeEventListener('mouseup', onUp);
1847
+ rpc('update_config', { [configKey]: Math.round(panel.getBoundingClientRect().width) }).catch(console.error);
1848
+ };
1849
+
1850
+ document.addEventListener('mousemove', onMove);
1851
+ document.addEventListener('mouseup', onUp);
1852
+ });
1853
+ }
1854
+
1855
+ initPanelResizer('session-resizer', 'session-sidebar', 'sessionPanelWidth', 'left');
1856
+ initPanelResizer('preset-resizer', 'preset-panel', 'presetPanelWidth', 'right');
1857
+
1858
+ // ============================================================
1859
+ // Event Listeners
1860
+ // ============================================================
1861
+ document.getElementById('device-select').addEventListener('change', function () {
1862
+ rpc('update_config', { lastDevicePath: this.value || null }).catch(console.error);
1863
+ updateConnectionUI();
1864
+ });
1865
+ ['baudrate-select', 'databits-select', 'parity-select', 'stopbits-select', 'flowctrl-select'].forEach(id => {
1866
+ document.getElementById(id).addEventListener('change', function () {
1867
+ const key = id.replace('-select', '').replace('baudrate', 'baudRate').replace('databits', 'dataBits').replace('stopbits', 'stopBits').replace('flowctrl', 'flowControl');
1868
+ rpc('update_config', { [key]: isNaN(this.value) ? this.value : parseInt(this.value) }).catch(console.error);
1869
+ });
1870
+ });
1871
+ document.getElementById('auto-connect').addEventListener('change', function () { rpc('update_config', { autoConnect: this.checked }).catch(console.error); });
1872
+ document.getElementById('send-crlf').addEventListener('change', function () { rpc('update_config', { sendAppendCRLF: this.checked }).catch(console.error); });
1873
+ document.getElementById('local-echo').addEventListener('change', function () { rpc('update_config', { localEcho: this.checked }).catch(console.error); });
1874
+ document.getElementById('receive-mixed').addEventListener('change', function () {
1875
+ rpc('update_config', { receiveShowMixed: this.checked }).then(() => { document.getElementById('receive-log').innerHTML = ''; renderLogs(); }).catch(console.error);
1876
+ });
1877
+ document.getElementById('auto-scroll').addEventListener('change', function () { rpc('update_config', { receiveAutoScroll: this.checked }).catch(console.error); });
1878
+ document.getElementById('show-timestamp').addEventListener('change', function () {
1879
+ rpc('update_config', { receiveShowTimestamp: this.checked }).then(() => { document.getElementById('receive-log').innerHTML = ''; renderLogs(); }).catch(console.error);
1880
+ });
1881
+ document.getElementById('send-input').addEventListener('input', debounce(function () {
1882
+ rpc('update_config', { sendInputText: this.value }).catch(console.error);
1883
+ }, 200));
1884
+ document.querySelectorAll('#receive-mode button').forEach(btn => {
1885
+ btn.addEventListener('click', function () {
1886
+ document.querySelectorAll('#receive-mode button').forEach(b => b.classList.remove('active'));
1887
+ this.classList.add('active');
1888
+ rpc('update_config', { receiveDisplayMode: this.dataset.mode }).then(() => { document.getElementById('receive-log').innerHTML = ''; renderLogs(); }).catch(console.error);
1889
+ });
1890
+ });
1891
+ document.querySelectorAll('#send-mode button').forEach(btn => {
1892
+ btn.addEventListener('click', function () {
1893
+ document.querySelectorAll('#send-mode button').forEach(b => b.classList.remove('active'));
1894
+ this.classList.add('active');
1895
+ rpc('update_config', { sendMode: this.dataset.mode }).catch(console.error);
1896
+ });
1897
+ });
1898
+
1899
+ // ============================================================
1900
+ // Keyboard Shortcuts
1901
+ // ============================================================
1902
+ document.addEventListener('keydown', function (e) {
1903
+ const mod = e.metaKey || e.ctrlKey;
1904
+ const shift = e.shiftKey;
1905
+ if (mod && !shift && e.key === 'o') { e.preventDefault(); if (!state.serialOpen) toggleConnect(); }
1906
+ if (mod && !shift && e.key === 'd') { e.preventDefault(); if (state.serialOpen) toggleConnect(); }
1907
+ if (mod && !shift && e.key === 'Enter') { e.preventDefault(); sendData(); }
1908
+ if (mod && !shift && e.key === 's') { e.preventDefault(); rpc('save_session').then(() => toast('已保存', 'success')).catch(console.error); }
1909
+ if (mod && shift && e.key === 'P') { e.preventDefault(); togglePresetSidebar(); }
1910
+ if (mod && shift && e.key === 'S') { e.preventDefault(); toggleSessionSidebar(); }
1911
+ if (mod && !shift && e.key >= '1' && e.key <= '9') {
1912
+ e.preventDefault();
1913
+ for (const g of state.presets || []) { const cmd = g.commands.find(c => c.hotkey === parseInt(e.key)); if (cmd) { sendPreset(cmd); break; } }
1914
+ }
1915
+ });
1916
+
1917
+ // ============================================================
1918
+ // Helpers
1919
+ // ============================================================
1920
+ function escHtml(s) { if (!s) return ''; return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
1921
+ function debounce(fn, ms) { let t; return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; }
1922
+
1923
+ // ============================================================
1924
+ // Startup
1925
+ // ============================================================
1926
+ connect();
1927
+ setInterval(refreshDevices, 3000);
1928
+ </script>
1929
+ </body>
1930
+
1931
+ </html>