protocol-proxy 2.3.4 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config-store.js +295 -225
- package/lib/converters/gemini-to-anthropic.js +286 -277
- package/lib/converters/gemini-to-openai.js +255 -240
- package/lib/converters/openai-to-anthropic.js +368 -329
- package/lib/logger.js +58 -0
- package/lib/proxy-manager.js +4 -0
- package/lib/proxy-server.js +636 -357
- package/lib/stats-store.js +3 -5
- package/package.json +51 -51
- package/public/app.js +1296 -972
- package/public/index.html +321 -277
- package/public/style.css +1448 -1189
- package/server.js +767 -655
package/public/app.js
CHANGED
|
@@ -1,972 +1,1296 @@
|
|
|
1
|
-
let proxies = [];
|
|
2
|
-
let providers = [];
|
|
3
|
-
let editingId = null;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (!
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
async
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
document.getElementById('
|
|
493
|
-
document.getElementById('
|
|
494
|
-
document.getElementById('
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function
|
|
546
|
-
const container = document.getElementById('
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
document.getElementById('
|
|
833
|
-
document.getElementById('
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1
|
+
let proxies = [];
|
|
2
|
+
let providers = [];
|
|
3
|
+
let editingId = null;
|
|
4
|
+
let editingProviderId = null;
|
|
5
|
+
let importData = null;
|
|
6
|
+
let statsRange = 'daily';
|
|
7
|
+
let statsProxyId = '';
|
|
8
|
+
let providerPoolItems = [];
|
|
9
|
+
|
|
10
|
+
// ==================== 数据加载 ====================
|
|
11
|
+
|
|
12
|
+
async function loadProxies() {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch('/api/proxies');
|
|
15
|
+
proxies = await res.json();
|
|
16
|
+
renderProxies();
|
|
17
|
+
updateStats();
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error('加载代理失败:', err);
|
|
20
|
+
document.getElementById('proxy-list').innerHTML =
|
|
21
|
+
'<div class="empty">加载失败,请刷新重试</div>';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadProviders() {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('/api/providers');
|
|
28
|
+
providers = await res.json();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('加载供应商失败:', err);
|
|
31
|
+
providers = [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updateStats() {
|
|
36
|
+
document.getElementById('stat-total').textContent = proxies.length;
|
|
37
|
+
document.getElementById('stat-running').textContent =
|
|
38
|
+
proxies.filter(p => p.running).length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseProviderPool(value) {
|
|
42
|
+
const text = (value || '').trim();
|
|
43
|
+
if (!text) return [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const items = [];
|
|
46
|
+
for (const part of text.split(/[\n,]/)) {
|
|
47
|
+
const token = part.trim();
|
|
48
|
+
if (!token) continue;
|
|
49
|
+
const [providerIdRaw, modelRaw, weightRaw] = token.split(':');
|
|
50
|
+
const providerId = (providerIdRaw || '').trim();
|
|
51
|
+
if (!providerId) continue;
|
|
52
|
+
const model = (modelRaw || '').trim();
|
|
53
|
+
const key = `${providerId}\0${model}`;
|
|
54
|
+
if (seen.has(key)) continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
items.push({ providerId, model, weight: Math.max(1, parseInt((weightRaw || '1').trim(), 10) || 1) });
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatProviderPool(pool) {
|
|
62
|
+
if (!Array.isArray(pool) || pool.length === 0) return '';
|
|
63
|
+
return pool.map(item => {
|
|
64
|
+
const w = Math.max(1, parseInt(item.weight, 10) || 1);
|
|
65
|
+
return item.model ? `${item.providerId}:${item.model}:${w}` : `${item.providerId}::${w}`;
|
|
66
|
+
}).join(', ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function syncSimpleDropdown(dropdownId, value, hiddenInputId) {
|
|
70
|
+
const dropdown = document.getElementById(dropdownId);
|
|
71
|
+
if (!dropdown) return value;
|
|
72
|
+
const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
|
|
73
|
+
const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
|
|
74
|
+
const options = Array.from(dropdown.querySelectorAll('.model-option'));
|
|
75
|
+
const nextValue = options.some(opt => opt.dataset.value === value)
|
|
76
|
+
? value
|
|
77
|
+
: (options[0]?.dataset.value || '');
|
|
78
|
+
options.forEach(opt => opt.classList.toggle('selected', opt.dataset.value === nextValue));
|
|
79
|
+
if (hiddenInput) hiddenInput.value = nextValue;
|
|
80
|
+
const selected = options.find(opt => opt.dataset.value === nextValue);
|
|
81
|
+
if (valueEl && selected) {
|
|
82
|
+
valueEl.textContent = selected.querySelector('.model-option-name')?.textContent || valueEl.textContent;
|
|
83
|
+
}
|
|
84
|
+
return nextValue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function syncProviderPoolState(items) {
|
|
88
|
+
providerPoolItems = Array.isArray(items)
|
|
89
|
+
? items
|
|
90
|
+
.filter(item => item && item.providerId)
|
|
91
|
+
.map(item => ({
|
|
92
|
+
providerId: item.providerId,
|
|
93
|
+
model: typeof item.model === 'string' ? item.model : '',
|
|
94
|
+
weight: Math.max(1, parseInt(item.weight, 10) || 1),
|
|
95
|
+
}))
|
|
96
|
+
: [];
|
|
97
|
+
renderProviderPoolEditor();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function addProviderToPool(providerId, model) {
|
|
101
|
+
if (!providerId) return;
|
|
102
|
+
const m = model || '';
|
|
103
|
+
if (providerPoolItems.some(item => item.providerId === providerId && (item.model || '') === m)) return;
|
|
104
|
+
providerPoolItems = [...providerPoolItems, { providerId, model: m, weight: 1 }];
|
|
105
|
+
renderProviderPoolEditor();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function removeProviderFromPool(providerId, model) {
|
|
109
|
+
const m = model || '';
|
|
110
|
+
providerPoolItems = providerPoolItems.filter(item => !(item.providerId === providerId && (item.model || '') === m));
|
|
111
|
+
renderProviderPoolEditor();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateProviderPoolWeight(providerId, model, weight) {
|
|
115
|
+
const m = model || '';
|
|
116
|
+
providerPoolItems = providerPoolItems.map(item => (
|
|
117
|
+
item.providerId === providerId && (item.model || '') === m
|
|
118
|
+
? { ...item, weight: Math.max(1, parseInt(weight, 10) || 1) }
|
|
119
|
+
: item
|
|
120
|
+
));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderProviderPoolEditor() {
|
|
124
|
+
const container = document.getElementById('provider-pool-list');
|
|
125
|
+
const select = document.getElementById('provider-pool-dropdown-options');
|
|
126
|
+
const valueEl = document.getElementById('provider-pool-dropdown-value');
|
|
127
|
+
const dropdown = document.getElementById('provider-pool-dropdown');
|
|
128
|
+
if (!container || !select || !valueEl || !dropdown) return;
|
|
129
|
+
|
|
130
|
+
const primaryId = document.getElementById('provider-id').value;
|
|
131
|
+
const defaultModel = document.getElementById('target-model').value;
|
|
132
|
+
// All providers available (including primary, for different models)
|
|
133
|
+
const available = providers.filter(p => p.id);
|
|
134
|
+
|
|
135
|
+
// Build dropdown: show providers, each expandable to models
|
|
136
|
+
select.innerHTML = available.length === 0
|
|
137
|
+
? '<div class="model-option"><span class="model-option-name">暂无可添加供应商</span></div>'
|
|
138
|
+
: available.map(p => {
|
|
139
|
+
const models = p.models || [];
|
|
140
|
+
const isPrimary = p.id === primaryId;
|
|
141
|
+
// Filter out already-added provider+model combos
|
|
142
|
+
const usedModels = new Set(
|
|
143
|
+
providerPoolItems
|
|
144
|
+
.filter(item => item.providerId === p.id)
|
|
145
|
+
.map(item => item.model || '')
|
|
146
|
+
);
|
|
147
|
+
// For primary provider, also exclude its default model (already in use)
|
|
148
|
+
if (isPrimary && defaultModel) usedModels.add(defaultModel);
|
|
149
|
+
const availModels = models.filter(m => !usedModels.has(m));
|
|
150
|
+
// "any model" not available for primary (already covered by defaultModel)
|
|
151
|
+
const anyModelUsed = usedModels.has('');
|
|
152
|
+
const showAnyModel = !isPrimary && !anyModelUsed;
|
|
153
|
+
return `
|
|
154
|
+
<div class="pool-provider-group" data-pool-provider="${escapeHtml(p.id)}">
|
|
155
|
+
<div class="model-option pool-provider-trigger" data-pool-provider-id="${escapeHtml(p.id)}">
|
|
156
|
+
<span class="model-option-name">${escapeHtml(p.name)}</span>
|
|
157
|
+
${p.url ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>` : ''}
|
|
158
|
+
<span class="pool-provider-arrow">▸</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="pool-model-sublist" data-pool-models-for="${escapeHtml(p.id)}">
|
|
161
|
+
${showAnyModel ? `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model=""><span class="model-option-name">不指定模型(使用请求模型)</span></div>` : ''}
|
|
162
|
+
${availModels.map(m => `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model="${escapeHtml(m)}"><span class="model-option-name">${escapeHtml(m)}</span></div>`).join('')}
|
|
163
|
+
${availModels.length === 0 && !showAnyModel ? '<div class="model-option"><span class="model-option-name">该供应商所有模型已添加</span></div>' : ''}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
}).join('');
|
|
168
|
+
|
|
169
|
+
valueEl.textContent = available.length === 0 ? '暂无可添加供应商' : '从供应商列表添加';
|
|
170
|
+
|
|
171
|
+
// Provider click → toggle model sub-list
|
|
172
|
+
select.querySelectorAll('.pool-provider-trigger').forEach(trigger => {
|
|
173
|
+
trigger.addEventListener('click', (e) => {
|
|
174
|
+
e.stopPropagation();
|
|
175
|
+
const group = trigger.closest('.pool-provider-group');
|
|
176
|
+
const wasOpen = group.classList.contains('open');
|
|
177
|
+
// Close all other sub-lists
|
|
178
|
+
select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
|
|
179
|
+
if (!wasOpen) group.classList.add('open');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Model click → add to pool
|
|
184
|
+
select.querySelectorAll('.pool-model-option').forEach(opt => {
|
|
185
|
+
opt.addEventListener('click', () => {
|
|
186
|
+
addProviderToPool(opt.dataset.poolProviderId, opt.dataset.poolModel || '');
|
|
187
|
+
dropdown.classList.remove('open');
|
|
188
|
+
select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Render pool items
|
|
193
|
+
container.innerHTML = providerPoolItems.length === 0
|
|
194
|
+
? '<div class="provider-pool-empty">暂无备选供应商,使用上方下拉框添加</div>'
|
|
195
|
+
: providerPoolItems.map(item => {
|
|
196
|
+
const provider = providers.find(p => p.id === item.providerId);
|
|
197
|
+
const modelLabel = item.model || '使用请求模型';
|
|
198
|
+
return `
|
|
199
|
+
<div class="provider-pool-item">
|
|
200
|
+
<div class="provider-pool-main">
|
|
201
|
+
<div class="provider-pool-name">${escapeHtml(provider?.name || item.providerId)}</div>
|
|
202
|
+
<div class="provider-pool-meta">${escapeHtml(provider?.url || '')}</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="provider-pool-model">
|
|
205
|
+
<label>模型</label>
|
|
206
|
+
<span class="provider-pool-model-value">${escapeHtml(modelLabel)}</span>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="provider-pool-weight">
|
|
209
|
+
<label>权重</label>
|
|
210
|
+
<input type="number" min="1" step="1" value="${Math.max(1, parseInt(item.weight, 10) || 1)}" data-weight-provider="${escapeHtml(item.providerId)}" data-weight-model="${escapeHtml(item.model || '')}">
|
|
211
|
+
</div>
|
|
212
|
+
<button type="button" class="provider-pool-remove" data-remove-provider="${escapeHtml(item.providerId)}" data-remove-model="${escapeHtml(item.model || '')}">移除</button>
|
|
213
|
+
</div>
|
|
214
|
+
`;
|
|
215
|
+
}).join('');
|
|
216
|
+
|
|
217
|
+
container.querySelectorAll('[data-weight-provider]').forEach(input => {
|
|
218
|
+
const handler = () => updateProviderPoolWeight(input.dataset.weightProvider, input.dataset.weightModel, input.value);
|
|
219
|
+
input.addEventListener('change', handler);
|
|
220
|
+
input.addEventListener('input', handler);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
container.querySelectorAll('[data-remove-provider]').forEach(btn => {
|
|
224
|
+
btn.addEventListener('click', () => removeProviderFromPool(btn.dataset.removeProvider, btn.dataset.removeModel));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ==================== 供应商下拉框 ====================
|
|
229
|
+
|
|
230
|
+
function initProviderDropdown() {
|
|
231
|
+
const trigger = document.getElementById('provider-dropdown-trigger');
|
|
232
|
+
const dropdown = document.getElementById('provider-dropdown');
|
|
233
|
+
const addNameInput = document.getElementById('provider-add-name');
|
|
234
|
+
const addUrlInput = document.getElementById('provider-add-url');
|
|
235
|
+
const addBtn = document.getElementById('provider-add-btn');
|
|
236
|
+
|
|
237
|
+
trigger.addEventListener('click', (e) => {
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
dropdown.classList.toggle('open');
|
|
240
|
+
if (dropdown.classList.contains('open')) {
|
|
241
|
+
editingProviderId = null;
|
|
242
|
+
addNameInput.value = '';
|
|
243
|
+
addUrlInput.value = '';
|
|
244
|
+
addUrlInput.disabled = false;
|
|
245
|
+
addBtn.textContent = '添加';
|
|
246
|
+
renderProviderOptions();
|
|
247
|
+
addNameInput.focus();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
document.addEventListener('click', (e) => {
|
|
252
|
+
if (!dropdown.contains(e.target)) {
|
|
253
|
+
dropdown.classList.remove('open');
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
addBtn.addEventListener('click', async () => {
|
|
258
|
+
const name = addNameInput.value.trim();
|
|
259
|
+
const url = addUrlInput.value.trim();
|
|
260
|
+
if (!name || !url) {
|
|
261
|
+
showToast('请填写供应商名称和地址', true);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
let res;
|
|
266
|
+
if (editingProviderId) {
|
|
267
|
+
// 更新模式
|
|
268
|
+
res = await fetch(`/api/providers/${editingProviderId}`, {
|
|
269
|
+
method: 'PUT',
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
271
|
+
body: JSON.stringify({ name }),
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
// 新增模式
|
|
275
|
+
res = await fetch('/api/providers', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ name, url }),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
const err = await res.json();
|
|
283
|
+
showToast(err.error || '操作失败', true);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const provider = await res.json();
|
|
287
|
+
editingProviderId = null;
|
|
288
|
+
addUrlInput.disabled = false;
|
|
289
|
+
addBtn.textContent = '添加';
|
|
290
|
+
addNameInput.value = '';
|
|
291
|
+
addUrlInput.value = '';
|
|
292
|
+
await loadProviders();
|
|
293
|
+
selectProvider(provider.id);
|
|
294
|
+
renderProviderOptions();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
showToast('操作失败: ' + err.message, true);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
addUrlInput.addEventListener('keydown', (e) => {
|
|
301
|
+
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
|
|
302
|
+
if (e.key === 'Escape') dropdown.classList.remove('open');
|
|
303
|
+
});
|
|
304
|
+
addNameInput.addEventListener('keydown', (e) => {
|
|
305
|
+
if (e.key === 'Escape') dropdown.classList.remove('open');
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderProviderOptions() {
|
|
310
|
+
const container = document.getElementById('provider-dropdown-options');
|
|
311
|
+
const currentId = document.getElementById('provider-id').value;
|
|
312
|
+
|
|
313
|
+
container.innerHTML = providers.map(p => `
|
|
314
|
+
<div class="model-option${p.id === currentId ? ' selected' : ''}" data-id="${escapeHtml(p.id)}">
|
|
315
|
+
<span class="model-option-name">${escapeHtml(p.name)}</span>
|
|
316
|
+
${p.name !== p.url ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>` : ''}
|
|
317
|
+
<span style="margin-left:auto;display:flex;gap:4px">
|
|
318
|
+
<button type="button" class="model-option-delete" data-edit-id="${escapeHtml(p.id)}" title="编辑此供应商" style="color:#60a5fa;font-size:14px">✎</button>
|
|
319
|
+
<button type="button" class="model-option-delete" data-delete-id="${escapeHtml(p.id)}" title="删除此供应商">×</button>
|
|
320
|
+
</span>
|
|
321
|
+
</div>
|
|
322
|
+
`).join('');
|
|
323
|
+
|
|
324
|
+
if (providers.length === 0) {
|
|
325
|
+
container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无供应商,请在下方添加</div>';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
container.querySelectorAll('.model-option').forEach(opt => {
|
|
329
|
+
opt.addEventListener('click', (e) => {
|
|
330
|
+
if (e.target.closest('.model-option-delete')) return;
|
|
331
|
+
selectProvider(opt.dataset.id);
|
|
332
|
+
document.getElementById('provider-dropdown').classList.remove('open');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// 编辑供应商
|
|
337
|
+
container.querySelectorAll('[data-edit-id]').forEach(btn => {
|
|
338
|
+
btn.addEventListener('click', async (e) => {
|
|
339
|
+
e.stopPropagation();
|
|
340
|
+
const id = btn.dataset.editId;
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(`/api/providers/${id}`);
|
|
343
|
+
if (!res.ok) throw new Error('加载失败');
|
|
344
|
+
const p = await res.json();
|
|
345
|
+
editingProviderId = id;
|
|
346
|
+
document.getElementById('provider-add-name').value = p.name;
|
|
347
|
+
document.getElementById('provider-add-url').value = p.url;
|
|
348
|
+
document.getElementById('provider-add-url').disabled = true;
|
|
349
|
+
document.getElementById('provider-add-btn').textContent = '更新';
|
|
350
|
+
} catch (err) {
|
|
351
|
+
showToast('加载供应商失败: ' + err.message, true);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// 删除供应商
|
|
357
|
+
container.querySelectorAll('[data-delete-id]').forEach(btn => {
|
|
358
|
+
btn.addEventListener('click', async (e) => {
|
|
359
|
+
e.stopPropagation();
|
|
360
|
+
const id = btn.dataset.deleteId;
|
|
361
|
+
const p = providers.find(pr => pr.id === id);
|
|
362
|
+
const ok = await showConfirm(`确定要删除供应商 <strong>${escapeHtml(p?.name || '')}</strong> 吗?`);
|
|
363
|
+
if (!ok) return;
|
|
364
|
+
try {
|
|
365
|
+
const res = await fetch(`/api/providers/${id}`, { method: 'DELETE' });
|
|
366
|
+
if (!res.ok) {
|
|
367
|
+
const err = await res.json();
|
|
368
|
+
showToast(err.error || '删除失败', true);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
await loadProviders();
|
|
372
|
+
if (document.getElementById('provider-id').value === id) {
|
|
373
|
+
selectProvider('');
|
|
374
|
+
}
|
|
375
|
+
renderProviderOptions();
|
|
376
|
+
} catch (err) {
|
|
377
|
+
showToast('删除失败: ' + err.message, true);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderApiKeys(provider) {
|
|
384
|
+
const container = document.getElementById('api-keys-list');
|
|
385
|
+
if (!container) return;
|
|
386
|
+
const keys = provider?.apiKeys || [];
|
|
387
|
+
const hasKeys = keys.length > 0;
|
|
388
|
+
const items = hasKeys ? keys : [{ alias: '', masked: false, key: '', index: 0 }];
|
|
389
|
+
container.innerHTML = items.map((k, i) => `
|
|
390
|
+
<div class="form-row api-key-entry" data-index="${k.index ?? i}" data-masked="${k.masked ? 'true' : 'false'}" ${!hasKeys ? 'data-new="true"' : ''}>
|
|
391
|
+
<div class="form-group">
|
|
392
|
+
<label>别名</label>
|
|
393
|
+
<input type="text" class="api-key-alias" value="${escapeHtml(k.alias || '')}" placeholder="可选">
|
|
394
|
+
</div>
|
|
395
|
+
<div class="form-group">
|
|
396
|
+
<label>API Key</label>
|
|
397
|
+
${k.masked
|
|
398
|
+
? `<span class="api-key-display">****</span>`
|
|
399
|
+
: `<input type="password" class="api-key-input" value="${escapeHtml(k.key || '')}" placeholder="sk-...">`
|
|
400
|
+
}
|
|
401
|
+
</div>
|
|
402
|
+
<label class="toggle-switch" title="${k.enabled !== false ? '已启用' : '已禁用'}">
|
|
403
|
+
<input type="checkbox" class="api-key-enabled" ${k.enabled !== false ? 'checked' : ''}>
|
|
404
|
+
<span class="toggle-slider"></span>
|
|
405
|
+
</label>
|
|
406
|
+
<button type="button" class="api-key-remove" title="移除">×</button>
|
|
407
|
+
</div>
|
|
408
|
+
`).join('');
|
|
409
|
+
|
|
410
|
+
container.querySelectorAll('.api-key-remove').forEach(btn => {
|
|
411
|
+
btn.addEventListener('click', () => {
|
|
412
|
+
btn.closest('.api-key-entry').remove();
|
|
413
|
+
if (container.children.length === 0) renderApiKeys(null);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function collectApiKeys() {
|
|
419
|
+
const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
|
|
420
|
+
return Array.from(rows).map(row => {
|
|
421
|
+
const alias = row.querySelector('.api-key-alias')?.value.trim() || '';
|
|
422
|
+
const enabled = row.querySelector('.api-key-enabled')?.checked !== false;
|
|
423
|
+
const isMasked = row.dataset.masked === 'true';
|
|
424
|
+
if (isMasked) {
|
|
425
|
+
return { alias, masked: true, index: parseInt(row.dataset.index, 10), enabled };
|
|
426
|
+
}
|
|
427
|
+
const key = row.querySelector('.api-key-input')?.value.trim() || '';
|
|
428
|
+
if (!key) return null;
|
|
429
|
+
return { key, alias, enabled };
|
|
430
|
+
}).filter(Boolean);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function initApiKeyAddBtn() {
|
|
434
|
+
const btn = document.getElementById('api-key-add-btn');
|
|
435
|
+
if (!btn) return;
|
|
436
|
+
btn.addEventListener('click', () => {
|
|
437
|
+
const container = document.getElementById('api-keys-list');
|
|
438
|
+
const row = document.createElement('div');
|
|
439
|
+
row.className = 'form-row api-key-entry';
|
|
440
|
+
row.dataset.new = 'true';
|
|
441
|
+
row.innerHTML = `
|
|
442
|
+
<div class="form-group"><label>别名</label><input type="text" class="api-key-alias" placeholder="可选"></div>
|
|
443
|
+
<div class="form-group"><label>API Key</label><input type="password" class="api-key-input" placeholder="sk-..."></div>
|
|
444
|
+
<label class="toggle-switch" title="已启用"><input type="checkbox" class="api-key-enabled" checked><span class="toggle-slider"></span></label>
|
|
445
|
+
<button type="button" class="api-key-remove" title="移除">×</button>
|
|
446
|
+
`;
|
|
447
|
+
container.appendChild(row);
|
|
448
|
+
row.querySelector('.api-key-alias').focus();
|
|
449
|
+
row.querySelector('.api-key-remove').addEventListener('click', () => {
|
|
450
|
+
row.remove();
|
|
451
|
+
if (container.children.length === 0) renderApiKeys(null);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function selectProvider(id) {
|
|
457
|
+
const provider = providers.find(p => p.id === id);
|
|
458
|
+
document.getElementById('provider-id').value = id || '';
|
|
459
|
+
const protocol = provider ? provider.protocol : '';
|
|
460
|
+
document.getElementById('target-protocol').value = protocol;
|
|
461
|
+
// 同步协议自定义下拉框
|
|
462
|
+
document.querySelectorAll('#protocol-dropdown .model-option').forEach(o => o.classList.remove('selected'));
|
|
463
|
+
const protoOpt = document.querySelector(`#protocol-dropdown .model-option[data-value="${protocol}"]`);
|
|
464
|
+
if (protoOpt) {
|
|
465
|
+
protoOpt.classList.add('selected');
|
|
466
|
+
document.getElementById('protocol-dropdown-value').textContent = protoOpt.querySelector('.model-option-name').textContent;
|
|
467
|
+
} else {
|
|
468
|
+
document.getElementById('protocol-dropdown-value').textContent = '选择协议...';
|
|
469
|
+
}
|
|
470
|
+
document.getElementById('provider-dropdown-value').textContent = provider
|
|
471
|
+
? (provider.name !== provider.url ? `${provider.name} - ${provider.url}` : provider.url)
|
|
472
|
+
: '选择供应商...';
|
|
473
|
+
// 切换供应商后模型自动选为该供应商模型列表的第一个
|
|
474
|
+
const models = provider?.models || [];
|
|
475
|
+
selectModel(models[0] || '');
|
|
476
|
+
updateModelAddState();
|
|
477
|
+
// 同步 API Keys
|
|
478
|
+
renderApiKeys(provider);
|
|
479
|
+
// 同步 Azure 字段
|
|
480
|
+
document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
|
|
481
|
+
document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
|
|
482
|
+
document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
|
|
483
|
+
// Only remove pool entries matching this provider's default model (allow other models)
|
|
484
|
+
const currentModel = models[0] || '';
|
|
485
|
+
providerPoolItems = providerPoolItems.filter(item => !(item.providerId === id && (!item.model || item.model === currentModel)));
|
|
486
|
+
renderProviderPoolEditor();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ==================== Model 下拉框 ====================
|
|
490
|
+
|
|
491
|
+
function initModelDropdown() {
|
|
492
|
+
const trigger = document.getElementById('model-dropdown-trigger');
|
|
493
|
+
const dropdown = document.getElementById('model-dropdown');
|
|
494
|
+
const addInput = document.getElementById('model-add-input');
|
|
495
|
+
const addBtn = document.getElementById('model-add-btn');
|
|
496
|
+
|
|
497
|
+
trigger.addEventListener('click', (e) => {
|
|
498
|
+
e.stopPropagation();
|
|
499
|
+
dropdown.classList.toggle('open');
|
|
500
|
+
if (dropdown.classList.contains('open')) {
|
|
501
|
+
addInput.value = '';
|
|
502
|
+
addInput.focus();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
document.addEventListener('click', (e) => {
|
|
507
|
+
if (!dropdown.contains(e.target)) {
|
|
508
|
+
dropdown.classList.remove('open');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
addBtn.addEventListener('click', async () => {
|
|
513
|
+
const providerId = document.getElementById('provider-id').value;
|
|
514
|
+
if (!providerId) {
|
|
515
|
+
showToast('请先选择供应商', true);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const name = addInput.value.trim();
|
|
519
|
+
if (!name) return;
|
|
520
|
+
const provider = providers.find(p => p.id === providerId);
|
|
521
|
+
if (!provider) return;
|
|
522
|
+
const models = [...(provider.models || []), name];
|
|
523
|
+
try {
|
|
524
|
+
await fetch(`/api/providers/${providerId}`, {
|
|
525
|
+
method: 'PUT',
|
|
526
|
+
headers: { 'Content-Type': 'application/json' },
|
|
527
|
+
body: JSON.stringify({ models }),
|
|
528
|
+
});
|
|
529
|
+
await loadProviders();
|
|
530
|
+
selectModel(name);
|
|
531
|
+
renderModelOptions();
|
|
532
|
+
addInput.value = '';
|
|
533
|
+
addInput.focus();
|
|
534
|
+
} catch (err) {
|
|
535
|
+
showToast('添加模型失败: ' + err.message, true);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
addInput.addEventListener('keydown', (e) => {
|
|
540
|
+
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
|
|
541
|
+
if (e.key === 'Escape') dropdown.classList.remove('open');
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function renderModelOptions() {
|
|
546
|
+
const container = document.getElementById('model-dropdown-options');
|
|
547
|
+
const providerId = document.getElementById('provider-id').value;
|
|
548
|
+
const provider = providers.find(p => p.id === providerId);
|
|
549
|
+
const models = provider?.models || [];
|
|
550
|
+
const current = document.getElementById('target-model').value;
|
|
551
|
+
|
|
552
|
+
if (!providerId) {
|
|
553
|
+
container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">请先选择供应商</div>';
|
|
554
|
+
} else if (models.length === 0) {
|
|
555
|
+
container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无模型,请在下方添加</div>';
|
|
556
|
+
} else {
|
|
557
|
+
container.innerHTML = models.map(m => `
|
|
558
|
+
<div class="model-option${m === current ? ' selected' : ''}" data-model="${escapeHtml(m)}">
|
|
559
|
+
<span class="model-option-name">${escapeHtml(m)}</span>
|
|
560
|
+
<button type="button" class="model-option-delete" data-delete="${escapeHtml(m)}" title="删除此模型">×</button>
|
|
561
|
+
</div>
|
|
562
|
+
`).join('');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
container.querySelectorAll('.model-option').forEach(opt => {
|
|
566
|
+
opt.addEventListener('click', (e) => {
|
|
567
|
+
if (e.target.closest('.model-option-delete')) return;
|
|
568
|
+
selectModel(opt.dataset.model);
|
|
569
|
+
document.getElementById('model-dropdown').classList.remove('open');
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
container.querySelectorAll('.model-option-delete').forEach(btn => {
|
|
574
|
+
btn.addEventListener('click', async (e) => {
|
|
575
|
+
e.stopPropagation();
|
|
576
|
+
const name = btn.dataset.delete;
|
|
577
|
+
const ok = await showConfirm(`确定要删除模型 <strong>${escapeHtml(name)}</strong> 吗?`);
|
|
578
|
+
if (!ok) return;
|
|
579
|
+
const provider = providers.find(p => p.id === providerId);
|
|
580
|
+
if (!provider) return;
|
|
581
|
+
const models = (provider.models || []).filter(m => m !== name);
|
|
582
|
+
try {
|
|
583
|
+
await fetch(`/api/providers/${providerId}`, {
|
|
584
|
+
method: 'PUT',
|
|
585
|
+
headers: { 'Content-Type': 'application/json' },
|
|
586
|
+
body: JSON.stringify({ models }),
|
|
587
|
+
});
|
|
588
|
+
await loadProviders();
|
|
589
|
+
if (document.getElementById('target-model').value === name) {
|
|
590
|
+
selectModel('');
|
|
591
|
+
}
|
|
592
|
+
renderModelOptions();
|
|
593
|
+
} catch (err) {
|
|
594
|
+
showToast('删除模型失败: ' + err.message, true);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function selectModel(value) {
|
|
601
|
+
document.getElementById('target-model').value = value || '';
|
|
602
|
+
document.getElementById('model-dropdown-value').textContent = value || '选择模型...';
|
|
603
|
+
renderModelOptions();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function updateModelAddState() {
|
|
607
|
+
const providerId = document.getElementById('provider-id').value;
|
|
608
|
+
const addInput = document.getElementById('model-add-input');
|
|
609
|
+
const addBtn = document.getElementById('model-add-btn');
|
|
610
|
+
if (providerId) {
|
|
611
|
+
addInput.disabled = false;
|
|
612
|
+
addBtn.disabled = false;
|
|
613
|
+
addInput.placeholder = '输入模型名称';
|
|
614
|
+
} else {
|
|
615
|
+
addInput.disabled = true;
|
|
616
|
+
addBtn.disabled = true;
|
|
617
|
+
addInput.placeholder = '请先选择供应商';
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ==================== 配置导入/导出 ====================
|
|
622
|
+
|
|
623
|
+
async function exportConfig() {
|
|
624
|
+
try {
|
|
625
|
+
const res = await fetch('/api/config/export');
|
|
626
|
+
const data = await res.json();
|
|
627
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
628
|
+
const url = URL.createObjectURL(blob);
|
|
629
|
+
const a = document.createElement('a');
|
|
630
|
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
631
|
+
a.href = url;
|
|
632
|
+
a.download = `config-backup-${date}.json`;
|
|
633
|
+
a.click();
|
|
634
|
+
URL.revokeObjectURL(url);
|
|
635
|
+
showToast('配置已导出');
|
|
636
|
+
} catch (err) {
|
|
637
|
+
showToast('导出失败: ' + err.message, true);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function handleImportFile(e) {
|
|
642
|
+
const file = e.target.files[0];
|
|
643
|
+
if (!file) return;
|
|
644
|
+
e.target.value = '';
|
|
645
|
+
|
|
646
|
+
const reader = new FileReader();
|
|
647
|
+
reader.onload = () => {
|
|
648
|
+
try {
|
|
649
|
+
const data = JSON.parse(reader.result);
|
|
650
|
+
if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
|
|
651
|
+
showToast('配置格式错误:需要 providers 和 proxies 数组', true);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
importData = data;
|
|
655
|
+
document.getElementById('import-providers-count').textContent = data.providers.length;
|
|
656
|
+
document.getElementById('import-proxies-count').textContent = data.proxies.length;
|
|
657
|
+
document.getElementById('import-modal').classList.add('active');
|
|
658
|
+
} catch (err) {
|
|
659
|
+
showToast('文件解析失败: ' + err.message, true);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
reader.readAsText(file);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function closeImportModal() {
|
|
666
|
+
document.getElementById('import-modal').classList.remove('active');
|
|
667
|
+
importData = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function confirmImport() {
|
|
671
|
+
if (!importData) return;
|
|
672
|
+
const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
|
|
673
|
+
|
|
674
|
+
if (mode === 'overwrite') {
|
|
675
|
+
const ok = await showConfirm('确认<strong>覆盖</strong>现有配置?此操作不可撤销。');
|
|
676
|
+
if (!ok) return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
const res = await fetch('/api/config/import', {
|
|
681
|
+
method: 'POST',
|
|
682
|
+
headers: { 'Content-Type': 'application/json' },
|
|
683
|
+
body: JSON.stringify({ config: importData, mode }),
|
|
684
|
+
});
|
|
685
|
+
const result = await res.json();
|
|
686
|
+
|
|
687
|
+
if (!res.ok) {
|
|
688
|
+
showToast(result.error || '导入失败', true);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
closeImportModal();
|
|
693
|
+
await Promise.all([loadProxies(), loadProviders()]);
|
|
694
|
+
|
|
695
|
+
const added = result.added;
|
|
696
|
+
let msg = `导入成功(${mode === 'overwrite' ? '覆盖' : '合并'})`;
|
|
697
|
+
if (added) msg += `:新增 ${added.providers} 供应商、${added.proxies} 代理`;
|
|
698
|
+
|
|
699
|
+
const restart = await showConfirm(`${msg}。<br><br>运行中的代理需要重启才能应用变更,新增的代理需要手动启动。<br><br>是否立即重启所有代理?`);
|
|
700
|
+
if (restart) {
|
|
701
|
+
await restartAllProxies();
|
|
702
|
+
}
|
|
703
|
+
} catch (err) {
|
|
704
|
+
showToast('导入失败: ' + err.message, true);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function restartAllProxies() {
|
|
709
|
+
try {
|
|
710
|
+
// 先停掉所有运行中的代理(不管 ID 是否匹配新配置)
|
|
711
|
+
const statusRes = await fetch('/api/status');
|
|
712
|
+
const status = await statusRes.json();
|
|
713
|
+
const runningIds = (status.running || []).map(r => r.id);
|
|
714
|
+
for (const id of runningIds) {
|
|
715
|
+
await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
|
|
716
|
+
}
|
|
717
|
+
// 重新加载配置
|
|
718
|
+
await loadProxies();
|
|
719
|
+
// 按新配置启动所有代理
|
|
720
|
+
for (const p of proxies) {
|
|
721
|
+
await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
|
|
722
|
+
}
|
|
723
|
+
await loadProxies();
|
|
724
|
+
showToast('所有代理已重启');
|
|
725
|
+
} catch (err) {
|
|
726
|
+
showToast('重启失败: ' + err.message, true);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ==================== 初始化 ====================
|
|
731
|
+
|
|
732
|
+
// ==================== Token 用量统计 ====================
|
|
733
|
+
|
|
734
|
+
async function loadStats() {
|
|
735
|
+
try {
|
|
736
|
+
const params = new URLSearchParams({ range: statsRange });
|
|
737
|
+
if (statsProxyId) params.set('proxyId', statsProxyId);
|
|
738
|
+
const startDate = document.getElementById('stats-start-date')?.value;
|
|
739
|
+
const endDate = document.getElementById('stats-end-date')?.value;
|
|
740
|
+
if (startDate) params.set('startDate', startDate);
|
|
741
|
+
if (endDate) params.set('endDate', endDate);
|
|
742
|
+
const res = await fetch('/api/stats?' + params);
|
|
743
|
+
const data = await res.json();
|
|
744
|
+
renderStatsSummary(data.summary);
|
|
745
|
+
renderStatsBreakdown(data);
|
|
746
|
+
renderStatsProxyOptions(data.proxies || []);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.error('加载统计失败:', err);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function renderStatsSummary(summary) {
|
|
753
|
+
document.getElementById('stats-total-tokens').textContent = formatTokens(summary.total);
|
|
754
|
+
document.getElementById('stats-prompt-tokens').textContent = formatTokens(summary.prompt);
|
|
755
|
+
document.getElementById('stats-completion-tokens').textContent = formatTokens(summary.completion);
|
|
756
|
+
document.getElementById('stats-total-requests').textContent = summary.requests.toLocaleString();
|
|
757
|
+
const badge = document.getElementById('stats-estimated-badge');
|
|
758
|
+
if (badge) badge.style.display = summary.hasEstimated ? 'inline' : 'none';
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function formatTokens(n) {
|
|
762
|
+
if (!n || n === 0) return '0';
|
|
763
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
764
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
765
|
+
return n.toLocaleString();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function renderStatsBreakdown(data) {
|
|
769
|
+
const container = document.getElementById('stats-breakdown');
|
|
770
|
+
const { byProvider, byModel, summary } = data;
|
|
771
|
+
|
|
772
|
+
if (!byProvider || byProvider.length === 0) {
|
|
773
|
+
container.innerHTML = '<div class="empty">暂无数据</div>';
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let html = '<table class="stats-table"><thead><tr>';
|
|
778
|
+
html += '<th>供应商</th><th>模型</th><th style="text-align:right">请求数</th>';
|
|
779
|
+
html += '<th style="text-align:right">输入 Token</th><th style="text-align:right">输出 Token</th>';
|
|
780
|
+
html += '<th style="text-align:right">合计</th>';
|
|
781
|
+
html += '</tr></thead><tbody>';
|
|
782
|
+
|
|
783
|
+
for (const item of byModel) {
|
|
784
|
+
const prefix = item.hasEstimated ? '~' : '';
|
|
785
|
+
html += '<tr>';
|
|
786
|
+
html += `<td class="provider-cell">${escapeHtml(item.provider)}</td>`;
|
|
787
|
+
html += `<td class="model-cell"><code>${escapeHtml(item.model)}</code></td>`;
|
|
788
|
+
html += `<td class="num">${item.requests.toLocaleString()}</td>`;
|
|
789
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.prompt)}</td>`;
|
|
790
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.completion)}</td>`;
|
|
791
|
+
html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.total)}</td>`;
|
|
792
|
+
html += '</tr>';
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
html += '</tbody>';
|
|
796
|
+
html += '<tfoot><tr>';
|
|
797
|
+
html += '<td colspan="2">合计</td>';
|
|
798
|
+
html += `<td class="num">${summary.requests.toLocaleString()}</td>`;
|
|
799
|
+
html += `<td class="num">${formatTokens(summary.prompt)}</td>`;
|
|
800
|
+
html += `<td class="num">${formatTokens(summary.completion)}</td>`;
|
|
801
|
+
html += `<td class="num">${formatTokens(summary.total)}</td>`;
|
|
802
|
+
html += '</tr></tfoot></table>';
|
|
803
|
+
|
|
804
|
+
container.innerHTML = html;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function renderStatsProxyOptions(proxyList) {
|
|
808
|
+
const container = document.getElementById('stats-proxy-dropdown-options');
|
|
809
|
+
container.innerHTML = `<div class="model-option${!statsProxyId ? ' selected' : ''}" data-proxy-id="">
|
|
810
|
+
<span class="model-option-name">全部代理</span>
|
|
811
|
+
</div>` + proxyList.map(p => `
|
|
812
|
+
<div class="model-option${p.id === statsProxyId ? ' selected' : ''}" data-proxy-id="${escapeHtml(p.id)}">
|
|
813
|
+
<span class="model-option-name">${escapeHtml(p.name)}</span>
|
|
814
|
+
${p.providerName ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.providerName)}</span>` : ''}
|
|
815
|
+
</div>
|
|
816
|
+
`).join('');
|
|
817
|
+
|
|
818
|
+
container.querySelectorAll('.model-option').forEach(opt => {
|
|
819
|
+
opt.addEventListener('click', () => {
|
|
820
|
+
statsProxyId = opt.dataset.proxyId;
|
|
821
|
+
document.getElementById('stats-proxy-dropdown-value').textContent =
|
|
822
|
+
statsProxyId ? (proxyList.find(p => p.id === statsProxyId)?.name || '全部代理') : '全部代理';
|
|
823
|
+
document.getElementById('stats-proxy-dropdown').classList.remove('open');
|
|
824
|
+
container.querySelectorAll('.model-option').forEach(o => o.classList.remove('selected'));
|
|
825
|
+
opt.classList.add('selected');
|
|
826
|
+
loadStats();
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function initStatsDropdown() {
|
|
832
|
+
const trigger = document.getElementById('stats-proxy-dropdown-trigger');
|
|
833
|
+
const dropdown = document.getElementById('stats-proxy-dropdown');
|
|
834
|
+
|
|
835
|
+
trigger.addEventListener('click', (e) => {
|
|
836
|
+
e.stopPropagation();
|
|
837
|
+
dropdown.classList.toggle('open');
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
document.addEventListener('click', (e) => {
|
|
841
|
+
if (!dropdown.contains(e.target)) {
|
|
842
|
+
dropdown.classList.remove('open');
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function initStatsRangeBtns() {
|
|
848
|
+
document.querySelectorAll('.stats-range-btn').forEach(btn => {
|
|
849
|
+
btn.addEventListener('click', () => {
|
|
850
|
+
document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
851
|
+
btn.classList.add('active');
|
|
852
|
+
statsRange = btn.dataset.range;
|
|
853
|
+
// 清空日期选择器,显示全部数据
|
|
854
|
+
document.getElementById('stats-start-date').value = '';
|
|
855
|
+
document.getElementById('stats-end-date').value = '';
|
|
856
|
+
loadStats();
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
// 日期选择器变化时自动加载
|
|
860
|
+
document.getElementById('stats-start-date').addEventListener('change', loadStats);
|
|
861
|
+
document.getElementById('stats-end-date').addEventListener('change', loadStats);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function generateToken() {
|
|
865
|
+
const arr = new Uint8Array(24);
|
|
866
|
+
crypto.getRandomValues(arr);
|
|
867
|
+
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function initSimpleDropdown(dropdownId, onChange, hiddenInputId) {
|
|
871
|
+
const dropdown = document.getElementById(dropdownId);
|
|
872
|
+
const trigger = dropdown.querySelector('.model-dropdown-trigger');
|
|
873
|
+
const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
|
|
874
|
+
const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
|
|
875
|
+
const opts = dropdown.querySelectorAll('.model-option');
|
|
876
|
+
|
|
877
|
+
trigger.addEventListener('click', (e) => {
|
|
878
|
+
e.stopPropagation();
|
|
879
|
+
dropdown.classList.toggle('open');
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
document.addEventListener('click', (e) => {
|
|
883
|
+
if (!dropdown.contains(e.target)) {
|
|
884
|
+
dropdown.classList.remove('open');
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
opts.forEach(opt => {
|
|
889
|
+
opt.addEventListener('click', () => {
|
|
890
|
+
const val = syncSimpleDropdown(dropdownId, opt.dataset.value, hiddenInput?.id);
|
|
891
|
+
onChange?.(val);
|
|
892
|
+
dropdown.classList.remove('open');
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
syncSimpleDropdown(dropdownId, hiddenInput?.value || opts[0]?.dataset.value || '', hiddenInput?.id);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function initProviderPoolDropdown() {
|
|
900
|
+
const dropdown = document.getElementById('provider-pool-dropdown');
|
|
901
|
+
const trigger = document.getElementById('provider-pool-dropdown-trigger');
|
|
902
|
+
if (!dropdown || !trigger) return;
|
|
903
|
+
|
|
904
|
+
trigger.addEventListener('click', (e) => {
|
|
905
|
+
e.stopPropagation();
|
|
906
|
+
renderProviderPoolEditor();
|
|
907
|
+
dropdown.classList.toggle('open');
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
document.addEventListener('click', (e) => {
|
|
911
|
+
if (!dropdown.contains(e.target)) dropdown.classList.remove('open');
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function init() {
|
|
916
|
+
await Promise.all([loadProxies(), loadProviders(), loadStats()]);
|
|
917
|
+
renderProxies();
|
|
918
|
+
initProviderDropdown();
|
|
919
|
+
initModelDropdown();
|
|
920
|
+
initStatsDropdown();
|
|
921
|
+
initStatsRangeBtns();
|
|
922
|
+
initProviderPoolDropdown();
|
|
923
|
+
initApiKeyAddBtn();
|
|
924
|
+
initSimpleDropdown('auth-dropdown', (val) => {
|
|
925
|
+
const enabled = val === 'true';
|
|
926
|
+
document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
|
|
927
|
+
if (enabled && !document.getElementById('proxy-auth-token').value) {
|
|
928
|
+
document.getElementById('proxy-auth-token').value = generateToken();
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
initSimpleDropdown('protocol-dropdown', (val) => {
|
|
932
|
+
document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
|
|
933
|
+
}, 'target-protocol');
|
|
934
|
+
initSimpleDropdown('routing-dropdown', (val) => {
|
|
935
|
+
document.getElementById('routing-strategy').value = val;
|
|
936
|
+
}, 'routing-strategy');
|
|
937
|
+
renderProviderPoolEditor();
|
|
938
|
+
// 初始状态:根据当前协议值决定 Azure 字段显示
|
|
939
|
+
const initProto = document.getElementById('target-protocol').value;
|
|
940
|
+
document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ==================== 代理地址复制 ====================
|
|
944
|
+
|
|
945
|
+
function getProxyUrl(port) {
|
|
946
|
+
return `http://localhost:${port}`;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function copyProxyUrl(port, btn) {
|
|
950
|
+
const url = getProxyUrl(port);
|
|
951
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
952
|
+
showToast('代理地址已复制');
|
|
953
|
+
const orig = btn.innerHTML;
|
|
954
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> 已复制';
|
|
955
|
+
setTimeout(() => { btn.innerHTML = orig; }, 1500);
|
|
956
|
+
}).catch(() => {
|
|
957
|
+
showToast('复制失败,请手动复制', true);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function showConfirm(text) {
|
|
962
|
+
return new Promise(resolve => {
|
|
963
|
+
const modal = document.getElementById('confirm-modal');
|
|
964
|
+
document.getElementById('confirm-text').innerHTML = text;
|
|
965
|
+
modal.classList.add('active');
|
|
966
|
+
|
|
967
|
+
const okBtn = document.getElementById('confirm-ok');
|
|
968
|
+
const cancelBtn = document.getElementById('confirm-cancel');
|
|
969
|
+
|
|
970
|
+
function cleanup(result) {
|
|
971
|
+
modal.classList.remove('active');
|
|
972
|
+
okBtn.removeEventListener('click', onOk);
|
|
973
|
+
cancelBtn.removeEventListener('click', onCancel);
|
|
974
|
+
resolve(result);
|
|
975
|
+
}
|
|
976
|
+
function onOk() { cleanup(true); }
|
|
977
|
+
function onCancel() { cleanup(false); }
|
|
978
|
+
|
|
979
|
+
okBtn.addEventListener('click', onOk);
|
|
980
|
+
cancelBtn.addEventListener('click', onCancel);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function showToast(msg, isError) {
|
|
985
|
+
const existing = document.querySelector('.toast');
|
|
986
|
+
if (existing) existing.remove();
|
|
987
|
+
const toast = document.createElement('div');
|
|
988
|
+
toast.className = 'toast';
|
|
989
|
+
toast.textContent = msg;
|
|
990
|
+
if (isError) toast.style.background = '#ef4444';
|
|
991
|
+
document.body.appendChild(toast);
|
|
992
|
+
setTimeout(() => toast.remove(), 2000);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ==================== 渲染代理列表 ====================
|
|
996
|
+
|
|
997
|
+
const ROUTING_LABELS = {
|
|
998
|
+
primary_fallback: '主备切换',
|
|
999
|
+
round_robin: '轮询',
|
|
1000
|
+
weighted: '加权',
|
|
1001
|
+
fastest: '最快优先',
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
function renderProxies() {
|
|
1005
|
+
const container = document.getElementById('proxy-list');
|
|
1006
|
+
if (proxies.length === 0) {
|
|
1007
|
+
container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
container.innerHTML = proxies.map(p => {
|
|
1012
|
+
// Build unified provider rows: primary first, then pool entries
|
|
1013
|
+
const primaryRow = {
|
|
1014
|
+
name: p.providerName || p.providerUrl || '-',
|
|
1015
|
+
tag: '',
|
|
1016
|
+
protocol: p.protocol || '-',
|
|
1017
|
+
model: p.defaultModel || '-',
|
|
1018
|
+
weight: Math.max(1, parseInt(p.providerWeight, 10) || 1),
|
|
1019
|
+
};
|
|
1020
|
+
const poolRows = (p.providerPool || []).map(item => {
|
|
1021
|
+
const prov = providers.find(pr => pr.id === item.providerId);
|
|
1022
|
+
return {
|
|
1023
|
+
name: prov?.name || item.providerId,
|
|
1024
|
+
tag: '备选',
|
|
1025
|
+
protocol: prov?.protocol || p.protocol || '-',
|
|
1026
|
+
model: item.model || '-',
|
|
1027
|
+
weight: Math.max(1, parseInt(item.weight, 10) || 1),
|
|
1028
|
+
};
|
|
1029
|
+
});
|
|
1030
|
+
const allRows = [primaryRow, ...poolRows];
|
|
1031
|
+
const strategy = ROUTING_LABELS[p.routingStrategy] || p.routingStrategy;
|
|
1032
|
+
|
|
1033
|
+
return `
|
|
1034
|
+
<div class="proxy-item">
|
|
1035
|
+
<div class="proxy-header">
|
|
1036
|
+
<div class="proxy-title">
|
|
1037
|
+
<h3>${escapeHtml(p.name)}</h3>
|
|
1038
|
+
<span class="badge ${p.running ? 'badge-running' : 'badge-stopped'}">
|
|
1039
|
+
${p.running ? '运行中' : '已停止'}
|
|
1040
|
+
</span>
|
|
1041
|
+
</div>
|
|
1042
|
+
<span class="proxy-routing-badge">${escapeHtml(strategy)}</span>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="proxy-meta">
|
|
1045
|
+
<span>端口: <strong>${p.port}</strong></span>
|
|
1046
|
+
<span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
|
|
1047
|
+
</div>
|
|
1048
|
+
<div class="proxy-address">
|
|
1049
|
+
<code>${escapeHtml(getProxyUrl(p.port))}</code>
|
|
1050
|
+
<button class="copy-btn" onclick="copyProxyUrl(${p.port}, this)">
|
|
1051
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
1052
|
+
复制
|
|
1053
|
+
</button>
|
|
1054
|
+
</div>
|
|
1055
|
+
<table class="target-table">
|
|
1056
|
+
<thead>
|
|
1057
|
+
<tr>
|
|
1058
|
+
<th>供应商</th>
|
|
1059
|
+
<th>协议</th>
|
|
1060
|
+
<th>模型</th>
|
|
1061
|
+
<th>权重</th>
|
|
1062
|
+
</tr>
|
|
1063
|
+
</thead>
|
|
1064
|
+
<tbody>
|
|
1065
|
+
${allRows.map(r => `
|
|
1066
|
+
<tr>
|
|
1067
|
+
<td>${escapeHtml(r.name)}${r.tag ? `<span class="provider-tag">${r.tag}</span>` : ''}</td>
|
|
1068
|
+
<td>
|
|
1069
|
+
<span class="badge" style="background:${r.protocol==='openai'?'#0c4a6e':r.protocol==='anthropic'?'#581c87':'#064e3b'};color:${r.protocol==='openai'?'#7dd3fc':r.protocol==='anthropic'?'#e9d5ff':'#6ee7b7'}">
|
|
1070
|
+
${r.protocol}
|
|
1071
|
+
</span>
|
|
1072
|
+
</td>
|
|
1073
|
+
<td><code>${escapeHtml(r.model)}</code></td>
|
|
1074
|
+
<td>${r.weight}</td>
|
|
1075
|
+
</tr>`).join('')}
|
|
1076
|
+
</tbody>
|
|
1077
|
+
</table>
|
|
1078
|
+
<div class="proxy-actions">
|
|
1079
|
+
${p.running
|
|
1080
|
+
? `<button class="btn btn-danger" onclick="stopProxy('${p.id}')">停止</button>`
|
|
1081
|
+
: `<button class="btn btn-success" onclick="startProxy('${p.id}')">启动</button>`
|
|
1082
|
+
}
|
|
1083
|
+
<button class="btn" onclick="editProxy('${p.id}')">编辑</button>
|
|
1084
|
+
<button class="btn btn-danger" onclick="deleteProxy('${p.id}')">删除</button>
|
|
1085
|
+
</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
`}).join('');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ==================== 弹窗操作 ====================
|
|
1091
|
+
|
|
1092
|
+
function openModal(id = null) {
|
|
1093
|
+
editingId = id;
|
|
1094
|
+
document.getElementById('modal').dataset.proxyId = id || '';
|
|
1095
|
+
document.getElementById('modal-title').textContent = id ? '编辑代理' : '新建代理';
|
|
1096
|
+
document.getElementById('proxy-form').reset();
|
|
1097
|
+
|
|
1098
|
+
if (id) {
|
|
1099
|
+
const p = proxies.find(x => x.id === id);
|
|
1100
|
+
if (!p) return;
|
|
1101
|
+
document.getElementById('proxy-id').value = p.id;
|
|
1102
|
+
document.getElementById('proxy-name').value = p.name;
|
|
1103
|
+
document.getElementById('proxy-port').value = p.port;
|
|
1104
|
+
// 同步认证下拉框
|
|
1105
|
+
const authVal = p.requireAuth ? 'true' : 'false';
|
|
1106
|
+
document.getElementById('proxy-auth').value = authVal;
|
|
1107
|
+
document.querySelectorAll('#auth-dropdown .model-option').forEach(o => o.classList.remove('selected'));
|
|
1108
|
+
const authOpt = document.querySelector(`#auth-dropdown .model-option[data-value="${authVal}"]`);
|
|
1109
|
+
if (authOpt) {
|
|
1110
|
+
authOpt.classList.add('selected');
|
|
1111
|
+
document.getElementById('auth-dropdown-value').textContent = authOpt.querySelector('.model-option-name').textContent;
|
|
1112
|
+
}
|
|
1113
|
+
document.getElementById('proxy-auth-token').value = p.authToken || '';
|
|
1114
|
+
document.getElementById('auth-token-group').style.display = p.requireAuth ? 'block' : 'none';
|
|
1115
|
+
selectProvider(p.providerId || '');
|
|
1116
|
+
selectModel(p.defaultModel || '');
|
|
1117
|
+
renderApiKeys(providers.find(pr => pr.id === p.providerId));
|
|
1118
|
+
// Azure 字段从供应商配置读取
|
|
1119
|
+
const provider = providers.find(pr => pr.id === p.providerId);
|
|
1120
|
+
document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
|
|
1121
|
+
document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
|
|
1122
|
+
document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
|
|
1123
|
+
document.getElementById('provider-weight').value = Math.max(1, parseInt(p.providerWeight, 10) || 1);
|
|
1124
|
+
syncSimpleDropdown('routing-dropdown', p.routingStrategy || 'primary_fallback', 'routing-strategy');
|
|
1125
|
+
syncProviderPoolState(p.providerPool || []);
|
|
1126
|
+
} else {
|
|
1127
|
+
document.getElementById('proxy-id').value = '';
|
|
1128
|
+
// 重置认证下拉框
|
|
1129
|
+
document.getElementById('proxy-auth').value = 'false';
|
|
1130
|
+
document.querySelectorAll('#auth-dropdown .model-option').forEach(o => o.classList.remove('selected'));
|
|
1131
|
+
document.querySelector('#auth-dropdown .model-option[data-value="false"]').classList.add('selected');
|
|
1132
|
+
document.getElementById('auth-dropdown-value').textContent = '不启用';
|
|
1133
|
+
document.getElementById('auth-token-group').style.display = 'none';
|
|
1134
|
+
selectProvider('');
|
|
1135
|
+
selectModel('');
|
|
1136
|
+
renderApiKeys(null);
|
|
1137
|
+
document.getElementById('target-azure-deployment').value = '';
|
|
1138
|
+
document.getElementById('target-azure-version').value = '';
|
|
1139
|
+
document.getElementById('azure-fields').style.display = 'none';
|
|
1140
|
+
document.getElementById('provider-weight').value = 1;
|
|
1141
|
+
syncSimpleDropdown('routing-dropdown', 'primary_fallback', 'routing-strategy');
|
|
1142
|
+
syncProviderPoolState([]);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
updateModelAddState();
|
|
1146
|
+
document.getElementById('modal').classList.add('active');
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function closeModal() {
|
|
1150
|
+
document.getElementById('modal').classList.remove('active');
|
|
1151
|
+
document.getElementById('model-dropdown').classList.remove('open');
|
|
1152
|
+
document.getElementById('provider-dropdown').classList.remove('open');
|
|
1153
|
+
document.getElementById('auth-dropdown').classList.remove('open');
|
|
1154
|
+
document.getElementById('protocol-dropdown').classList.remove('open');
|
|
1155
|
+
editingId = null;
|
|
1156
|
+
editingProviderId = null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function handleSubmit(e) {
|
|
1160
|
+
e.preventDefault();
|
|
1161
|
+
|
|
1162
|
+
const providerId = document.getElementById('provider-id').value;
|
|
1163
|
+
if (!providerId) {
|
|
1164
|
+
showToast('请选择供应商', true);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const port = parseInt(document.getElementById('proxy-port').value);
|
|
1169
|
+
|
|
1170
|
+
const conflict = proxies.find(p => p.id !== editingId && p.port === port);
|
|
1171
|
+
if (conflict) {
|
|
1172
|
+
showToast(`端口 ${port} 已被代理「${conflict.name}」占用`, true);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const apiKeys = collectApiKeys();
|
|
1177
|
+
const protocol = document.getElementById('target-protocol').value;
|
|
1178
|
+
const defaultModel = document.getElementById('target-model').value.trim() || '';
|
|
1179
|
+
|
|
1180
|
+
// 同步更新供应商配置
|
|
1181
|
+
const providerUpdates = {};
|
|
1182
|
+
providerUpdates.apiKeys = apiKeys;
|
|
1183
|
+
if (protocol) providerUpdates.protocol = protocol;
|
|
1184
|
+
const azureDeployment = document.getElementById('target-azure-deployment').value.trim();
|
|
1185
|
+
const azureApiVersion = document.getElementById('target-azure-version').value.trim();
|
|
1186
|
+
providerUpdates.azureDeployment = azureDeployment || '';
|
|
1187
|
+
providerUpdates.azureApiVersion = azureApiVersion || '';
|
|
1188
|
+
if (Object.keys(providerUpdates).length > 0) {
|
|
1189
|
+
try {
|
|
1190
|
+
const res = await fetch(`/api/providers/${providerId}`, {
|
|
1191
|
+
method: 'PUT',
|
|
1192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1193
|
+
body: JSON.stringify(providerUpdates),
|
|
1194
|
+
});
|
|
1195
|
+
if (!res.ok) {
|
|
1196
|
+
const err = await res.json();
|
|
1197
|
+
showToast('供应商配置保存失败: ' + (err.error || '未知错误'), true);
|
|
1198
|
+
}
|
|
1199
|
+
await loadProviders();
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
showToast('供应商配置保存失败: ' + err.message, true);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const payload = {
|
|
1206
|
+
name: document.getElementById('proxy-name').value.trim(),
|
|
1207
|
+
port,
|
|
1208
|
+
requireAuth: document.getElementById('proxy-auth').value === 'true',
|
|
1209
|
+
authToken: document.getElementById('proxy-auth-token').value.trim() || null,
|
|
1210
|
+
providerId,
|
|
1211
|
+
defaultModel,
|
|
1212
|
+
providerWeight: Math.max(1, parseInt(document.getElementById('provider-weight').value, 10) || 1),
|
|
1213
|
+
routingStrategy: document.getElementById('routing-strategy').value || 'primary_fallback',
|
|
1214
|
+
providerPool: providerPoolItems,
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
const url = editingId ? `/api/proxies/${editingId}` : '/api/proxies';
|
|
1219
|
+
const method = editingId ? 'PUT' : 'POST';
|
|
1220
|
+
const res = await fetch(url, {
|
|
1221
|
+
method,
|
|
1222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1223
|
+
body: JSON.stringify(payload),
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
const result = await res.json();
|
|
1227
|
+
|
|
1228
|
+
if (!res.ok) {
|
|
1229
|
+
showToast(result.error || '操作失败', true);
|
|
1230
|
+
await loadProxies();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
closeModal();
|
|
1235
|
+
await loadProxies();
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
showToast('网络错误: ' + err.message, true);
|
|
1238
|
+
await loadProxies();
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ==================== 代理操作 ====================
|
|
1243
|
+
|
|
1244
|
+
async function startProxy(id) {
|
|
1245
|
+
try {
|
|
1246
|
+
await fetch(`/api/proxies/${id}/start`, { method: 'POST' });
|
|
1247
|
+
await loadProxies();
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
showToast('启动失败: ' + err.message, true);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function stopProxy(id) {
|
|
1254
|
+
try {
|
|
1255
|
+
await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
|
|
1256
|
+
await loadProxies();
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
showToast('停止失败: ' + err.message, true);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function deleteProxy(id) {
|
|
1263
|
+
const p = proxies.find(x => x.id === id);
|
|
1264
|
+
const ok = await showConfirm(`确定要删除代理配置 <strong>${escapeHtml(p?.name || '')}</strong> 吗?`);
|
|
1265
|
+
if (!ok) return;
|
|
1266
|
+
try {
|
|
1267
|
+
await fetch(`/api/proxies/${id}`, { method: 'DELETE' });
|
|
1268
|
+
await loadProxies();
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
showToast('删除失败: ' + err.message, true);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function editProxy(id) {
|
|
1275
|
+
try {
|
|
1276
|
+
const res = await fetch(`/api/proxies/${id}`);
|
|
1277
|
+
if (!res.ok) throw new Error('加载失败');
|
|
1278
|
+
const full = await res.json();
|
|
1279
|
+
const idx = proxies.findIndex(p => p.id === id);
|
|
1280
|
+
if (idx !== -1) proxies[idx] = { ...proxies[idx], ...full };
|
|
1281
|
+
openModal(id);
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
showToast('加载代理配置失败: ' + err.message, true);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ==================== 工具函数 ====================
|
|
1288
|
+
|
|
1289
|
+
function escapeHtml(text) {
|
|
1290
|
+
if (!text) return '';
|
|
1291
|
+
const div = document.createElement('div');
|
|
1292
|
+
div.textContent = text;
|
|
1293
|
+
return div.innerHTML;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
init();
|