@chenpu17/tcpmapping 2.1.1 → 2.4.1

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.
@@ -4,7 +4,77 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>TCP转发服务器 - 管理面板</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%233b82f6'/%3E%3Cpath d='M6 12h3l2-5 3 10 2-5h2' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
8
+ <script src="/common.js"></script>
7
9
  <style>
10
+ :root {
11
+ color-scheme: light;
12
+ --bg: #f3f5fb;
13
+ --bg-accent-1: rgba(99, 102, 241, 0.16);
14
+ --bg-accent-2: rgba(14, 165, 233, 0.12);
15
+ --surface: #ffffff;
16
+ --surface-muted: #f8fafc;
17
+ --surface-hover: #f1f5f9;
18
+ --text: #0f172a;
19
+ --text-muted: #64748b;
20
+ --text-faint: #94a3b8;
21
+ --border: #e2e8f0;
22
+ --border-strong: #cbd5e1;
23
+ --primary: #6366f1;
24
+ --primary-strong: #4f46e5;
25
+ --primary-soft: rgba(99, 102, 241, 0.12);
26
+ --green: #10b981;
27
+ --green-soft: rgba(16, 185, 129, 0.12);
28
+ --amber: #f59e0b;
29
+ --amber-soft: rgba(245, 158, 11, 0.14);
30
+ --red: #ef4444;
31
+ --red-soft: rgba(239, 68, 68, 0.12);
32
+ --sky: #0ea5e9;
33
+ --sky-soft: rgba(14, 165, 233, 0.12);
34
+ --violet: #8b5cf6;
35
+ --violet-soft: rgba(139, 92, 246, 0.12);
36
+ --header-bg: rgba(255, 255, 255, 0.82);
37
+ --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
38
+ --shadow-md: 0 12px 30px -12px rgba(15, 23, 42, 0.18);
39
+ --shadow-lg: 0 24px 60px -20px rgba(15, 23, 42, 0.25);
40
+ --radius: 18px;
41
+ --radius-sm: 12px;
42
+ --gradient: linear-gradient(135deg, #6366f1, #8b5cf6);
43
+ --mono: 'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace;
44
+ }
45
+
46
+ [data-theme="dark"] {
47
+ color-scheme: dark;
48
+ --bg: #0b1120;
49
+ --bg-accent-1: rgba(99, 102, 241, 0.22);
50
+ --bg-accent-2: rgba(14, 165, 233, 0.14);
51
+ --surface: #111827;
52
+ --surface-muted: #0f172a;
53
+ --surface-hover: #1e293b;
54
+ --text: #e2e8f0;
55
+ --text-muted: #94a3b8;
56
+ --text-faint: #64748b;
57
+ --border: #1e293b;
58
+ --border-strong: #334155;
59
+ --primary: #818cf8;
60
+ --primary-strong: #6366f1;
61
+ --primary-soft: rgba(129, 140, 248, 0.18);
62
+ --green: #34d399;
63
+ --green-soft: rgba(52, 211, 153, 0.15);
64
+ --amber: #fbbf24;
65
+ --amber-soft: rgba(251, 191, 36, 0.15);
66
+ --red: #f87171;
67
+ --red-soft: rgba(248, 113, 113, 0.15);
68
+ --sky: #38bdf8;
69
+ --sky-soft: rgba(56, 189, 248, 0.15);
70
+ --violet: #a78bfa;
71
+ --violet-soft: rgba(167, 139, 250, 0.15);
72
+ --header-bg: rgba(11, 17, 32, 0.8);
73
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
74
+ --shadow-md: 0 16px 40px -16px rgba(0, 0, 0, 0.6);
75
+ --shadow-lg: 0 30px 70px -24px rgba(0, 0, 0, 0.7);
76
+ }
77
+
8
78
  * {
9
79
  margin: 0;
10
80
  padding: 0;
@@ -12,260 +82,757 @@
12
82
  }
13
83
 
14
84
  body {
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
- background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
85
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
86
+ background:
87
+ radial-gradient(900px 520px at 8% -12%, var(--bg-accent-1), transparent 60%),
88
+ radial-gradient(900px 520px at 100% 0%, var(--bg-accent-2), transparent 55%),
89
+ var(--bg);
90
+ background-attachment: fixed;
91
+ color: var(--text);
17
92
  min-height: 100vh;
93
+ transition: background-color 0.25s ease, color 0.25s ease;
94
+ }
95
+
96
+ svg {
97
+ display: block;
18
98
  }
19
99
 
20
- .header {
21
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
22
- padding: 1rem 2rem;
23
- box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
100
+ .app-header {
101
+ position: sticky;
102
+ top: 0;
103
+ z-index: 50;
24
104
  display: flex;
105
+ align-items: center;
25
106
  justify-content: space-between;
107
+ gap: 1rem;
108
+ padding: 0.85rem 1.5rem;
109
+ background: var(--header-bg);
110
+ backdrop-filter: blur(16px);
111
+ -webkit-backdrop-filter: blur(16px);
112
+ border-bottom: 1px solid var(--border);
113
+ }
114
+
115
+ .brand {
116
+ display: flex;
26
117
  align-items: center;
118
+ gap: 0.8rem;
119
+ min-width: 0;
27
120
  }
28
121
 
29
- .header h1 {
30
- color: white;
31
- font-size: 22px;
32
- font-weight: 600;
33
- text-shadow: 0 2px 4px rgba(0,0,0,0.1);
122
+ .brand-logo {
123
+ width: 42px;
124
+ height: 42px;
125
+ border-radius: 13px;
126
+ background: var(--gradient);
127
+ display: grid;
128
+ place-items: center;
129
+ color: #ffffff;
130
+ box-shadow: 0 8px 20px -6px rgba(99, 102, 241, 0.6);
131
+ flex-shrink: 0;
132
+ }
133
+
134
+ .brand-logo svg {
135
+ width: 22px;
136
+ height: 22px;
137
+ }
138
+
139
+ .brand-text h1 {
140
+ font-size: 1rem;
141
+ font-weight: 700;
142
+ letter-spacing: -0.01em;
143
+ white-space: nowrap;
144
+ overflow: hidden;
145
+ text-overflow: ellipsis;
146
+ }
147
+
148
+ .brand-text p {
149
+ font-size: 0.75rem;
150
+ color: var(--text-muted);
151
+ margin-top: 0.1rem;
34
152
  }
35
153
 
36
154
  .header-actions {
37
155
  display: flex;
38
- gap: 1rem;
156
+ align-items: center;
157
+ gap: 0.6rem;
158
+ flex-wrap: wrap;
159
+ justify-content: flex-end;
160
+ }
161
+
162
+ .icon-btn {
163
+ width: 36px;
164
+ height: 36px;
165
+ display: grid;
166
+ place-items: center;
167
+ border: 1px solid var(--border);
168
+ border-radius: 10px;
169
+ background: var(--surface);
170
+ color: var(--text-muted);
171
+ cursor: pointer;
172
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
173
+ }
174
+
175
+ .icon-btn:hover {
176
+ background: var(--surface-hover);
177
+ color: var(--text);
178
+ border-color: var(--border-strong);
179
+ }
180
+
181
+ .icon-btn:focus-visible {
182
+ outline: 2px solid var(--primary);
183
+ outline-offset: 2px;
184
+ }
185
+
186
+ .icon-btn svg {
187
+ width: 18px;
188
+ height: 18px;
189
+ }
190
+
191
+ .icon-btn .icon-sun {
192
+ display: none;
193
+ }
194
+
195
+ [data-theme="dark"] .icon-btn .icon-moon {
196
+ display: none;
197
+ }
198
+
199
+ [data-theme="dark"] .icon-btn .icon-sun {
200
+ display: block;
201
+ }
202
+
203
+ .select-wrap {
204
+ position: relative;
205
+ display: flex;
39
206
  align-items: center;
40
207
  }
41
208
 
42
- .header-actions span {
43
- color: rgba(255,255,255,0.9);
44
- font-size: 14px;
209
+ .select-wrap svg {
210
+ position: absolute;
211
+ left: 0.6rem;
212
+ width: 15px;
213
+ height: 15px;
214
+ color: var(--text-muted);
215
+ pointer-events: none;
216
+ }
217
+
218
+ .select-wrap select {
219
+ appearance: none;
220
+ -webkit-appearance: none;
221
+ padding: 0.5rem 1.9rem 0.5rem 1.9rem;
222
+ border: 1px solid var(--border);
223
+ border-radius: 10px;
224
+ background: var(--surface);
225
+ color: var(--text);
226
+ font-size: 0.8rem;
227
+ font-weight: 500;
228
+ cursor: pointer;
229
+ transition: border-color 0.2s ease;
230
+ }
231
+
232
+ .select-wrap select:hover {
233
+ border-color: var(--border-strong);
234
+ }
235
+
236
+ .select-wrap select:focus-visible {
237
+ outline: 2px solid var(--primary);
238
+ outline-offset: 2px;
239
+ }
240
+
241
+ .select-wrap::after {
242
+ content: '';
243
+ position: absolute;
244
+ right: 0.7rem;
245
+ width: 6px;
246
+ height: 6px;
247
+ border-right: 1.5px solid var(--text-muted);
248
+ border-bottom: 1.5px solid var(--text-muted);
249
+ transform: rotate(45deg) translateY(-1px);
250
+ pointer-events: none;
251
+ }
252
+
253
+ .user-chip {
254
+ display: flex;
255
+ align-items: center;
256
+ gap: 0.5rem;
257
+ padding: 0.25rem 0.7rem 0.25rem 0.3rem;
258
+ border: 1px solid var(--border);
259
+ border-radius: 999px;
260
+ background: var(--surface);
261
+ font-size: 0.8rem;
262
+ color: var(--text-muted);
263
+ max-width: 220px;
264
+ }
265
+
266
+ .user-chip .avatar {
267
+ width: 28px;
268
+ height: 28px;
269
+ border-radius: 50%;
270
+ background: var(--gradient);
271
+ color: #ffffff;
272
+ display: grid;
273
+ place-items: center;
274
+ font-size: 0.75rem;
275
+ font-weight: 700;
276
+ flex-shrink: 0;
277
+ }
278
+
279
+ .user-chip #welcomeText {
280
+ overflow: hidden;
281
+ text-overflow: ellipsis;
282
+ white-space: nowrap;
45
283
  }
46
284
 
47
285
  .btn {
48
- padding: 0.6rem 1.2rem;
286
+ padding: 0.55rem 1rem;
49
287
  border: none;
50
- border-radius: 6px;
288
+ border-radius: 10px;
51
289
  cursor: pointer;
52
- font-size: 14px;
53
- font-weight: 500;
54
- transition: all 0.3s ease;
290
+ font-size: 0.8rem;
291
+ font-weight: 600;
292
+ font-family: inherit;
293
+ transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease, background 0.2s ease;
55
294
  display: inline-flex;
56
295
  align-items: center;
57
- gap: 6px;
296
+ justify-content: center;
297
+ gap: 0.4rem;
298
+ white-space: nowrap;
58
299
  }
59
300
 
60
- .btn-primary {
61
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
62
- color: white;
63
- box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
301
+ .btn:focus-visible {
302
+ outline: 2px solid var(--primary);
303
+ outline-offset: 2px;
64
304
  }
65
305
 
66
- .btn-primary:hover {
67
- transform: translateY(-2px);
68
- box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
306
+ .btn:active {
307
+ transform: translateY(1px);
69
308
  }
70
309
 
71
- .btn-danger {
72
- background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
73
- color: white;
74
- box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
310
+ .btn svg {
311
+ width: 15px;
312
+ height: 15px;
75
313
  }
76
314
 
77
- .btn-danger:hover {
78
- transform: translateY(-2px);
79
- box-shadow: 0 6px 20px rgba(255, 107, 107, 0.4);
315
+ .btn-primary {
316
+ background: var(--gradient);
317
+ color: #ffffff;
318
+ box-shadow: 0 8px 18px -8px rgba(99, 102, 241, 0.7);
319
+ }
320
+
321
+ .btn-primary:hover {
322
+ filter: brightness(1.07);
80
323
  }
81
324
 
82
325
  .btn-success {
83
- background: linear-gradient(135deg, #2ed573 0%, #26de81 100%);
84
- color: white;
85
- box-shadow: 0 4px 15px rgba(46, 213, 115, 0.3);
326
+ background: var(--green);
327
+ color: #ffffff;
328
+ box-shadow: 0 8px 18px -10px var(--green);
86
329
  }
87
330
 
88
331
  .btn-success:hover {
89
- transform: translateY(-2px);
90
- box-shadow: 0 6px 20px rgba(46, 213, 115, 0.4);
332
+ filter: brightness(1.07);
91
333
  }
92
334
 
93
335
  .btn-warning {
94
- background: linear-gradient(135deg, #ffa502 0%, #ff9f43 100%);
95
- color: white;
96
- box-shadow: 0 4px 15px rgba(255, 165, 2, 0.3);
336
+ background: var(--amber);
337
+ color: #1f2937;
338
+ box-shadow: 0 8px 18px -10px var(--amber);
97
339
  }
98
340
 
99
341
  .btn-warning:hover {
100
- transform: translateY(-2px);
101
- box-shadow: 0 6px 20px rgba(255, 165, 2, 0.4);
342
+ filter: brightness(1.05);
343
+ }
344
+
345
+ .btn-danger {
346
+ background: var(--red);
347
+ color: #ffffff;
348
+ box-shadow: 0 8px 18px -10px var(--red);
349
+ }
350
+
351
+ .btn-danger:hover {
352
+ filter: brightness(1.07);
353
+ }
354
+
355
+ .btn-cancel {
356
+ background: var(--surface-muted);
357
+ color: var(--text-muted);
358
+ border: 1px solid var(--border);
359
+ }
360
+
361
+ .btn-cancel:hover {
362
+ background: var(--surface-hover);
363
+ color: var(--text);
102
364
  }
103
365
 
104
366
  .btn-sm {
105
- padding: 0.4rem 0.8rem;
106
- font-size: 12px;
367
+ padding: 0.35rem 0.65rem;
368
+ font-size: 0.72rem;
369
+ border-radius: 8px;
107
370
  }
108
371
 
109
372
  .container {
110
- max-width: 1300px;
373
+ max-width: 1320px;
111
374
  margin: 0 auto;
112
- padding: 2rem;
375
+ padding: 1.75rem 1.5rem 3rem;
113
376
  }
114
377
 
115
378
  .card {
116
- background: white;
117
- border-radius: 16px;
118
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08);
119
- margin-bottom: 2rem;
379
+ background: var(--surface);
380
+ border: 1px solid var(--border);
381
+ border-radius: var(--radius);
382
+ box-shadow: var(--shadow-sm);
383
+ margin-bottom: 1.5rem;
120
384
  overflow: hidden;
385
+ transition: box-shadow 0.25s ease, border-color 0.25s ease;
386
+ }
387
+
388
+ .card:hover {
389
+ box-shadow: var(--shadow-md);
121
390
  }
122
391
 
123
392
  .card-header {
124
- padding: 1.5rem 2rem;
125
- border-bottom: 1px solid #f0f0f0;
393
+ padding: 1.1rem 1.5rem;
394
+ border-bottom: 1px solid var(--border);
126
395
  display: flex;
127
396
  justify-content: space-between;
128
397
  align-items: center;
129
- background: linear-gradient(135deg, #fafbfc 0%, #f8f9fa 100%);
398
+ gap: 1rem;
399
+ background: var(--surface-muted);
130
400
  }
131
401
 
132
402
  .card-header h2 {
133
- color: #2d3436;
134
- font-size: 18px;
135
- font-weight: 600;
403
+ color: var(--text);
404
+ font-size: 0.95rem;
405
+ font-weight: 650;
406
+ letter-spacing: 0.01em;
136
407
  }
137
408
 
138
409
  .card-body {
139
- padding: 1.5rem 2rem;
410
+ padding: 1.5rem;
140
411
  }
141
412
 
142
413
  .stats-grid {
143
414
  display: grid;
144
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
145
- gap: 1.5rem;
146
- margin-bottom: 0;
415
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
416
+ gap: 1rem;
147
417
  }
148
418
 
149
419
  .stat-card {
150
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
151
- color: white;
152
- padding: 1.8rem;
153
- border-radius: 16px;
154
- text-align: center;
155
- box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
156
- transition: transform 0.3s ease;
420
+ position: relative;
421
+ background: var(--surface-muted);
422
+ border: 1px solid var(--border);
423
+ border-radius: var(--radius-sm);
424
+ padding: 1.15rem 1.2rem 1.15rem 1.4rem;
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 0.9rem;
428
+ overflow: hidden;
429
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
157
430
  }
158
431
 
159
- .stat-card:hover {
160
- transform: translateY(-5px);
432
+ .stat-card::before {
433
+ content: '';
434
+ position: absolute;
435
+ left: 0;
436
+ top: 0;
437
+ bottom: 0;
438
+ width: 4px;
439
+ background: var(--accent);
161
440
  }
162
441
 
163
- .stat-card:nth-child(2) {
164
- background: linear-gradient(135deg, #2ed573 0%, #26de81 100%);
165
- box-shadow: 0 10px 30px rgba(46, 213, 115, 0.3);
442
+ .stat-card:hover {
443
+ transform: translateY(-2px);
444
+ box-shadow: var(--shadow-md);
445
+ border-color: var(--border-strong);
166
446
  }
167
447
 
168
- .stat-card:nth-child(3) {
169
- background: linear-gradient(135deg, #ffa502 0%, #ff9f43 100%);
170
- box-shadow: 0 10px 30px rgba(255, 165, 2, 0.3);
448
+ .stat-icon {
449
+ width: 44px;
450
+ height: 44px;
451
+ border-radius: 12px;
452
+ display: grid;
453
+ place-items: center;
454
+ background: var(--accent-soft);
455
+ color: var(--accent);
456
+ flex-shrink: 0;
171
457
  }
172
458
 
173
- .stat-card:nth-child(4) {
174
- background: linear-gradient(135deg, #45aaf2 0%, #2d98da 100%);
175
- box-shadow: 0 10px 30px rgba(69, 170, 242, 0.3);
459
+ .stat-icon svg {
460
+ width: 22px;
461
+ height: 22px;
176
462
  }
177
463
 
178
464
  .stat-card h3 {
179
- font-size: 36px;
180
- margin-bottom: 0.5rem;
465
+ font-size: 1.45rem;
181
466
  font-weight: 700;
467
+ line-height: 1.15;
468
+ color: var(--text);
469
+ }
470
+
471
+ .stat-card h3.small {
472
+ font-size: 1.02rem;
473
+ display: flex;
474
+ flex-wrap: wrap;
475
+ align-items: baseline;
476
+ column-gap: 0.5rem;
477
+ row-gap: 0.1rem;
478
+ }
479
+
480
+ .stat-card h3.small span {
481
+ white-space: nowrap;
182
482
  }
183
483
 
184
484
  .stat-card p {
185
- font-size: 14px;
186
- opacity: 0.9;
485
+ font-size: 0.76rem;
486
+ color: var(--text-muted);
487
+ margin-top: 0.2rem;
187
488
  font-weight: 500;
188
489
  }
189
490
 
491
+ .stat-card--indigo { --accent: var(--primary); --accent-soft: var(--primary-soft); }
492
+ .stat-card--green { --accent: var(--green); --accent-soft: var(--green-soft); }
493
+ .stat-card--amber { --accent: var(--amber); --accent-soft: var(--amber-soft); }
494
+ .stat-card--sky { --accent: var(--sky); --accent-soft: var(--sky-soft); }
495
+ .stat-card--violet { --accent: var(--violet); --accent-soft: var(--violet-soft); }
496
+
497
+ /* 服务资源 */
498
+ .resource-sampled {
499
+ display: inline-flex;
500
+ align-items: center;
501
+ gap: 0.45rem;
502
+ font-size: 0.72rem;
503
+ color: var(--text-muted);
504
+ font-variant-numeric: tabular-nums;
505
+ }
506
+
507
+ .live-dot {
508
+ width: 7px;
509
+ height: 7px;
510
+ border-radius: 50%;
511
+ background: var(--green);
512
+ box-shadow: 0 0 0 4px var(--green-soft);
513
+ animation: livePulse 2s ease-in-out infinite;
514
+ }
515
+
516
+ @keyframes livePulse {
517
+ 0%, 100% { opacity: 1; }
518
+ 50% { opacity: 0.45; }
519
+ }
520
+
521
+ .resource-grid {
522
+ display: grid;
523
+ grid-template-columns: repeat(3, minmax(0, 1fr));
524
+ gap: 1rem;
525
+ }
526
+
527
+ .resource-card {
528
+ background: var(--surface-muted);
529
+ border: 1px solid var(--border);
530
+ border-radius: var(--radius-sm);
531
+ padding: 1rem 1.1rem;
532
+ }
533
+
534
+ .resource-card-head {
535
+ display: flex;
536
+ justify-content: space-between;
537
+ align-items: baseline;
538
+ gap: 0.75rem;
539
+ margin-bottom: 0.7rem;
540
+ }
541
+
542
+ .resource-label {
543
+ font-size: 0.78rem;
544
+ font-weight: 600;
545
+ color: var(--text-muted);
546
+ }
547
+
548
+ .resource-value {
549
+ font-size: 1.3rem;
550
+ font-weight: 700;
551
+ color: var(--text);
552
+ font-variant-numeric: tabular-nums;
553
+ }
554
+
555
+ .resource-track {
556
+ height: 8px;
557
+ border-radius: 999px;
558
+ background: var(--border);
559
+ overflow: hidden;
560
+ }
561
+
562
+ .resource-fill {
563
+ display: block;
564
+ width: 0;
565
+ height: 100%;
566
+ border-radius: inherit;
567
+ background: var(--primary);
568
+ transition: width 0.4s ease;
569
+ }
570
+
571
+ .resource-fill--memory {
572
+ background: var(--green);
573
+ }
574
+
575
+ .resource-hint {
576
+ margin-top: 0.6rem;
577
+ font-size: 0.72rem;
578
+ color: var(--text-muted);
579
+ font-variant-numeric: tabular-nums;
580
+ }
581
+
582
+ .resource-traffic {
583
+ display: flex;
584
+ flex-wrap: wrap;
585
+ align-items: baseline;
586
+ gap: 0.4rem 1.4rem;
587
+ font-family: var(--mono);
588
+ font-size: 1.02rem;
589
+ font-weight: 700;
590
+ font-variant-numeric: tabular-nums;
591
+ }
592
+
593
+ .resource-traffic .up { color: var(--green); }
594
+ .resource-traffic .down { color: var(--sky); }
595
+
596
+ .table-wrap {
597
+ overflow-x: auto;
598
+ border-radius: var(--radius-sm);
599
+ border: 1px solid var(--border);
600
+ }
601
+
190
602
  .table {
191
603
  width: 100%;
192
604
  border-collapse: collapse;
605
+ min-width: 760px;
193
606
  }
194
607
 
195
608
  .table th,
196
609
  .table td {
197
- padding: 1rem;
610
+ padding: 0.85rem 1rem;
198
611
  text-align: left;
199
- border-bottom: 1px solid #f0f0f0;
200
612
  }
201
613
 
202
614
  .table th {
203
- background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%);
615
+ background: var(--surface-muted);
204
616
  font-weight: 600;
205
- color: #2d3436;
206
- font-size: 13px;
617
+ color: var(--text-muted);
618
+ font-size: 0.7rem;
207
619
  text-transform: uppercase;
208
- letter-spacing: 0.5px;
620
+ letter-spacing: 0.07em;
621
+ white-space: nowrap;
622
+ border-bottom: 1px solid var(--border);
209
623
  }
210
624
 
211
- .table tr:hover {
212
- background-color: #fafbfc;
625
+ .table td {
626
+ color: var(--text-muted);
627
+ font-size: 0.85rem;
628
+ border-bottom: 1px solid var(--border);
629
+ vertical-align: middle;
213
630
  }
214
631
 
215
- .table td {
216
- color: #636e72;
217
- font-size: 14px;
632
+ .table tbody tr:last-child td {
633
+ border-bottom: none;
634
+ }
635
+
636
+ .table tbody tr {
637
+ transition: background 0.15s ease;
638
+ }
639
+
640
+ .table tbody tr:hover td {
641
+ background: var(--surface-hover);
642
+ }
643
+
644
+ .table strong {
645
+ color: var(--text);
646
+ font-weight: 600;
647
+ }
648
+
649
+ .table code {
650
+ font-family: var(--mono);
651
+ font-size: 0.78rem;
652
+ color: var(--text);
653
+ background: var(--surface-muted);
654
+ border: 1px solid var(--border);
655
+ padding: 0.15rem 0.4rem;
656
+ border-radius: 6px;
657
+ white-space: nowrap;
218
658
  }
219
659
 
220
660
  .status-badge {
221
- padding: 0.35rem 0.75rem;
222
- border-radius: 20px;
223
- font-size: 12px;
661
+ padding: 0.25rem 0.6rem;
662
+ border-radius: 999px;
663
+ font-size: 0.7rem;
224
664
  font-weight: 600;
225
- display: inline-block;
665
+ display: inline-flex;
666
+ align-items: center;
667
+ gap: 0.3rem;
668
+ white-space: nowrap;
669
+ }
670
+
671
+ .status-badge::before {
672
+ content: '';
673
+ width: 6px;
674
+ height: 6px;
675
+ border-radius: 50%;
676
+ background: currentColor;
226
677
  }
227
678
 
228
679
  .status-running {
229
- background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
230
- color: #155724;
680
+ background: var(--green-soft);
681
+ color: var(--green);
231
682
  }
232
683
 
233
684
  .status-stopped {
234
- background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
235
- color: #721c24;
685
+ background: var(--red-soft);
686
+ color: var(--red);
236
687
  }
237
688
 
238
689
  .status-connected {
239
- background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%);
240
- color: #0c5460;
690
+ background: var(--sky-soft);
691
+ color: var(--sky);
241
692
  }
242
693
 
243
694
  .traffic-info {
244
- font-size: 12px;
245
- color: #636e72;
246
- line-height: 1.6;
695
+ font-size: 0.75rem;
696
+ color: var(--text-muted);
697
+ line-height: 1.7;
698
+ white-space: nowrap;
247
699
  }
248
700
 
249
701
  .traffic-info .up {
250
- color: #2ed573;
702
+ color: var(--green);
251
703
  }
252
704
 
253
705
  .traffic-info .down {
254
- color: #45aaf2;
706
+ color: var(--sky);
707
+ }
708
+
709
+ .traffic-info .active-traffic {
710
+ color: var(--text-muted);
711
+ font-size: 0.7rem;
712
+ }
713
+
714
+ .action-buttons {
715
+ display: flex;
716
+ gap: 0.35rem;
717
+ flex-wrap: wrap;
718
+ }
719
+
720
+ .connection-card {
721
+ margin-bottom: 0.9rem;
722
+ padding: 1.1rem 1.2rem;
723
+ border: 1px solid var(--border);
724
+ border-radius: 14px;
725
+ background: var(--surface-muted);
726
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
727
+ }
728
+
729
+ .connection-card:last-child {
730
+ margin-bottom: 0;
731
+ }
732
+
733
+ .connection-card:hover {
734
+ border-color: var(--border-strong);
735
+ box-shadow: var(--shadow-sm);
736
+ }
737
+
738
+ .connection-card h4 {
739
+ color: var(--text);
740
+ font-size: 0.9rem;
741
+ font-weight: 650;
742
+ margin-bottom: 0.55rem;
743
+ display: flex;
744
+ align-items: center;
745
+ gap: 0.5rem;
746
+ flex-wrap: wrap;
747
+ }
748
+
749
+ .port-badge {
750
+ background: var(--gradient);
751
+ color: #ffffff;
752
+ padding: 0.2rem 0.55rem;
753
+ border-radius: 7px;
754
+ font-size: 0.72rem;
755
+ font-family: var(--mono);
756
+ font-weight: 500;
757
+ }
758
+
759
+ .connection-meta {
760
+ display: flex;
761
+ align-items: center;
762
+ gap: 0.5rem;
763
+ flex-wrap: wrap;
764
+ color: var(--text-muted);
765
+ font-size: 0.78rem;
766
+ margin-bottom: 0.6rem;
767
+ }
768
+
769
+ .connection-meta code {
770
+ font-family: var(--mono);
771
+ font-size: 0.72rem;
772
+ color: var(--text);
773
+ background: var(--surface);
774
+ border: 1px solid var(--border);
775
+ padding: 0.1rem 0.35rem;
776
+ border-radius: 5px;
777
+ }
778
+
779
+ .connection-item {
780
+ display: grid;
781
+ grid-template-columns: auto 1fr auto;
782
+ align-items: center;
783
+ gap: 0.75rem;
784
+ padding: 0.55rem 0;
785
+ border-bottom: 1px dashed var(--border);
786
+ font-size: 0.78rem;
787
+ }
788
+
789
+ .connection-item:last-child {
790
+ border-bottom: none;
791
+ }
792
+
793
+ .conn-endpoints {
794
+ color: var(--text);
795
+ font-family: var(--mono);
796
+ font-size: 0.75rem;
797
+ overflow-wrap: anywhere;
798
+ }
799
+
800
+ .conn-metrics {
801
+ color: var(--text-muted);
802
+ text-align: right;
803
+ white-space: nowrap;
804
+ }
805
+
806
+ .empty-state {
807
+ text-align: center;
808
+ padding: 2.5rem 1rem;
809
+ color: var(--text-muted);
810
+ font-size: 0.85rem;
811
+ }
812
+
813
+ .empty-state svg {
814
+ width: 52px;
815
+ height: 52px;
816
+ margin: 0 auto 0.9rem;
817
+ opacity: 0.45;
818
+ }
819
+
820
+ .history-hint {
821
+ color: var(--text-muted);
822
+ font-size: 0.78rem;
823
+ margin-bottom: 0.9rem;
255
824
  }
256
825
 
257
- /* Modal Styles */
258
826
  .modal {
259
827
  display: none;
260
828
  position: fixed;
261
- top: 0;
262
- left: 0;
263
- width: 100%;
264
- height: 100%;
265
- background: rgba(0, 0, 0, 0.6);
266
- backdrop-filter: blur(4px);
829
+ inset: 0;
830
+ background: rgba(2, 6, 23, 0.55);
831
+ backdrop-filter: blur(6px);
832
+ -webkit-backdrop-filter: blur(6px);
267
833
  z-index: 1000;
268
- animation: fadeIn 0.3s ease;
834
+ animation: fadeIn 0.2s ease;
835
+ overflow-y: auto;
269
836
  }
270
837
 
271
838
  @keyframes fadeIn {
@@ -276,7 +843,7 @@
276
843
  @keyframes slideIn {
277
844
  from {
278
845
  opacity: 0;
279
- transform: translateY(-30px) scale(0.95);
846
+ transform: translateY(-18px) scale(0.97);
280
847
  }
281
848
  to {
282
849
  opacity: 1;
@@ -285,14 +852,15 @@
285
852
  }
286
853
 
287
854
  .modal-content {
288
- background: white;
289
- margin: 8% auto;
290
- padding: 0;
855
+ background: var(--surface);
856
+ color: var(--text);
857
+ margin: 7% auto;
858
+ border: 1px solid var(--border);
291
859
  border-radius: 20px;
292
- max-width: 480px;
293
- width: 90%;
294
- box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
295
- animation: slideIn 0.3s ease;
860
+ max-width: 520px;
861
+ width: calc(100% - 2rem);
862
+ box-shadow: var(--shadow-lg);
863
+ animation: slideIn 0.24s ease;
296
864
  overflow: hidden;
297
865
  }
298
866
 
@@ -300,320 +868,623 @@
300
868
  display: flex;
301
869
  justify-content: space-between;
302
870
  align-items: center;
303
- padding: 1.5rem 2rem;
304
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
305
- color: white;
871
+ padding: 1.2rem 1.5rem;
872
+ border-bottom: 1px solid var(--border);
873
+ background: var(--surface-muted);
306
874
  }
307
875
 
308
876
  .modal-header h3 {
309
- font-size: 18px;
310
- font-weight: 600;
877
+ font-size: 1rem;
878
+ font-weight: 650;
879
+ color: var(--text);
311
880
  }
312
881
 
313
882
  .close {
314
- font-size: 28px;
883
+ width: 32px;
884
+ height: 32px;
885
+ display: grid;
886
+ place-items: center;
887
+ font-size: 1.4rem;
315
888
  cursor: pointer;
316
- color: rgba(255,255,255,0.8);
317
- transition: color 0.2s;
889
+ color: var(--text-muted);
890
+ border: none;
891
+ background: transparent;
892
+ font-family: inherit;
893
+ border-radius: 8px;
894
+ transition: background 0.2s ease, color 0.2s ease;
318
895
  line-height: 1;
319
896
  }
320
897
 
321
898
  .close:hover {
322
- color: white;
899
+ background: var(--surface-hover);
900
+ color: var(--text);
323
901
  }
324
902
 
325
903
  .modal-body {
326
- padding: 2rem;
904
+ padding: 1.5rem;
327
905
  }
328
906
 
329
907
  .form-group {
330
- margin-bottom: 1.5rem;
908
+ margin-bottom: 1.1rem;
909
+ }
910
+
911
+ .form-group:last-of-type {
912
+ margin-bottom: 0;
331
913
  }
332
914
 
333
915
  .form-group label {
334
916
  display: block;
335
- margin-bottom: 0.5rem;
336
- color: #2d3436;
917
+ margin-bottom: 0.4rem;
918
+ color: var(--text);
337
919
  font-weight: 600;
338
- font-size: 14px;
920
+ font-size: 0.8rem;
339
921
  }
340
922
 
341
923
  .form-group input {
342
924
  width: 100%;
343
- padding: 0.85rem 1rem;
344
- border: 2px solid #e9ecef;
925
+ padding: 0.7rem 0.85rem;
926
+ border: 1.5px solid var(--border);
345
927
  border-radius: 10px;
346
- font-size: 14px;
347
- transition: all 0.3s ease;
928
+ font-size: 0.85rem;
929
+ font-family: inherit;
930
+ background: var(--surface-muted);
931
+ color: var(--text);
932
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
348
933
  }
349
934
 
350
935
  .form-group input:focus {
351
936
  outline: none;
352
- border-color: #667eea;
353
- box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
937
+ border-color: var(--primary);
938
+ background: var(--surface);
939
+ box-shadow: 0 0 0 4px var(--primary-soft);
354
940
  }
355
941
 
356
942
  .form-group input::placeholder {
357
- color: #b2bec3;
943
+ color: var(--text-muted);
358
944
  }
359
945
 
360
- .modal-footer {
361
- padding: 0 2rem 2rem 2rem;
362
- display: flex;
363
- gap: 1rem;
946
+ .form-row {
947
+ display: grid;
948
+ grid-template-columns: 2fr 1fr;
949
+ gap: 0.75rem;
364
950
  }
365
951
 
366
- .modal-footer .btn {
367
- flex: 1;
368
- justify-content: center;
369
- padding: 0.85rem 1.5rem;
952
+ .form-row .form-group {
953
+ margin-bottom: 1.1rem;
370
954
  }
371
955
 
372
- .btn-cancel {
373
- background: #f1f3f4;
374
- color: #636e72;
956
+ .modal-footer {
957
+ padding: 0 1.5rem 1.5rem;
958
+ display: flex;
959
+ gap: 0.75rem;
375
960
  }
376
961
 
377
- .btn-cancel:hover {
378
- background: #e9ecef;
962
+ .modal-footer .btn {
963
+ flex: 1;
964
+ padding: 0.75rem 1.25rem;
379
965
  }
380
966
 
381
967
  .error-message {
382
- color: #ee5a5a;
383
- font-size: 14px;
968
+ color: var(--red);
969
+ font-size: 0.8rem;
384
970
  margin-top: 1rem;
385
- padding: 0.75rem 1rem;
386
- background: #fff5f5;
971
+ padding: 0.7rem 0.9rem;
972
+ background: var(--red-soft);
387
973
  border-radius: 8px;
388
974
  display: none;
389
975
  }
390
976
 
391
977
  .success-message {
392
- color: #2ed573;
393
- font-size: 14px;
978
+ color: var(--green);
979
+ font-size: 0.8rem;
394
980
  margin-top: 1rem;
395
- padding: 0.75rem 1rem;
396
- background: #f0fff4;
981
+ padding: 0.7rem 0.9rem;
982
+ background: var(--green-soft);
397
983
  border-radius: 8px;
398
984
  display: none;
399
985
  }
400
986
 
401
- .connection-card {
402
- margin-bottom: 1rem;
403
- padding: 1.25rem;
404
- border: 1px solid #e9ecef;
405
- border-radius: 12px;
406
- background: linear-gradient(135deg, #fafbfc 0%, #f8f9fa 100%);
407
- transition: all 0.3s ease;
408
- }
409
-
410
- .connection-card:hover {
411
- border-color: #667eea;
412
- box-shadow: 0 5px 20px rgba(102, 126, 234, 0.1);
413
- }
414
-
415
- .connection-card h4 {
416
- color: #2d3436;
417
- font-size: 15px;
418
- margin-bottom: 0.75rem;
419
- display: flex;
420
- align-items: center;
421
- gap: 8px;
422
- }
423
-
424
- .connection-card h4 .port-badge {
425
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
426
- color: white;
427
- padding: 0.25rem 0.6rem;
428
- border-radius: 6px;
429
- font-size: 12px;
430
- }
431
-
432
- .connection-details {
433
- font-size: 13px;
434
- color: #636e72;
987
+ .toast-container {
988
+ position: fixed;
989
+ right: 1.25rem;
990
+ bottom: 1.25rem;
991
+ display: flex;
992
+ flex-direction: column;
993
+ gap: 0.5rem;
994
+ z-index: 2000;
995
+ max-width: min(360px, calc(100vw - 2.5rem));
435
996
  }
436
997
 
437
- .connection-item {
438
- padding: 0.5rem 0;
439
- border-bottom: 1px dashed #e9ecef;
440
- display: flex;
441
- align-items: center;
442
- gap: 8px;
998
+ .toast {
999
+ background: var(--surface);
1000
+ color: var(--text);
1001
+ border: 1px solid var(--border);
1002
+ border-left: 4px solid var(--primary);
1003
+ padding: 0.75rem 1rem;
1004
+ border-radius: 10px;
1005
+ box-shadow: var(--shadow-lg);
1006
+ font-size: 0.82rem;
1007
+ opacity: 0;
1008
+ transform: translateY(8px);
1009
+ transition: opacity 0.2s ease, transform 0.2s ease;
1010
+ overflow-wrap: anywhere;
443
1011
  }
444
1012
 
445
- .connection-item:last-child {
446
- border-bottom: none;
1013
+ .toast.show {
1014
+ opacity: 1;
1015
+ transform: translateY(0);
447
1016
  }
448
1017
 
449
- .no-connections {
450
- text-align: center;
451
- padding: 3rem;
452
- color: #b2bec3;
1018
+ .toast-error {
1019
+ border-left-color: var(--red);
453
1020
  }
454
1021
 
455
- .no-connections svg {
456
- width: 60px;
457
- height: 60px;
458
- margin-bottom: 1rem;
459
- opacity: 0.5;
1022
+ .toast-success {
1023
+ border-left-color: var(--green);
460
1024
  }
461
1025
 
462
- .action-buttons {
463
- display: flex;
464
- gap: 8px;
1026
+ @media (prefers-reduced-motion: reduce) {
1027
+ *,
1028
+ *::before,
1029
+ *::after {
1030
+ animation-duration: 0.01ms !important;
1031
+ transition-duration: 0.01ms !important;
1032
+ }
465
1033
  }
466
1034
 
467
- @media (max-width: 768px) {
468
- .header {
469
- flex-direction: column;
470
- gap: 1rem;
471
- text-align: center;
1035
+ @media (max-width: 900px) {
1036
+ .app-header {
1037
+ flex-wrap: wrap;
1038
+ padding: 0.8rem 1rem;
472
1039
  }
473
1040
 
474
1041
  .container {
475
- padding: 1rem;
1042
+ padding: 1.25rem 1rem 2.5rem;
1043
+ }
1044
+
1045
+ .stats-grid {
1046
+ grid-template-columns: repeat(2, 1fr);
1047
+ }
1048
+
1049
+ .resource-grid {
1050
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1051
+ }
1052
+
1053
+ .resource-card:last-child {
1054
+ grid-column: 1 / -1;
476
1055
  }
477
1056
 
478
1057
  .card-header {
479
- flex-direction: column;
480
- gap: 1rem;
1058
+ flex-wrap: wrap;
1059
+ }
1060
+ }
1061
+
1062
+ @media (max-width: 640px) {
1063
+ .brand-text p {
1064
+ display: none;
481
1065
  }
482
1066
 
483
- .table {
484
- display: block;
485
- overflow-x: auto;
1067
+ .user-chip {
1068
+ display: none;
486
1069
  }
487
1070
 
488
1071
  .stats-grid {
489
- grid-template-columns: repeat(2, 1fr);
1072
+ grid-template-columns: 1fr;
1073
+ }
1074
+
1075
+ .resource-grid {
1076
+ grid-template-columns: 1fr;
1077
+ }
1078
+
1079
+ .resource-card:last-child {
1080
+ grid-column: auto;
1081
+ }
1082
+
1083
+ .card-body {
1084
+ padding: 1.1rem;
1085
+ }
1086
+
1087
+ .form-row {
1088
+ grid-template-columns: 1fr;
1089
+ }
1090
+
1091
+ .connection-item {
1092
+ grid-template-columns: 1fr;
1093
+ gap: 0.3rem;
1094
+ }
1095
+
1096
+ .conn-metrics {
1097
+ text-align: left;
490
1098
  }
491
1099
  }
492
1100
  </style>
493
1101
  </head>
494
1102
  <body>
495
- <div class="header">
496
- <h1>TCP转发服务器管理面板</h1>
1103
+ <header class="app-header">
1104
+ <div class="brand">
1105
+ <div class="brand-logo">
1106
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1107
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
1108
+ </svg>
1109
+ </div>
1110
+ <div class="brand-text">
1111
+ <h1 data-i18n="brand.name">TCP 转发服务器</h1>
1112
+ <p data-i18n="brand.subtitle">管理面板</p>
1113
+ </div>
1114
+ </div>
1115
+
497
1116
  <div class="header-actions">
498
- <span>欢迎,管理员</span>
499
- <button class="btn btn-danger btn-sm" onclick="logout()">退出登录</button>
1117
+ <div class="select-wrap">
1118
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1119
+ <circle cx="12" cy="12" r="10"/>
1120
+ <line x1="2" y1="12" x2="22" y2="12"/>
1121
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
1122
+ </svg>
1123
+ <select id="languageSelect" data-i18n-aria-label="common.language">
1124
+ <option value="zh-CN">中文</option>
1125
+ <option value="en-US">English</option>
1126
+ </select>
1127
+ </div>
1128
+
1129
+ <button type="button" class="icon-btn" data-theme-toggle data-i18n-title="common.theme" data-i18n-aria-label="common.theme">
1130
+ <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1131
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
1132
+ </svg>
1133
+ <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1134
+ <circle cx="12" cy="12" r="4"/>
1135
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
1136
+ </svg>
1137
+ </button>
1138
+
1139
+ <div class="user-chip">
1140
+ <span class="avatar" id="userAvatar">A</span>
1141
+ <span id="welcomeText">管理员</span>
1142
+ </div>
1143
+
1144
+ <button class="btn btn-danger btn-sm" onclick="logout()" data-i18n="header.logout">退出登录</button>
500
1145
  </div>
501
- </div>
1146
+ </header>
502
1147
 
503
- <div class="container">
1148
+ <main class="container">
504
1149
  <!-- 系统状态 -->
505
- <div class="card">
1150
+ <section class="card">
506
1151
  <div class="card-header">
507
- <h2>系统状态</h2>
508
- <button class="btn btn-primary btn-sm" onclick="refreshStatus()">刷新</button>
1152
+ <h2 data-i18n="stats.title">系统状态</h2>
1153
+ <button class="btn btn-primary btn-sm" onclick="refreshAll()">
1154
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1155
+ <path d="M23 4v6h-6M1 20v-6h6"/>
1156
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
1157
+ </svg>
1158
+ <span data-i18n="common.refresh">刷新</span>
1159
+ </button>
509
1160
  </div>
510
1161
  <div class="card-body">
511
1162
  <div class="stats-grid" id="statsGrid">
512
1163
  <!-- 状态卡片将通过JavaScript动态生成 -->
513
1164
  </div>
514
1165
  </div>
515
- </div>
1166
+ </section>
1167
+
1168
+ <!-- 服务资源 -->
1169
+ <section class="card">
1170
+ <div class="card-header">
1171
+ <h2 data-i18n="resources.title">服务资源</h2>
1172
+ <span class="resource-sampled">
1173
+ <span class="live-dot" aria-hidden="true"></span>
1174
+ <span id="resourceSampledAt" data-i18n="resources.waiting">等待采样…</span>
1175
+ </span>
1176
+ </div>
1177
+ <div class="card-body">
1178
+ <div class="resource-grid">
1179
+ <div class="resource-card">
1180
+ <div class="resource-card-head">
1181
+ <span class="resource-label" data-i18n="resources.cpu">CPU 使用率</span>
1182
+ <strong class="resource-value" id="cpuValue">0.0%</strong>
1183
+ </div>
1184
+ <div class="resource-track" id="cpuBar" role="progressbar"
1185
+ data-i18n-aria-label="resources.cpu" aria-label="CPU usage"
1186
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
1187
+ <span class="resource-fill" id="cpuFill"></span>
1188
+ </div>
1189
+ <p class="resource-hint" id="cpuHint">—</p>
1190
+ </div>
1191
+
1192
+ <div class="resource-card">
1193
+ <div class="resource-card-head">
1194
+ <span class="resource-label" data-i18n="resources.memory">内存占用</span>
1195
+ <strong class="resource-value" id="memoryValue">0 B</strong>
1196
+ </div>
1197
+ <div class="resource-track" id="memoryBar" role="progressbar"
1198
+ data-i18n-aria-label="resources.memory" aria-label="Memory usage"
1199
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
1200
+ <span class="resource-fill resource-fill--memory" id="memoryFill"></span>
1201
+ </div>
1202
+ <p class="resource-hint" id="memoryHint">—</p>
1203
+ </div>
1204
+
1205
+ <div class="resource-card">
1206
+ <div class="resource-card-head">
1207
+ <span class="resource-label" data-i18n="resources.traffic">实时流量</span>
1208
+ </div>
1209
+ <div class="resource-traffic">
1210
+ <span class="up">↑ <strong id="trafficUp">0 B/s</strong></span>
1211
+ <span class="down">↓ <strong id="trafficDown">0 B/s</strong></span>
1212
+ </div>
1213
+ <p class="resource-hint" id="trafficHint">—</p>
1214
+ </div>
1215
+ </div>
1216
+ </div>
1217
+ </section>
516
1218
 
517
1219
  <!-- 端口映射管理 -->
518
- <div class="card">
1220
+ <section class="card">
519
1221
  <div class="card-header">
520
- <h2>端口映射管理</h2>
521
- <button class="btn btn-success" onclick="showAddMappingModal()">+ 添加映射</button>
1222
+ <h2 data-i18n="mappings.title">端口映射管理</h2>
1223
+ <button class="btn btn-success btn-sm" onclick="showAddMappingModal()" data-i18n="mappings.add">+ 添加映射</button>
522
1224
  </div>
523
1225
  <div class="card-body">
524
- <table class="table">
525
- <thead>
526
- <tr>
527
- <th>名称</th>
528
- <th>监听端口</th>
529
- <th>目标地址</th>
530
- <th>连接数</th>
531
- <th>流量统计</th>
532
- <th>状态</th>
533
- <th>操作</th>
534
- </tr>
535
- </thead>
536
- <tbody id="mappingsTable">
537
- <!-- 端口映射列表将通过JavaScript动态生成 -->
538
- </tbody>
539
- </table>
1226
+ <div class="table-wrap">
1227
+ <table class="table">
1228
+ <thead>
1229
+ <tr>
1230
+ <th data-i18n="mappings.thName">名称</th>
1231
+ <th data-i18n="mappings.thListen">监听端口</th>
1232
+ <th data-i18n="mappings.thTarget">目标地址</th>
1233
+ <th data-i18n="mappings.thConnections">连接数</th>
1234
+ <th data-i18n="mappings.thTraffic">流量统计</th>
1235
+ <th data-i18n="mappings.thStatus">状态</th>
1236
+ <th data-i18n="mappings.thActions">操作</th>
1237
+ </tr>
1238
+ </thead>
1239
+ <tbody id="mappingsTable">
1240
+ <!-- 端口映射列表将通过JavaScript动态生成 -->
1241
+ </tbody>
1242
+ </table>
1243
+ </div>
540
1244
  </div>
541
- </div>
1245
+ </section>
542
1246
 
543
1247
  <!-- 连接状态监控 -->
544
- <div class="card">
1248
+ <section class="card">
545
1249
  <div class="card-header">
546
- <h2>连接状态监控</h2>
547
- <button class="btn btn-primary btn-sm" onclick="refreshConnections()">刷新</button>
1250
+ <h2 data-i18n="connections.title">连接状态监控</h2>
1251
+ <button class="btn btn-primary btn-sm" onclick="refreshAll()">
1252
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1253
+ <path d="M23 4v6h-6M1 20v-6h6"/>
1254
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
1255
+ </svg>
1256
+ <span data-i18n="common.refresh">刷新</span>
1257
+ </button>
548
1258
  </div>
549
1259
  <div class="card-body">
550
1260
  <div id="connectionsDisplay">
551
1261
  <!-- 连接状态将通过JavaScript动态生成 -->
552
1262
  </div>
553
1263
  </div>
554
- </div>
555
- </div>
1264
+ </section>
1265
+
1266
+ <!-- 最近连接记录 -->
1267
+ <section class="card">
1268
+ <div class="card-header">
1269
+ <h2 data-i18n="history.title">最近连接记录</h2>
1270
+ <div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
1271
+ <div class="select-wrap">
1272
+ <select id="historyFilter" onchange="loadHistory()" data-i18n-aria-label="history.thMapping">
1273
+ <option value="" data-i18n="history.allMappings">全部映射</option>
1274
+ </select>
1275
+ </div>
1276
+ <button class="btn btn-primary btn-sm" onclick="loadHistory()">
1277
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1278
+ <path d="M23 4v6h-6M1 20v-6h6"/>
1279
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
1280
+ </svg>
1281
+ <span data-i18n="common.refresh">刷新</span>
1282
+ </button>
1283
+ </div>
1284
+ </div>
1285
+ <div class="card-body">
1286
+ <p class="history-hint" data-i18n="history.hint">记录已关闭连接的源地址、目的地址与持续时长,最多保留最近 500 条(服务重启后清空)。</p>
1287
+ <div class="table-wrap">
1288
+ <table class="table" style="min-width: 880px;">
1289
+ <thead>
1290
+ <tr>
1291
+ <th data-i18n="history.thClosedAt">关闭时间</th>
1292
+ <th data-i18n="history.thMapping">映射</th>
1293
+ <th data-i18n="history.thSource">源地址</th>
1294
+ <th data-i18n="history.thDestination">目的地址</th>
1295
+ <th data-i18n="history.thDuration">持续时长</th>
1296
+ <th data-i18n="history.thTraffic">流量</th>
1297
+ <th data-i18n="history.thReason">结束原因</th>
1298
+ </tr>
1299
+ </thead>
1300
+ <tbody id="historyTable">
1301
+ <!-- 历史记录将通过JavaScript动态生成 -->
1302
+ </tbody>
1303
+ </table>
1304
+ </div>
1305
+ </div>
1306
+ </section>
1307
+ </main>
556
1308
 
557
- <!-- 添加映射模态框 -->
1309
+ <!-- 添加 / 编辑映射模态框 -->
558
1310
  <div id="addMappingModal" class="modal">
559
1311
  <div class="modal-content">
560
1312
  <div class="modal-header">
561
- <h3>添加端口映射</h3>
562
- <span class="close" onclick="closeModal()">&times;</span>
1313
+ <h3 id="modalTitle" data-i18n="modal.addTitle">添加端口映射</h3>
1314
+ <button type="button" class="close" onclick="closeModal()" aria-label="Close">&times;</button>
563
1315
  </div>
564
1316
  <form id="addMappingForm">
565
1317
  <div class="modal-body">
566
1318
  <div class="form-group">
567
- <label for="mappingName">映射名称</label>
568
- <input type="text" id="mappingName" name="name" placeholder="例如:Web服务" required>
1319
+ <label for="mappingName" data-i18n="modal.name">映射名称</label>
1320
+ <input type="text" id="mappingName" name="name" maxlength="100" data-i18n-placeholder="modal.namePlaceholder" placeholder="例如:Web服务" required>
569
1321
  </div>
570
- <div class="form-group">
571
- <label for="listenPort">监听端口</label>
572
- <input type="number" id="listenPort" name="listenPort" min="1" max="65535" placeholder="1-65535" required>
573
- </div>
574
- <div class="form-group">
575
- <label for="targetHost">目标主机</label>
576
- <input type="text" id="targetHost" name="targetHost" placeholder="IP地址或域名" required>
1322
+
1323
+ <div class="form-row">
1324
+ <div class="form-group">
1325
+ <label for="listenHost" data-i18n="modal.listenHost">监听地址</label>
1326
+ <input type="text" id="listenHost" name="listenHost" value="0.0.0.0" data-i18n-placeholder="modal.listenHostPlaceholder" placeholder="0.0.0.0 表示所有网卡" required>
1327
+ </div>
1328
+ <div class="form-group">
1329
+ <label for="listenPort" data-i18n="modal.listenPort">监听端口</label>
1330
+ <input type="number" id="listenPort" name="listenPort" min="1" max="65535" data-i18n-placeholder="modal.portPlaceholder" placeholder="1-65535" required>
1331
+ </div>
577
1332
  </div>
578
- <div class="form-group">
579
- <label for="targetPort">目标端口</label>
580
- <input type="number" id="targetPort" name="targetPort" min="1" max="65535" placeholder="1-65535" required>
1333
+
1334
+ <div class="form-row">
1335
+ <div class="form-group">
1336
+ <label for="targetHost" data-i18n="modal.targetHost">目标主机</label>
1337
+ <input type="text" id="targetHost" name="targetHost" data-i18n-placeholder="modal.targetHostPlaceholder" placeholder="IP地址或域名" required>
1338
+ </div>
1339
+ <div class="form-group">
1340
+ <label for="targetPort" data-i18n="modal.targetPort">目标端口</label>
1341
+ <input type="number" id="targetPort" name="targetPort" min="1" max="65535" data-i18n-placeholder="modal.portPlaceholder" placeholder="1-65535" required>
1342
+ </div>
581
1343
  </div>
1344
+
582
1345
  <div class="error-message" id="modalErrorMessage"></div>
583
1346
  <div class="success-message" id="modalSuccessMessage"></div>
584
1347
  </div>
585
1348
  <div class="modal-footer">
586
- <button type="button" class="btn btn-cancel" onclick="closeModal()">取消</button>
587
- <button type="submit" class="btn btn-success">确认添加</button>
1349
+ <button type="button" class="btn btn-cancel" onclick="closeModal()" data-i18n="common.cancel">取消</button>
1350
+ <button type="submit" class="btn btn-success" id="modalSubmitBtn" data-i18n="modal.confirmAdd">确认添加</button>
588
1351
  </div>
589
1352
  </form>
590
1353
  </div>
591
1354
  </div>
592
1355
 
1356
+ <div id="toastContainer" class="toast-container" role="status" aria-live="polite"></div>
1357
+
593
1358
  <script>
594
1359
  let mappings = [];
595
- let connections = {};
1360
+ let connections = { byPort: [] };
1361
+ let connectionHistory = [];
596
1362
  let status = {};
1363
+ let editingId = null;
1364
+ let refreshTick = 0;
1365
+ let historyFilterSignature = '';
1366
+
1367
+ // 统一翻译入口
1368
+ function t(key, params) {
1369
+ return window.I18n ? window.I18n.t(key, params) : key;
1370
+ }
1371
+
1372
+ // 转义 HTML,避免映射名称等内容造成 XSS
1373
+ function escapeHtml(value) {
1374
+ return String(value === undefined || value === null ? '' : value)
1375
+ .replace(/&/g, '&amp;')
1376
+ .replace(/</g, '&lt;')
1377
+ .replace(/>/g, '&gt;')
1378
+ .replace(/"/g, '&quot;')
1379
+ .replace(/'/g, '&#39;');
1380
+ }
1381
+
1382
+ // IPv6 地址加方括号
1383
+ function formatEndpoint(host, port) {
1384
+ const value = String(host === undefined || host === null ? '' : host);
1385
+ return value.includes(':') ? `[${value}]:${port}` : `${value}:${port}`;
1386
+ }
1387
+
1388
+ // 统一 API 请求:携带语言并处理登录失效
1389
+ async function api(url, options = {}) {
1390
+ const headers = Object.assign(
1391
+ { 'X-Language': t ? (window.I18n ? window.I18n.getLanguage() : 'zh-CN') : 'zh-CN' },
1392
+ options.headers || {}
1393
+ );
1394
+
1395
+ const response = await fetch(url, {
1396
+ credentials: 'same-origin',
1397
+ ...options,
1398
+ headers
1399
+ });
1400
+
1401
+ if (response.status === 401) {
1402
+ window.location.href = '/login';
1403
+ throw new Error(t('common.sessionExpired'));
1404
+ }
1405
+
1406
+ let data;
1407
+ try {
1408
+ data = await response.json();
1409
+ } catch (error) {
1410
+ data = { success: false, message: t('common.invalidResponse') };
1411
+ }
1412
+
1413
+ if (!response.ok && data.message === undefined) {
1414
+ data.message = t('common.requestFailed', { status: response.status });
1415
+ }
1416
+
1417
+ return data;
1418
+ }
1419
+
1420
+ // 轻量提示条
1421
+ function showToast(message, type) {
1422
+ const container = document.getElementById('toastContainer');
1423
+ if (!container) return;
1424
+
1425
+ const toast = document.createElement('div');
1426
+ toast.className = 'toast' + (type ? ` toast-${type}` : '');
1427
+ toast.textContent = message;
1428
+ container.appendChild(toast);
1429
+
1430
+ requestAnimationFrame(() => toast.classList.add('show'));
1431
+
1432
+ setTimeout(() => {
1433
+ toast.classList.remove('show');
1434
+ setTimeout(() => toast.remove(), 250);
1435
+ }, 3600);
1436
+ }
597
1437
 
598
- // 页面加载时初始化数据
599
1438
  document.addEventListener('DOMContentLoaded', function() {
600
- loadMappings();
601
- loadConnections();
602
- loadStatus();
1439
+ document.title = t('page.dashboard');
1440
+ refreshAll();
603
1441
 
604
- // 每5秒自动刷新连接状态
1442
+ // 每 5 秒自动刷新;页面隐藏时暂停
605
1443
  setInterval(() => {
606
- loadConnections();
607
- loadStatus();
1444
+ if (!document.hidden) {
1445
+ refreshAll();
1446
+ }
608
1447
  }, 5000);
1448
+
1449
+ document.addEventListener('visibilitychange', () => {
1450
+ if (!document.hidden) {
1451
+ refreshAll();
1452
+ }
1453
+ });
609
1454
  });
610
1455
 
611
- // 加载端口映射
1456
+ // 切换语言后重新渲染动态内容
1457
+ document.addEventListener('languagechange', function() {
1458
+ document.title = t('page.dashboard');
1459
+ historyFilterSignature = '';
1460
+ renderMappings();
1461
+ renderConnections();
1462
+ // 关闭原因由服务端按请求语言返回,切换语言后需重新拉取
1463
+ loadHistory();
1464
+ renderStatus();
1465
+
1466
+ // 模态框打开时同步标题与按钮
1467
+ const modal = document.getElementById('addMappingModal');
1468
+ if (modal && modal.style.display === 'block') {
1469
+ document.getElementById('modalTitle').textContent = editingId ? t('modal.editTitle') : t('modal.addTitle');
1470
+ document.getElementById('modalSubmitBtn').textContent = editingId ? t('modal.saveChanges') : t('modal.confirmAdd');
1471
+ }
1472
+ });
1473
+
1474
+ function refreshAll() {
1475
+ loadMappings();
1476
+ loadConnections();
1477
+ loadStatus();
1478
+
1479
+ // 历史记录变化较慢,每 15 秒刷新一次即可
1480
+ if (refreshTick++ % 3 === 0) {
1481
+ loadHistory();
1482
+ }
1483
+ }
1484
+
612
1485
  async function loadMappings() {
613
1486
  try {
614
- const response = await fetch('/api/mappings');
615
- const data = await response.json();
616
-
1487
+ const data = await api('/api/mappings');
617
1488
  if (data.success) {
618
1489
  mappings = data.data;
619
1490
  renderMappings();
@@ -623,12 +1494,9 @@
623
1494
  }
624
1495
  }
625
1496
 
626
- // 加载连接状态
627
1497
  async function loadConnections() {
628
1498
  try {
629
- const response = await fetch('/api/connections');
630
- const data = await response.json();
631
-
1499
+ const data = await api('/api/connections');
632
1500
  if (data.success) {
633
1501
  connections = data.data;
634
1502
  renderConnections();
@@ -638,12 +1506,28 @@
638
1506
  }
639
1507
  }
640
1508
 
641
- // 加载系统状态
642
- async function loadStatus() {
1509
+ async function loadHistory() {
643
1510
  try {
644
- const response = await fetch('/api/status');
645
- const data = await response.json();
1511
+ const filter = document.getElementById('historyFilter');
1512
+ const mappingId = filter ? filter.value : '';
1513
+ const params = new URLSearchParams({ limit: '100' });
1514
+ if (mappingId) {
1515
+ params.set('mappingId', mappingId);
1516
+ }
1517
+
1518
+ const data = await api(`/api/connections/history?${params.toString()}`);
1519
+ if (data.success) {
1520
+ connectionHistory = data.data.items;
1521
+ renderHistory();
1522
+ }
1523
+ } catch (error) {
1524
+ console.error('加载连接记录失败:', error);
1525
+ }
1526
+ }
646
1527
 
1528
+ async function loadStatus() {
1529
+ try {
1530
+ const data = await api('/api/status');
647
1531
  if (data.success) {
648
1532
  status = data.data;
649
1533
  renderStatus();
@@ -653,51 +1537,167 @@
653
1537
  }
654
1538
  }
655
1539
 
656
- // 格式化字节数
1540
+ // 更新历史记录筛选下拉框(仅在映射列表变化时重建)
1541
+ function updateHistoryFilter() {
1542
+ const select = document.getElementById('historyFilter');
1543
+ if (!select) return;
1544
+
1545
+ const signature = mappings
1546
+ .map(mapping => `${mapping.id}:${mapping.name}:${mapping.listen.host}:${mapping.listen.port}`)
1547
+ .join('|');
1548
+
1549
+ if (signature === historyFilterSignature) return;
1550
+ historyFilterSignature = signature;
1551
+
1552
+ const current = select.value;
1553
+ const options = mappings.map(mapping =>
1554
+ `<option value="${escapeHtml(mapping.id)}">${escapeHtml(mapping.name)} (${escapeHtml(formatEndpoint(mapping.listen.host, mapping.listen.port))})</option>`
1555
+ ).join('');
1556
+
1557
+ select.innerHTML = `<option value="">${escapeHtml(t('history.allMappings'))}</option>${options}`;
1558
+
1559
+ if (mappings.some(mapping => mapping.id === current)) {
1560
+ select.value = current;
1561
+ } else {
1562
+ select.value = '';
1563
+ if (current !== '') {
1564
+ loadHistory();
1565
+ }
1566
+ }
1567
+ }
1568
+
657
1569
  function formatBytes(bytes) {
658
- if (bytes === 0) return '0 B';
1570
+ const value = Number(bytes) || 0;
1571
+ if (value <= 0) return '0 B';
1572
+
659
1573
  const k = 1024;
660
- const sizes = ['B', 'KB', 'MB', 'GB'];
661
- const i = Math.floor(Math.log(bytes) / Math.log(k));
662
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1574
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1575
+ const i = Math.min(sizes.length - 1, Math.floor(Math.log(value) / Math.log(k)));
1576
+ return parseFloat((value / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1577
+ }
1578
+
1579
+ function formatDuration(seconds) {
1580
+ const value = Math.max(0, Math.floor(Number(seconds) || 0));
1581
+ if (value < 60) return `${value}s`;
1582
+ if (value < 3600) return `${Math.floor(value / 60)}m ${value % 60}s`;
1583
+ if (value < 86400) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
1584
+ return `${Math.floor(value / 86400)}d ${Math.floor((value % 86400) / 3600)}h`;
1585
+ }
1586
+
1587
+ function formatUptime(seconds) {
1588
+ const value = Number(seconds) || 0;
1589
+ if (value < 60) return Math.floor(value) + 's';
1590
+ if (value < 3600) return Math.floor(value / 60) + 'm';
1591
+ if (value < 86400) return Math.floor(value / 3600) + 'h ' + Math.floor((value % 3600) / 60) + 'm';
1592
+ return Math.floor(value / 86400) + 'd ' + Math.floor((value % 86400) / 3600) + 'h';
1593
+ }
1594
+
1595
+ // 渲染服务进程自身的资源占用(CPU / 内存 / 实时流量)
1596
+ function renderResources() {
1597
+ const resources = status.resources;
1598
+ if (!resources) return;
1599
+
1600
+ const cpuPercent = Math.max(0, Math.min(100, Number(resources.cpuPercent) || 0));
1601
+ const cpuValue = document.getElementById('cpuValue');
1602
+ const cpuFill = document.getElementById('cpuFill');
1603
+ const cpuBar = document.getElementById('cpuBar');
1604
+ const cpuHint = document.getElementById('cpuHint');
1605
+ if (cpuValue) cpuValue.textContent = cpuPercent.toFixed(1) + '%';
1606
+ if (cpuFill) cpuFill.style.width = cpuPercent + '%';
1607
+ if (cpuBar) cpuBar.setAttribute('aria-valuenow', cpuPercent.toFixed(1));
1608
+ if (cpuHint) cpuHint.textContent = t('resources.cpuHint', { count: resources.cpuCount || 1 });
1609
+
1610
+ const memory = resources.memory || {};
1611
+ const rss = Number(memory.rss) || 0;
1612
+ const systemTotal = Number(memory.systemTotal) || 0;
1613
+ const memoryPercent = systemTotal > 0 ? Math.min(100, (rss / systemTotal) * 100) : 0;
1614
+ const memoryValue = document.getElementById('memoryValue');
1615
+ const memoryFill = document.getElementById('memoryFill');
1616
+ const memoryBar = document.getElementById('memoryBar');
1617
+ const memoryHint = document.getElementById('memoryHint');
1618
+ if (memoryValue) memoryValue.textContent = formatBytes(rss);
1619
+ if (memoryFill) memoryFill.style.width = memoryPercent + '%';
1620
+ if (memoryBar) memoryBar.setAttribute('aria-valuenow', memoryPercent.toFixed(1));
1621
+ if (memoryHint) {
1622
+ memoryHint.textContent = t('resources.memoryHint', {
1623
+ percent: memoryPercent.toFixed(1),
1624
+ heap: formatBytes(memory.heapUsed)
1625
+ });
1626
+ }
1627
+
1628
+ const traffic = resources.traffic || {};
1629
+ const up = Number(traffic.receivedPerSecond) || 0;
1630
+ const down = Number(traffic.sentPerSecond) || 0;
1631
+ const trafficUp = document.getElementById('trafficUp');
1632
+ const trafficDown = document.getElementById('trafficDown');
1633
+ const trafficHint = document.getElementById('trafficHint');
1634
+ if (trafficUp) trafficUp.textContent = formatBytes(up) + '/s';
1635
+ if (trafficDown) trafficDown.textContent = formatBytes(down) + '/s';
1636
+ if (trafficHint) {
1637
+ trafficHint.textContent = t('resources.trafficHint', {
1638
+ up: formatBytes(traffic.totalReceived),
1639
+ down: formatBytes(traffic.totalSent)
1640
+ });
1641
+ }
1642
+
1643
+ const sampledAt = document.getElementById('resourceSampledAt');
1644
+ if (sampledAt) {
1645
+ if (resources.sampledAt) {
1646
+ const locale = I18n.getLanguage() === 'en-US' ? 'en-US' : 'zh-CN';
1647
+ const time = new Date(resources.sampledAt).toLocaleTimeString(locale, { hour12: false });
1648
+ sampledAt.textContent = t('resources.sampledAt', { time });
1649
+ } else {
1650
+ sampledAt.textContent = t('resources.waiting');
1651
+ }
1652
+ }
663
1653
  }
664
1654
 
665
1655
  // 渲染端口映射列表
666
1656
  function renderMappings() {
667
1657
  const tbody = document.getElementById('mappingsTable');
668
1658
  tbody.innerHTML = '';
1659
+ updateHistoryFilter();
669
1660
 
670
1661
  if (mappings.length === 0) {
671
- tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#b2bec3;padding:3rem;">暂无端口映射,点击上方按钮添加</td></tr>';
1662
+ tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state">${escapeHtml(t('mappings.empty'))}</div></td></tr>`;
672
1663
  return;
673
1664
  }
674
1665
 
675
1666
  mappings.forEach((mapping, index) => {
676
1667
  const row = document.createElement('tr');
677
- const identifier = mapping.id || index;
1668
+ const isRunning = mapping.status === 'running';
1669
+ const activeTraffic = mapping.connectionCount > 0
1670
+ ? `<div class="active-traffic">${escapeHtml(t('mappings.currentTraffic', {
1671
+ up: formatBytes(mapping.bytesReceived),
1672
+ down: formatBytes(mapping.bytesSent)
1673
+ }))}</div>`
1674
+ : '';
1675
+
678
1676
  row.innerHTML = `
679
- <td><strong>${mapping.name}</strong></td>
680
- <td><code>${mapping.listen.host}:${mapping.listen.port}</code></td>
681
- <td><code>${mapping.target.host}:${mapping.target.port}</code></td>
1677
+ <td><strong>${escapeHtml(mapping.name)}</strong></td>
1678
+ <td><code>${escapeHtml(formatEndpoint(mapping.listen.host, mapping.listen.port))}</code></td>
1679
+ <td><code>${escapeHtml(formatEndpoint(mapping.target.host, mapping.target.port))}</code></td>
682
1680
  <td><span class="status-badge status-connected">${mapping.connectionCount || 0}</span></td>
683
1681
  <td>
684
1682
  <div class="traffic-info">
685
- <span class="up">↑ ${formatBytes(mapping.bytesReceived || 0)}</span><br>
686
- <span class="down">↓ ${formatBytes(mapping.bytesSent || 0)}</span>
1683
+ <span class="up" title="${escapeHtml(t('mappings.totalUpload'))}">↑ ${formatBytes(mapping.totalBytesReceived)}</span><br>
1684
+ <span class="down" title="${escapeHtml(t('mappings.totalDownload'))}">↓ ${formatBytes(mapping.totalBytesSent)}</span>
1685
+ ${activeTraffic}
687
1686
  </div>
688
1687
  </td>
689
1688
  <td>
690
- <span class="status-badge ${mapping.status === 'running' ? 'status-running' : 'status-stopped'}">
691
- ${mapping.status === 'running' ? '运行中' : '已停止'}
1689
+ <span class="status-badge ${isRunning ? 'status-running' : 'status-stopped'}">
1690
+ ${escapeHtml(isRunning ? t('common.running') : t('common.stopped'))}
692
1691
  </span>
693
1692
  </td>
694
1693
  <td>
695
1694
  <div class="action-buttons">
696
- ${mapping.status === 'running'
697
- ? `<button class="btn btn-warning btn-sm" onclick="stopMapping('${identifier}')">停止</button>`
698
- : `<button class="btn btn-success btn-sm" onclick="startMapping('${identifier}')">启动</button>`
1695
+ <button class="btn btn-primary btn-sm" onclick="showEditMappingModal(${index})">${escapeHtml(t('common.edit'))}</button>
1696
+ ${isRunning
1697
+ ? `<button class="btn btn-warning btn-sm" onclick="stopMapping(${index})">${escapeHtml(t('common.stop'))}</button>`
1698
+ : `<button class="btn btn-success btn-sm" onclick="startMapping(${index})">${escapeHtml(t('common.start'))}</button>`
699
1699
  }
700
- <button class="btn btn-danger btn-sm" onclick="deleteMapping('${identifier}')">删除</button>
1700
+ <button class="btn btn-danger btn-sm" onclick="deleteMapping(${index})">${escapeHtml(t('common.delete'))}</button>
701
1701
  </div>
702
1702
  </td>
703
1703
  `;
@@ -705,222 +1705,409 @@
705
1705
  });
706
1706
  }
707
1707
 
708
- // 渲染连接状态
1708
+ // 渲染活跃连接
709
1709
  function renderConnections() {
710
1710
  const container = document.getElementById('connectionsDisplay');
711
1711
  container.innerHTML = '';
712
1712
 
713
- if (connections.byPort && connections.byPort.length > 0) {
714
- connections.byPort.forEach(portStat => {
715
- const portDiv = document.createElement('div');
716
- portDiv.className = 'connection-card';
717
-
718
- portDiv.innerHTML = `
719
- <h4><span class="port-badge">端口 ${portStat.port}</span> ${portStat.mappingName}</h4>
720
- <p style="margin-bottom:0.75rem;color:#636e72;">活跃连接: <strong>${portStat.totalConnections}</strong></p>
721
- <div class="connection-details">
722
- ${portStat.connections.map(conn => `
723
- <div class="connection-item">
724
- <span class="status-badge status-connected">${conn.status}</span>
725
- <span>${conn.clientAddress} → ${conn.targetAddress}</span>
726
- <span style="margin-left:auto;color:#b2bec3;">↑${formatBytes(conn.bytesReceived)} ↓${formatBytes(conn.bytesSent)}</span>
727
- </div>
728
- `).join('')}
729
- </div>
730
- `;
731
-
732
- container.appendChild(portDiv);
733
- });
734
- } else {
1713
+ if (!connections.byPort || connections.byPort.length === 0) {
735
1714
  container.innerHTML = `
736
- <div class="no-connections">
1715
+ <div class="empty-state">
737
1716
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
738
1717
  <path d="M8.5 14.5A2.5 2.5 0 0011 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 11-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 002.5 2.5z"/>
739
1718
  </svg>
740
- <p>当前没有活跃连接</p>
1719
+ <p>${escapeHtml(t('connections.empty'))}</p>
741
1720
  </div>
742
1721
  `;
1722
+ return;
1723
+ }
1724
+
1725
+ connections.byPort.forEach(portStat => {
1726
+ const portDiv = document.createElement('div');
1727
+ portDiv.className = 'connection-card';
1728
+
1729
+ const items = portStat.connections.map(conn => {
1730
+ const startTime = Date.parse(conn.startTime);
1731
+ const durationSeconds = Number.isFinite(Number(conn.durationMs))
1732
+ ? Number(conn.durationMs) / 1000
1733
+ : (Number.isFinite(startTime) ? Math.max(0, (Date.now() - startTime) / 1000) : 0);
1734
+ const statusText = conn.status === 'connected' ? t('common.connected') : t('common.connecting');
1735
+
1736
+ return `
1737
+ <div class="connection-item">
1738
+ <span class="status-badge status-connected">${escapeHtml(statusText)}</span>
1739
+ <span class="conn-endpoints">${escapeHtml(conn.clientAddress)} → ${escapeHtml(conn.targetAddress)}</span>
1740
+ <span class="conn-metrics">${formatDuration(durationSeconds)} · ↑${formatBytes(conn.bytesReceived)} ↓${formatBytes(conn.bytesSent)}</span>
1741
+ </div>
1742
+ `;
1743
+ }).join('');
1744
+
1745
+ const firstConn = portStat.connections[0] || {};
1746
+ const configuredListen = formatEndpoint(portStat.host, portStat.port);
1747
+ const actualListen = firstConn.listenAddress && firstConn.listenAddress !== configuredListen
1748
+ ? `<span>${escapeHtml(t('connections.actualListenLabel'))} <code>${escapeHtml(firstConn.listenAddress)}</code></span>`
1749
+ : '';
1750
+
1751
+ portDiv.innerHTML = `
1752
+ <h4><span class="port-badge">${escapeHtml(configuredListen)}</span> ${escapeHtml(portStat.mappingName)}</h4>
1753
+ <div class="connection-meta">
1754
+ <span>${escapeHtml(t('connections.activeCount', { count: portStat.totalConnections }))}</span>
1755
+ ${actualListen}
1756
+ </div>
1757
+ <div class="connection-details">${items}</div>
1758
+ `;
1759
+
1760
+ container.appendChild(portDiv);
1761
+ });
1762
+ }
1763
+
1764
+ // 将服务端记录的关闭原因转换为当前界面语言
1765
+ function translateCloseReason(reason) {
1766
+ if (!window.I18n || window.I18n.getLanguage() !== 'en-US') {
1767
+ return reason || '-';
1768
+ }
1769
+
1770
+ const map = {
1771
+ '客户端关闭': 'Client closed',
1772
+ '目标服务器关闭': 'Target closed',
1773
+ '目标连接超时': 'Target connect timeout',
1774
+ '目标连接空闲超时': 'Target idle timeout',
1775
+ '客户端空闲超时': 'Client idle timeout',
1776
+ '目标连接错误': 'Target connection error',
1777
+ '客户端连接错误': 'Client connection error',
1778
+ '端口映射已停止': 'Mapping stopped',
1779
+ '服务正在关闭': 'Server shutting down',
1780
+ '连接被主动断开': 'Closed by administrator'
1781
+ };
1782
+
1783
+ return map[reason] || reason || '-';
1784
+ }
1785
+
1786
+ // 渲染最近连接记录
1787
+ function renderHistory() {
1788
+ const tbody = document.getElementById('historyTable');
1789
+ if (!tbody) return;
1790
+ tbody.innerHTML = '';
1791
+
1792
+ if (!connectionHistory || connectionHistory.length === 0) {
1793
+ tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state">${escapeHtml(t('history.empty'))}</div></td></tr>`;
1794
+ return;
743
1795
  }
1796
+
1797
+ const locale = window.I18n ? window.I18n.getLanguage() : 'zh-CN';
1798
+
1799
+ connectionHistory.forEach(item => {
1800
+ const endTime = new Date(item.endTime);
1801
+ const endText = Number.isNaN(endTime.getTime()) ? '-' : endTime.toLocaleString(locale);
1802
+ const durationSeconds = Number(item.durationMs || 0) / 1000;
1803
+ const statusText = item.status === 'connected' ? '' : t('history.notEstablished');
1804
+
1805
+ const row = document.createElement('tr');
1806
+ row.innerHTML = `
1807
+ <td>${escapeHtml(endText)}</td>
1808
+ <td>
1809
+ <strong>${escapeHtml(item.mappingName)}</strong>
1810
+ <div style="color:var(--text-muted);font-size:0.72rem;">${escapeHtml(item.listenAddress || '')}</div>
1811
+ </td>
1812
+ <td><code>${escapeHtml(item.clientAddress)}</code></td>
1813
+ <td><code>${escapeHtml(item.targetAddress)}</code></td>
1814
+ <td>${escapeHtml(formatDuration(durationSeconds))}${escapeHtml(statusText)}</td>
1815
+ <td><span class="up">↑ ${formatBytes(item.bytesReceived)}</span><br><span class="down">↓ ${formatBytes(item.bytesSent)}</span></td>
1816
+ <td>${escapeHtml(translateCloseReason(item.closeReason))}</td>
1817
+ `;
1818
+ tbody.appendChild(row);
1819
+ });
744
1820
  }
745
1821
 
746
1822
  // 渲染系统状态
747
1823
  function renderStatus() {
748
1824
  const container = document.getElementById('statsGrid');
749
- const uptime = status.uptime || 0;
750
- let uptimeStr;
751
- if (uptime < 60) {
752
- uptimeStr = Math.floor(uptime) + 's';
753
- } else if (uptime < 3600) {
754
- uptimeStr = Math.floor(uptime / 60) + 'm';
755
- } else {
756
- uptimeStr = Math.floor(uptime / 3600) + 'h ' + Math.floor((uptime % 3600) / 60) + 'm';
1825
+ const username = status.username || t('header.welcomeFallback');
1826
+
1827
+ const welcome = document.getElementById('welcomeText');
1828
+ if (welcome) {
1829
+ welcome.textContent = t('header.welcome', { name: username });
757
1830
  }
758
1831
 
1832
+ const avatar = document.getElementById('userAvatar');
1833
+ if (avatar) {
1834
+ avatar.textContent = String(username).trim().charAt(0).toUpperCase() || 'A';
1835
+ }
1836
+
1837
+ const totalUp = mappings.reduce((sum, mapping) => sum + (Number(mapping.totalBytesReceived) || 0), 0);
1838
+ const totalDown = mappings.reduce((sum, mapping) => sum + (Number(mapping.totalBytesSent) || 0), 0);
1839
+
759
1840
  container.innerHTML = `
760
- <div class="stat-card">
761
- <h3>${status.totalMappings || 0}</h3>
762
- <p>总映射数</p>
1841
+ <div class="stat-card stat-card--indigo">
1842
+ <div class="stat-icon">
1843
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1844
+ <polygon points="12 2 2 7 12 12 22 7 12 2"/>
1845
+ <polyline points="2 17 12 22 22 17"/>
1846
+ <polyline points="2 12 12 17 22 12"/>
1847
+ </svg>
1848
+ </div>
1849
+ <div>
1850
+ <h3>${status.totalMappings || 0}</h3>
1851
+ <p>${escapeHtml(t('stats.totalMappings'))}</p>
1852
+ </div>
1853
+ </div>
1854
+ <div class="stat-card stat-card--green">
1855
+ <div class="stat-icon">
1856
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1857
+ <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
1858
+ </svg>
1859
+ </div>
1860
+ <div>
1861
+ <h3>${status.activeMappings || 0}</h3>
1862
+ <p>${escapeHtml(t('stats.activeMappings'))}</p>
1863
+ </div>
763
1864
  </div>
764
- <div class="stat-card">
765
- <h3>${status.activeMappings || 0}</h3>
766
- <p>活跃映射</p>
1865
+ <div class="stat-card stat-card--amber">
1866
+ <div class="stat-icon">
1867
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1868
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1869
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1870
+ </svg>
1871
+ </div>
1872
+ <div>
1873
+ <h3>${status.totalConnections || 0}</h3>
1874
+ <p>${escapeHtml(t('stats.activeConnections'))}</p>
1875
+ </div>
767
1876
  </div>
768
- <div class="stat-card">
769
- <h3>${status.totalConnections || 0}</h3>
770
- <p>总连接数</p>
1877
+ <div class="stat-card stat-card--sky">
1878
+ <div class="stat-icon">
1879
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1880
+ <circle cx="12" cy="12" r="10"/>
1881
+ <polyline points="12 6 12 12 16 14"/>
1882
+ </svg>
1883
+ </div>
1884
+ <div>
1885
+ <h3>${formatUptime(status.uptime)}</h3>
1886
+ <p>${escapeHtml(t('stats.uptime'))}</p>
1887
+ </div>
771
1888
  </div>
772
- <div class="stat-card">
773
- <h3>${uptimeStr}</h3>
774
- <p>运行时间</p>
1889
+ <div class="stat-card stat-card--violet">
1890
+ <div class="stat-icon">
1891
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1892
+ <polyline points="17 1 21 5 17 9"/>
1893
+ <path d="M3 11V9a4 4 0 0 1 4-4h14"/>
1894
+ <polyline points="7 23 3 19 7 15"/>
1895
+ <path d="M21 13v2a4 4 0 0 1-4 4H3"/>
1896
+ </svg>
1897
+ </div>
1898
+ <div>
1899
+ <h3 class="small"><span>↑${formatBytes(totalUp)}</span><span>↓${formatBytes(totalDown)}</span></h3>
1900
+ <p>${escapeHtml(t('stats.totalTraffic'))}</p>
1901
+ </div>
775
1902
  </div>
776
1903
  `;
1904
+
1905
+ renderResources();
777
1906
  }
778
1907
 
779
- // 显示添加映射模态框
780
1908
  function showAddMappingModal() {
781
- document.getElementById('addMappingModal').style.display = 'block';
1909
+ editingId = null;
1910
+
1911
+ const form = document.getElementById('addMappingForm');
1912
+ form.reset();
1913
+ document.getElementById('listenHost').value = '0.0.0.0';
1914
+ document.getElementById('modalTitle').textContent = t('modal.addTitle');
1915
+ document.getElementById('modalSubmitBtn').textContent = t('modal.confirmAdd');
1916
+
1917
+ showModal();
1918
+ document.getElementById('mappingName').focus();
1919
+ }
1920
+
1921
+ function showEditMappingModal(index) {
1922
+ const mapping = mappings[index];
1923
+ if (!mapping) return;
1924
+
1925
+ editingId = mapping.id;
1926
+
1927
+ document.getElementById('mappingName').value = mapping.name || '';
1928
+ document.getElementById('listenHost').value = mapping.listen.host || '0.0.0.0';
1929
+ document.getElementById('listenPort').value = mapping.listen.port;
1930
+ document.getElementById('targetHost').value = mapping.target.host || '';
1931
+ document.getElementById('targetPort').value = mapping.target.port;
1932
+ document.getElementById('modalTitle').textContent = t('modal.editTitle');
1933
+ document.getElementById('modalSubmitBtn').textContent = t('modal.saveChanges');
1934
+
1935
+ showModal();
782
1936
  document.getElementById('mappingName').focus();
783
1937
  }
784
1938
 
785
- // 关闭模态框
1939
+ function showModal() {
1940
+ document.getElementById('addMappingModal').style.display = 'block';
1941
+ hideModalMessages();
1942
+ }
1943
+
786
1944
  function closeModal() {
787
1945
  document.getElementById('addMappingModal').style.display = 'none';
788
1946
  document.getElementById('addMappingForm').reset();
1947
+ editingId = null;
1948
+ hideModalMessages();
1949
+ }
1950
+
1951
+ function hideModalMessages() {
789
1952
  document.getElementById('modalErrorMessage').style.display = 'none';
790
1953
  document.getElementById('modalSuccessMessage').style.display = 'none';
791
1954
  }
792
1955
 
793
- // 添加端口映射
1956
+ function showModalError(message) {
1957
+ const errorElement = document.getElementById('modalErrorMessage');
1958
+ errorElement.textContent = message;
1959
+ errorElement.style.display = 'block';
1960
+ document.getElementById('modalSuccessMessage').style.display = 'none';
1961
+ }
1962
+
1963
+ // 添加 / 更新端口映射
794
1964
  document.getElementById('addMappingForm').addEventListener('submit', async function(e) {
795
1965
  e.preventDefault();
796
1966
 
797
1967
  const formData = new FormData(e.target);
798
- const mappingData = {
799
- name: formData.get('name'),
1968
+ const payload = {
1969
+ name: String(formData.get('name') || '').trim(),
800
1970
  listen: {
801
- host: '0.0.0.0',
802
- port: parseInt(formData.get('listenPort'))
1971
+ host: String(formData.get('listenHost') || '').trim(),
1972
+ port: parseInt(formData.get('listenPort'), 10)
803
1973
  },
804
1974
  target: {
805
- host: formData.get('targetHost'),
806
- port: parseInt(formData.get('targetPort'))
1975
+ host: String(formData.get('targetHost') || '').trim(),
1976
+ port: parseInt(formData.get('targetPort'), 10)
807
1977
  }
808
1978
  };
809
1979
 
1980
+ const isEdit = editingId !== null;
1981
+ const mapping = isEdit ? mappings.find(item => item.id === editingId) : null;
1982
+
1983
+ if (isEdit && !mapping) {
1984
+ showModalError(t('modal.mappingMissing'));
1985
+ return;
1986
+ }
1987
+
1988
+ const submitBtn = document.getElementById('modalSubmitBtn');
1989
+ const originalText = submitBtn.textContent;
1990
+ submitBtn.disabled = true;
1991
+ submitBtn.textContent = t('modal.saving');
1992
+
810
1993
  try {
811
- const response = await fetch('/api/mappings', {
812
- method: 'POST',
813
- headers: {
814
- 'Content-Type': 'application/json',
815
- },
816
- body: JSON.stringify(mappingData)
1994
+ const url = isEdit
1995
+ ? `/api/mappings/${encodeURIComponent(editingId)}`
1996
+ : '/api/mappings';
1997
+
1998
+ const data = await api(url, {
1999
+ method: isEdit ? 'PUT' : 'POST',
2000
+ headers: { 'Content-Type': 'application/json' },
2001
+ body: JSON.stringify(payload)
817
2002
  });
818
2003
 
819
- const data = await response.json();
820
-
821
2004
  if (data.success) {
822
- document.getElementById('modalSuccessMessage').textContent = data.message;
823
- document.getElementById('modalSuccessMessage').style.display = 'block';
2005
+ const successElement = document.getElementById('modalSuccessMessage');
2006
+ successElement.textContent = isEdit ? t('modal.saveSuccess') : t('modal.addSuccess');
2007
+ successElement.style.display = 'block';
824
2008
  document.getElementById('modalErrorMessage').style.display = 'none';
825
2009
 
826
2010
  setTimeout(() => {
827
2011
  closeModal();
828
- loadMappings();
829
- }, 1000);
2012
+ refreshAll();
2013
+ }, 600);
830
2014
  } else {
831
- document.getElementById('modalErrorMessage').textContent = data.message;
832
- document.getElementById('modalErrorMessage').style.display = 'block';
833
- document.getElementById('modalSuccessMessage').style.display = 'none';
2015
+ showModalError(data.message || t('modal.operationFailed'));
834
2016
  }
835
2017
  } catch (error) {
836
- document.getElementById('modalErrorMessage').textContent = '添加失败,请检查网络连接';
837
- document.getElementById('modalErrorMessage').style.display = 'block';
2018
+ if (error.message !== t('common.sessionExpired')) {
2019
+ showModalError(t('common.networkError'));
2020
+ }
2021
+ } finally {
2022
+ submitBtn.disabled = false;
2023
+ submitBtn.textContent = originalText;
838
2024
  }
839
2025
  });
840
2026
 
841
- // 删除端口映射
842
- async function deleteMapping(identifier) {
843
- if (!confirm('确定要删除这个端口映射吗?')) {
2027
+ async function deleteMapping(index) {
2028
+ const mapping = mappings[index];
2029
+ if (!mapping) return;
2030
+
2031
+ if (!confirm(t('actions.deleteConfirm', { name: mapping.name }))) {
844
2032
  return;
845
2033
  }
846
2034
 
847
2035
  try {
848
- const response = await fetch(`/api/mappings/${identifier}`, {
2036
+ const data = await api(`/api/mappings/${encodeURIComponent(mapping.id)}`, {
849
2037
  method: 'DELETE'
850
2038
  });
851
2039
 
852
- const data = await response.json();
853
-
854
2040
  if (data.success) {
855
- loadMappings();
2041
+ showToast(t('actions.deleted'), 'success');
2042
+ refreshAll();
856
2043
  } else {
857
- alert('删除失败: ' + data.message);
2044
+ showToast(t('actions.deleteFailed', { message: data.message }), 'error');
858
2045
  }
859
2046
  } catch (error) {
860
- alert('删除失败,请检查网络连接');
2047
+ if (error.message !== t('common.sessionExpired')) {
2048
+ showToast(t('common.networkError'), 'error');
2049
+ }
861
2050
  }
862
2051
  }
863
2052
 
864
- // 启动端口映射
865
- async function startMapping(identifier) {
2053
+ async function startMapping(index) {
2054
+ const mapping = mappings[index];
2055
+ if (!mapping) return;
2056
+
866
2057
  try {
867
- const response = await fetch(`/api/mappings/${identifier}/start`, {
2058
+ const data = await api(`/api/mappings/${encodeURIComponent(mapping.id)}/start`, {
868
2059
  method: 'POST'
869
2060
  });
870
2061
 
871
- const data = await response.json();
872
-
873
2062
  if (data.success) {
874
- loadMappings();
2063
+ showToast(t('actions.started'), 'success');
2064
+ refreshAll();
875
2065
  } else {
876
- alert('启动失败: ' + data.message);
2066
+ showToast(t('actions.startFailed', { message: data.message }), 'error');
877
2067
  }
878
2068
  } catch (error) {
879
- alert('启动失败,请检查网络连接');
2069
+ if (error.message !== t('common.sessionExpired')) {
2070
+ showToast(t('common.networkError'), 'error');
2071
+ }
880
2072
  }
881
2073
  }
882
2074
 
883
- // 停止端口映射
884
- async function stopMapping(identifier) {
2075
+ async function stopMapping(index) {
2076
+ const mapping = mappings[index];
2077
+ if (!mapping) return;
2078
+
885
2079
  try {
886
- const response = await fetch(`/api/mappings/${identifier}/stop`, {
2080
+ const data = await api(`/api/mappings/${encodeURIComponent(mapping.id)}/stop`, {
887
2081
  method: 'POST'
888
2082
  });
889
2083
 
890
- const data = await response.json();
891
-
892
2084
  if (data.success) {
893
- loadMappings();
2085
+ showToast(t('actions.stopped'), 'success');
2086
+ refreshAll();
894
2087
  } else {
895
- alert('停止失败: ' + data.message);
2088
+ showToast(t('actions.stopFailed', { message: data.message }), 'error');
896
2089
  }
897
2090
  } catch (error) {
898
- alert('停止失败,请检查网络连接');
2091
+ if (error.message !== t('common.sessionExpired')) {
2092
+ showToast(t('common.networkError'), 'error');
2093
+ }
899
2094
  }
900
2095
  }
901
2096
 
902
- // 刷新状态
903
- function refreshStatus() {
904
- loadStatus();
905
- loadMappings();
906
- }
907
-
908
- // 刷新连接
909
- function refreshConnections() {
910
- loadConnections();
911
- }
912
-
913
- // 退出登录
914
2097
  async function logout() {
915
2098
  try {
916
- await fetch('/api/logout', { method: 'POST' });
917
- window.location.href = '/login';
2099
+ await fetch('/api/logout', {
2100
+ method: 'POST',
2101
+ credentials: 'same-origin',
2102
+ headers: { 'X-Language': window.I18n ? window.I18n.getLanguage() : 'zh-CN' }
2103
+ });
918
2104
  } catch (error) {
919
2105
  console.error('退出登录失败:', error);
920
2106
  }
2107
+ window.location.href = '/login';
921
2108
  }
922
2109
 
923
- // ESC键关闭模态框
2110
+ // ESC 关闭模态框
924
2111
  document.addEventListener('keydown', function(e) {
925
2112
  if (e.key === 'Escape') {
926
2113
  closeModal();