protocol-proxy 2.1.5 → 2.2.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/public/style.css CHANGED
@@ -843,6 +843,288 @@ form {
843
843
  min-width: 90px;
844
844
  }
845
845
 
846
+ /* Card header actions */
847
+ .card-header-actions {
848
+ display: flex;
849
+ gap: 8px;
850
+ align-items: center;
851
+ }
852
+
853
+ /* Import modal */
854
+ .import-preview {
855
+ padding: 24px 28px;
856
+ }
857
+
858
+ .import-stats {
859
+ display: flex;
860
+ gap: 16px;
861
+ margin-bottom: 24px;
862
+ }
863
+
864
+ .import-stat {
865
+ flex: 1;
866
+ background: rgba(6, 8, 15, 0.4);
867
+ border: 1px solid rgba(51, 65, 85, 0.3);
868
+ border-radius: 12px;
869
+ padding: 16px;
870
+ text-align: center;
871
+ }
872
+
873
+ .import-stat-value {
874
+ display: block;
875
+ font-size: 1.8rem;
876
+ font-weight: 700;
877
+ color: #60a5fa;
878
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
879
+ }
880
+
881
+ .import-stat-label {
882
+ color: #64748b;
883
+ font-size: 0.8rem;
884
+ text-transform: uppercase;
885
+ letter-spacing: 0.06em;
886
+ }
887
+
888
+ .import-mode label {
889
+ display: block;
890
+ margin-bottom: 10px;
891
+ color: #94a3b8;
892
+ font-size: 0.85rem;
893
+ font-weight: 500;
894
+ }
895
+
896
+ .import-mode-options {
897
+ display: flex;
898
+ flex-direction: column;
899
+ gap: 10px;
900
+ }
901
+
902
+ .import-mode-option {
903
+ display: flex;
904
+ align-items: flex-start;
905
+ gap: 12px;
906
+ padding: 14px 16px;
907
+ background: rgba(6, 8, 15, 0.4);
908
+ border: 1px solid rgba(51, 65, 85, 0.3);
909
+ border-radius: 10px;
910
+ cursor: pointer;
911
+ transition: all 0.2s;
912
+ }
913
+
914
+ .import-mode-option:hover {
915
+ border-color: rgba(59, 130, 246, 0.3);
916
+ }
917
+
918
+ .import-mode-option input[type="radio"] {
919
+ margin-top: 3px;
920
+ accent-color: #3b82f6;
921
+ }
922
+
923
+ .import-mode-option strong {
924
+ display: block;
925
+ color: #e2e8f0;
926
+ font-size: 0.9rem;
927
+ margin-bottom: 2px;
928
+ }
929
+
930
+ .import-mode-option small {
931
+ color: #64748b;
932
+ font-size: 0.8rem;
933
+ }
934
+
935
+ /* Stats Panel */
936
+ .stats-panel .card-header {
937
+ flex-wrap: wrap;
938
+ gap: 12px;
939
+ }
940
+
941
+ .stats-estimated-badge {
942
+ display: inline-block;
943
+ padding: 2px 10px;
944
+ border-radius: 12px;
945
+ font-size: 0.7rem;
946
+ font-weight: 500;
947
+ background: rgba(251, 191, 36, 0.15);
948
+ color: #fbbf24;
949
+ border: 1px solid rgba(251, 191, 36, 0.2);
950
+ vertical-align: middle;
951
+ letter-spacing: 0.02em;
952
+ }
953
+
954
+ .stats-controls {
955
+ display: flex;
956
+ gap: 12px;
957
+ align-items: center;
958
+ }
959
+
960
+ .stats-controls .model-dropdown {
961
+ width: 180px;
962
+ }
963
+
964
+ .stats-filter-trigger {
965
+ padding: 7px 14px !important;
966
+ font-size: 0.85rem !important;
967
+ background: rgba(51, 65, 85, 0.3) !important;
968
+ }
969
+
970
+ .stats-range-btns {
971
+ display: flex;
972
+ gap: 4px;
973
+ background: rgba(6, 8, 15, 0.4);
974
+ border-radius: 10px;
975
+ padding: 3px;
976
+ border: 1px solid rgba(51, 65, 85, 0.3);
977
+ }
978
+
979
+ .stats-range-btn {
980
+ border: none !important;
981
+ background: transparent !important;
982
+ box-shadow: none !important;
983
+ padding: 6px 16px !important;
984
+ border-radius: 8px !important;
985
+ color: #64748b !important;
986
+ font-size: 0.82rem !important;
987
+ transition: all 0.2s !important;
988
+ }
989
+
990
+ .stats-range-btn:hover {
991
+ color: #94a3b8 !important;
992
+ background: rgba(51, 65, 85, 0.3) !important;
993
+ }
994
+
995
+ .stats-range-btn.active {
996
+ background: rgba(59, 130, 246, 0.2) !important;
997
+ color: #60a5fa !important;
998
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.15) !important;
999
+ }
1000
+
1001
+ .stats-summary {
1002
+ display: grid;
1003
+ grid-template-columns: repeat(4, 1fr);
1004
+ gap: 16px;
1005
+ padding: 0 28px;
1006
+ margin-top: 8px;
1007
+ }
1008
+
1009
+ .stats-summary-item {
1010
+ background: rgba(6, 8, 15, 0.4);
1011
+ border: 1px solid rgba(51, 65, 85, 0.3);
1012
+ border-radius: 12px;
1013
+ padding: 18px 16px;
1014
+ text-align: center;
1015
+ transition: all 0.25s;
1016
+ }
1017
+
1018
+ .stats-summary-item:hover {
1019
+ border-color: rgba(59, 130, 246, 0.25);
1020
+ box-shadow: 0 0 16px rgba(59, 130, 246, 0.06);
1021
+ }
1022
+
1023
+ .stats-summary-value {
1024
+ display: block;
1025
+ font-size: 1.5rem;
1026
+ font-weight: 700;
1027
+ color: #60a5fa;
1028
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1029
+ margin-bottom: 4px;
1030
+ }
1031
+
1032
+ .stats-summary-label {
1033
+ color: #64748b;
1034
+ font-size: 0.78rem;
1035
+ text-transform: uppercase;
1036
+ letter-spacing: 0.06em;
1037
+ }
1038
+
1039
+ .stats-breakdown {
1040
+ padding: 20px 28px 28px;
1041
+ }
1042
+
1043
+ .stats-table {
1044
+ width: 100%;
1045
+ border-collapse: separate;
1046
+ border-spacing: 0;
1047
+ font-size: 0.85rem;
1048
+ border-radius: 10px;
1049
+ overflow: hidden;
1050
+ }
1051
+
1052
+ .stats-table th,
1053
+ .stats-table td {
1054
+ text-align: left;
1055
+ padding: 10px 14px;
1056
+ border-bottom: 1px solid rgba(30, 41, 59, 0.6);
1057
+ }
1058
+
1059
+ .stats-table th {
1060
+ color: #475569;
1061
+ font-weight: 600;
1062
+ text-transform: uppercase;
1063
+ font-size: 0.7rem;
1064
+ letter-spacing: 0.08em;
1065
+ background: rgba(15, 23, 42, 0.4);
1066
+ }
1067
+
1068
+ .stats-table td {
1069
+ color: #94a3b8;
1070
+ word-break: break-all;
1071
+ }
1072
+
1073
+ .stats-table tbody tr {
1074
+ transition: background 0.15s;
1075
+ }
1076
+
1077
+ .stats-table tbody tr:hover {
1078
+ background: rgba(59, 130, 246, 0.04);
1079
+ }
1080
+
1081
+ .stats-table tbody tr:last-child td {
1082
+ border-bottom: none;
1083
+ }
1084
+
1085
+ .stats-table td.num {
1086
+ text-align: right;
1087
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1088
+ font-size: 0.82rem;
1089
+ color: #7dd3fc;
1090
+ }
1091
+
1092
+ .stats-table tfoot td {
1093
+ font-weight: 600;
1094
+ color: #e2e8f0;
1095
+ background: rgba(15, 23, 42, 0.3);
1096
+ }
1097
+
1098
+ .stats-table .provider-cell {
1099
+ color: #a78bfa;
1100
+ }
1101
+
1102
+ .stats-table .model-cell {
1103
+ color: #94a3b8;
1104
+ }
1105
+
1106
+ .num-estimated {
1107
+ color: #fbbf24;
1108
+ font-size: 0.75rem;
1109
+ margin-right: 2px;
1110
+ cursor: help;
1111
+ }
1112
+
1113
+ @media (max-width: 640px) {
1114
+ .stats-summary {
1115
+ grid-template-columns: repeat(2, 1fr);
1116
+ }
1117
+
1118
+ .stats-controls {
1119
+ flex-direction: column;
1120
+ align-items: stretch;
1121
+ }
1122
+
1123
+ .stats-controls .model-dropdown {
1124
+ width: 100%;
1125
+ }
1126
+ }
1127
+
846
1128
  /* Responsive */
847
1129
  @media (max-width: 640px) {
848
1130
  .form-row {
package/server.js CHANGED
@@ -121,6 +121,7 @@ async function init() {
121
121
  const cors = require('cors');
122
122
  const configStore = require('./lib/config-store');
123
123
  const proxyManager = require('./lib/proxy-manager');
124
+ const statsStore = require('./lib/stats-store');
124
125
 
125
126
  const app = express();
126
127
  const PORT = process.env.ADMIN_PORT || 3000;
@@ -167,6 +168,8 @@ async function init() {
167
168
  apiKey: provider.apiKey,
168
169
  defaultModel: proxy.defaultModel,
169
170
  models: provider.models,
171
+ azureDeployment: provider.azureDeployment || '',
172
+ azureApiVersion: provider.azureApiVersion || '',
170
173
  };
171
174
  }
172
175
 
@@ -194,7 +197,7 @@ async function init() {
194
197
  });
195
198
 
196
199
  app.post('/api/providers', (req, res) => {
197
- const { name, url, protocol, apiKey, models } = req.body;
200
+ const { name, url, protocol, apiKey, models, azureDeployment, azureApiVersion } = req.body;
198
201
  if (!name || !url) {
199
202
  return res.status(400).json({ error: 'name and url are required' });
200
203
  }
@@ -203,6 +206,8 @@ async function init() {
203
206
  protocol: protocol || (/anthropic/i.test(url) ? 'anthropic' : 'openai'),
204
207
  apiKey: apiKey || '',
205
208
  models: models || [],
209
+ azureDeployment: azureDeployment || '',
210
+ azureApiVersion: azureApiVersion || '',
206
211
  });
207
212
  res.status(201).json(provider);
208
213
  });
@@ -217,6 +222,8 @@ async function init() {
217
222
  if (req.body.protocol !== undefined) updates.protocol = req.body.protocol;
218
223
  if (req.body.apiKey !== undefined && req.body.apiKey !== '') updates.apiKey = req.body.apiKey;
219
224
  if (req.body.models !== undefined) updates.models = req.body.models;
225
+ if (req.body.azureDeployment !== undefined) updates.azureDeployment = req.body.azureDeployment;
226
+ if (req.body.azureApiVersion !== undefined) updates.azureApiVersion = req.body.azureApiVersion;
220
227
 
221
228
  const updated = configStore.updateProvider(req.params.id, updates);
222
229
 
@@ -415,6 +422,148 @@ async function init() {
415
422
  });
416
423
  });
417
424
 
425
+ // Token 用量统计
426
+ app.get('/api/stats', (req, res) => {
427
+ const { range, startDate, endDate, proxyId } = req.query;
428
+ const stats = statsStore.getStats({
429
+ range: range || 'daily',
430
+ startDate: startDate || undefined,
431
+ endDate: endDate || undefined,
432
+ proxyId: proxyId || undefined,
433
+ });
434
+ const proxies = configStore.getProxies().map(p => ({
435
+ id: p.id,
436
+ name: p.name,
437
+ providerName: configStore.getProviderById(p.providerId)?.name || '',
438
+ }));
439
+ res.json({ ...stats, proxies });
440
+ });
441
+
442
+ // ==================== 配置导入/导出 ====================
443
+
444
+ app.get('/api/config/export', (req, res) => {
445
+ const providers = configStore.getProviders();
446
+ const proxies = configStore.getProxies().map(p => {
447
+ const provider = configStore.getProviderById(p.providerId);
448
+ return {
449
+ id: p.id,
450
+ name: p.name,
451
+ port: p.port,
452
+ requireAuth: p.requireAuth,
453
+ authToken: p.authToken,
454
+ providerId: p.providerId,
455
+ defaultModel: p.defaultModel || '',
456
+ providerName: provider?.name || '',
457
+ };
458
+ });
459
+ res.json({ providers, proxies, exportedAt: new Date().toISOString() });
460
+ });
461
+
462
+ app.post('/api/config/import', async (req, res) => {
463
+ const { config, mode } = req.body;
464
+
465
+ if (!config || !mode || !['overwrite', 'merge'].includes(mode)) {
466
+ return res.status(400).json({ error: '需要 config 和 mode(overwrite/merge)' });
467
+ }
468
+
469
+ // 校验结构
470
+ if (!Array.isArray(config.providers) || !Array.isArray(config.proxies)) {
471
+ return res.status(400).json({ error: '配置格式错误:需要 providers 和 proxies 数组' });
472
+ }
473
+
474
+ for (const p of config.providers) {
475
+ if (!p.name || !p.url || !p.protocol) {
476
+ return res.status(400).json({ error: `供应商 "${p.name || '?'}" 缺少必要字段(name/url/protocol)` });
477
+ }
478
+ }
479
+
480
+ for (const p of config.proxies) {
481
+ if (!p.name || !p.port || !p.providerId) {
482
+ return res.status(400).json({ error: `代理 "${p.name || '?'}" 缺少必要字段(name/port/providerId)` });
483
+ }
484
+ }
485
+
486
+ if (mode === 'overwrite') {
487
+ // 覆盖模式:直接替换整个配置
488
+ const newConfig = {
489
+ providers: config.providers.map(p => ({
490
+ id: p.id,
491
+ name: p.name,
492
+ url: p.url,
493
+ protocol: p.protocol,
494
+ apiKey: p.apiKey || '',
495
+ models: Array.isArray(p.models) ? p.models : [],
496
+ })),
497
+ proxies: config.proxies.map(p => ({
498
+ id: p.id,
499
+ name: p.name,
500
+ port: p.port,
501
+ requireAuth: !!p.requireAuth,
502
+ authToken: p.authToken || null,
503
+ providerId: p.providerId,
504
+ defaultModel: p.defaultModel || '',
505
+ })),
506
+ };
507
+ configStore.saveConfig(newConfig);
508
+ return res.json({ success: true, mode, providers: newConfig.providers.length, proxies: newConfig.proxies.length });
509
+ }
510
+
511
+ // 合并模式:按 ID 去重
512
+ const existingProviders = configStore.getProviders();
513
+ const existingProxies = configStore.getProxies();
514
+
515
+ const providerMap = new Map(existingProviders.map(p => [p.id, p]));
516
+ for (const p of config.providers) {
517
+ providerMap.set(p.id, {
518
+ id: p.id,
519
+ name: p.name,
520
+ url: p.url,
521
+ protocol: p.protocol,
522
+ apiKey: p.apiKey || '',
523
+ models: Array.isArray(p.models) ? p.models : [],
524
+ });
525
+ }
526
+
527
+ const proxyMap = new Map(existingProxies.map(p => [p.id, p]));
528
+ for (const p of config.proxies) {
529
+ // 检查端口冲突:导入的代理端口不能和现有代理或其他导入代理重复
530
+ const conflict = proxyMap.get(p.id)
531
+ ? null // 同 ID 是覆盖,不算冲突
532
+ : Array.from(proxyMap.values()).find(ep => ep.port === p.port);
533
+ if (conflict) {
534
+ return res.status(409).json({
535
+ error: `端口 ${p.port} 已被代理「${conflict.name}」占用,无法导入代理「${p.name}」`,
536
+ });
537
+ }
538
+ proxyMap.set(p.id, {
539
+ id: p.id,
540
+ name: p.name,
541
+ port: p.port,
542
+ requireAuth: !!p.requireAuth,
543
+ authToken: p.authToken || null,
544
+ providerId: p.providerId,
545
+ defaultModel: p.defaultModel || '',
546
+ });
547
+ }
548
+
549
+ const merged = {
550
+ providers: Array.from(providerMap.values()),
551
+ proxies: Array.from(proxyMap.values()),
552
+ };
553
+ configStore.saveConfig(merged);
554
+
555
+ res.json({
556
+ success: true,
557
+ mode,
558
+ providers: merged.providers.length,
559
+ proxies: merged.proxies.length,
560
+ added: {
561
+ providers: merged.providers.length - existingProviders.length,
562
+ proxies: merged.proxies.length - existingProxies.length,
563
+ },
564
+ });
565
+ });
566
+
418
567
  // 前端首页
419
568
  app.get('/', (req, res) => {
420
569
  res.sendFile(path.join(__dirname, 'public', 'index.html'));