protocol-proxy 2.1.6 → 2.3.2

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/app.js CHANGED
@@ -211,6 +211,12 @@ function selectProvider(id) {
211
211
  const models = provider?.models || [];
212
212
  selectModel(models[0] || '');
213
213
  updateModelAddState();
214
+ // 同步 API Key placeholder
215
+ document.getElementById('target-key').placeholder = provider?.apiKey ? '已设置(留空则不修改)' : 'sk-...';
216
+ // 同步 Azure 字段
217
+ document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
218
+ document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
219
+ document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
214
220
  }
215
221
 
216
222
  // ==================== Model 下拉框 ====================
@@ -345,8 +351,254 @@ function updateModelAddState() {
345
351
  }
346
352
  }
347
353
 
354
+ // ==================== 配置导入/导出 ====================
355
+
356
+ let importData = null;
357
+
358
+ async function exportConfig() {
359
+ try {
360
+ const res = await fetch('/api/config/export');
361
+ const data = await res.json();
362
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
363
+ const url = URL.createObjectURL(blob);
364
+ const a = document.createElement('a');
365
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
366
+ a.href = url;
367
+ a.download = `config-backup-${date}.json`;
368
+ a.click();
369
+ URL.revokeObjectURL(url);
370
+ showToast('配置已导出');
371
+ } catch (err) {
372
+ showToast('导出失败: ' + err.message, true);
373
+ }
374
+ }
375
+
376
+ function handleImportFile(e) {
377
+ const file = e.target.files[0];
378
+ if (!file) return;
379
+ e.target.value = '';
380
+
381
+ const reader = new FileReader();
382
+ reader.onload = () => {
383
+ try {
384
+ const data = JSON.parse(reader.result);
385
+ if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
386
+ showToast('配置格式错误:需要 providers 和 proxies 数组', true);
387
+ return;
388
+ }
389
+ importData = data;
390
+ document.getElementById('import-providers-count').textContent = data.providers.length;
391
+ document.getElementById('import-proxies-count').textContent = data.proxies.length;
392
+ document.getElementById('import-modal').classList.add('active');
393
+ } catch (err) {
394
+ showToast('文件解析失败: ' + err.message, true);
395
+ }
396
+ };
397
+ reader.readAsText(file);
398
+ }
399
+
400
+ function closeImportModal() {
401
+ document.getElementById('import-modal').classList.remove('active');
402
+ importData = null;
403
+ }
404
+
405
+ async function confirmImport() {
406
+ if (!importData) return;
407
+ const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
408
+
409
+ if (mode === 'overwrite') {
410
+ const ok = await showConfirm('确认<strong>覆盖</strong>现有配置?此操作不可撤销。');
411
+ if (!ok) return;
412
+ }
413
+
414
+ try {
415
+ const res = await fetch('/api/config/import', {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify({ config: importData, mode }),
419
+ });
420
+ const result = await res.json();
421
+
422
+ if (!res.ok) {
423
+ showToast(result.error || '导入失败', true);
424
+ return;
425
+ }
426
+
427
+ closeImportModal();
428
+ await Promise.all([loadProxies(), loadProviders()]);
429
+
430
+ const added = result.added;
431
+ let msg = `导入成功(${mode === 'overwrite' ? '覆盖' : '合并'})`;
432
+ if (added) msg += `:新增 ${added.providers} 供应商、${added.proxies} 代理`;
433
+
434
+ const restart = await showConfirm(`${msg}。<br><br>运行中的代理需要重启才能应用变更,新增的代理需要手动启动。<br><br>是否立即重启所有代理?`);
435
+ if (restart) {
436
+ await restartAllProxies();
437
+ }
438
+ } catch (err) {
439
+ showToast('导入失败: ' + err.message, true);
440
+ }
441
+ }
442
+
443
+ async function restartAllProxies() {
444
+ try {
445
+ // 先停掉所有运行中的代理(不管 ID 是否匹配新配置)
446
+ const statusRes = await fetch('/api/status');
447
+ const status = await statusRes.json();
448
+ const runningIds = (status.running || []).map(r => r.id);
449
+ for (const id of runningIds) {
450
+ await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
451
+ }
452
+ // 重新加载配置
453
+ await loadProxies();
454
+ // 按新配置启动所有代理
455
+ for (const p of proxies) {
456
+ await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
457
+ }
458
+ await loadProxies();
459
+ showToast('所有代理已重启');
460
+ } catch (err) {
461
+ showToast('重启失败: ' + err.message, true);
462
+ }
463
+ }
464
+
348
465
  // ==================== 初始化 ====================
349
466
 
467
+ // ==================== Token 用量统计 ====================
468
+
469
+ let statsRange = 'daily';
470
+ let statsProxyId = '';
471
+
472
+ async function loadStats() {
473
+ try {
474
+ const params = new URLSearchParams({ range: statsRange });
475
+ if (statsProxyId) params.set('proxyId', statsProxyId);
476
+ const startDate = document.getElementById('stats-start-date')?.value;
477
+ const endDate = document.getElementById('stats-end-date')?.value;
478
+ if (startDate) params.set('startDate', startDate);
479
+ if (endDate) params.set('endDate', endDate);
480
+ const res = await fetch('/api/stats?' + params);
481
+ const data = await res.json();
482
+ renderStatsSummary(data.summary);
483
+ renderStatsBreakdown(data);
484
+ renderStatsProxyOptions(data.proxies || []);
485
+ } catch (err) {
486
+ console.error('加载统计失败:', err);
487
+ }
488
+ }
489
+
490
+ function renderStatsSummary(summary) {
491
+ document.getElementById('stats-total-tokens').textContent = formatTokens(summary.total);
492
+ document.getElementById('stats-prompt-tokens').textContent = formatTokens(summary.prompt);
493
+ document.getElementById('stats-completion-tokens').textContent = formatTokens(summary.completion);
494
+ document.getElementById('stats-total-requests').textContent = summary.requests.toLocaleString();
495
+ const badge = document.getElementById('stats-estimated-badge');
496
+ if (badge) badge.style.display = summary.hasEstimated ? 'inline' : 'none';
497
+ }
498
+
499
+ function formatTokens(n) {
500
+ if (!n || n === 0) return '0';
501
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
502
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
503
+ return n.toLocaleString();
504
+ }
505
+
506
+ function renderStatsBreakdown(data) {
507
+ const container = document.getElementById('stats-breakdown');
508
+ const { byProvider, byModel, summary } = data;
509
+
510
+ if (!byProvider || byProvider.length === 0) {
511
+ container.innerHTML = '<div class="empty">暂无数据</div>';
512
+ return;
513
+ }
514
+
515
+ let html = '<table class="stats-table"><thead><tr>';
516
+ html += '<th>供应商</th><th>模型</th><th style="text-align:right">请求数</th>';
517
+ html += '<th style="text-align:right">输入 Token</th><th style="text-align:right">输出 Token</th>';
518
+ html += '<th style="text-align:right">合计</th>';
519
+ html += '</tr></thead><tbody>';
520
+
521
+ for (const item of byModel) {
522
+ const prefix = item.hasEstimated ? '~' : '';
523
+ html += '<tr>';
524
+ html += `<td class="provider-cell">${escapeHtml(item.provider)}</td>`;
525
+ html += `<td class="model-cell"><code>${escapeHtml(item.model)}</code></td>`;
526
+ html += `<td class="num">${item.requests.toLocaleString()}</td>`;
527
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.prompt)}</td>`;
528
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.completion)}</td>`;
529
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.total)}</td>`;
530
+ html += '</tr>';
531
+ }
532
+
533
+ html += '</tbody>';
534
+ html += '<tfoot><tr>';
535
+ html += '<td colspan="2">合计</td>';
536
+ html += `<td class="num">${summary.requests.toLocaleString()}</td>`;
537
+ html += `<td class="num">${formatTokens(summary.prompt)}</td>`;
538
+ html += `<td class="num">${formatTokens(summary.completion)}</td>`;
539
+ html += `<td class="num">${formatTokens(summary.total)}</td>`;
540
+ html += '</tr></tfoot></table>';
541
+
542
+ container.innerHTML = html;
543
+ }
544
+
545
+ function renderStatsProxyOptions(proxyList) {
546
+ const container = document.getElementById('stats-proxy-dropdown-options');
547
+ container.innerHTML = `<div class="model-option${!statsProxyId ? ' selected' : ''}" data-proxy-id="">
548
+ <span class="model-option-name">全部代理</span>
549
+ </div>` + proxyList.map(p => `
550
+ <div class="model-option${p.id === statsProxyId ? ' selected' : ''}" data-proxy-id="${escapeHtml(p.id)}">
551
+ <span class="model-option-name">${escapeHtml(p.name)}</span>
552
+ ${p.providerName ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.providerName)}</span>` : ''}
553
+ </div>
554
+ `).join('');
555
+
556
+ container.querySelectorAll('.model-option').forEach(opt => {
557
+ opt.addEventListener('click', () => {
558
+ statsProxyId = opt.dataset.proxyId;
559
+ document.getElementById('stats-proxy-dropdown-value').textContent =
560
+ statsProxyId ? (proxyList.find(p => p.id === statsProxyId)?.name || '全部代理') : '全部代理';
561
+ document.getElementById('stats-proxy-dropdown').classList.remove('open');
562
+ container.querySelectorAll('.model-option').forEach(o => o.classList.remove('selected'));
563
+ opt.classList.add('selected');
564
+ loadStats();
565
+ });
566
+ });
567
+ }
568
+
569
+ function initStatsDropdown() {
570
+ const trigger = document.getElementById('stats-proxy-dropdown-trigger');
571
+ const dropdown = document.getElementById('stats-proxy-dropdown');
572
+
573
+ trigger.addEventListener('click', (e) => {
574
+ e.stopPropagation();
575
+ dropdown.classList.toggle('open');
576
+ });
577
+
578
+ document.addEventListener('click', (e) => {
579
+ if (!dropdown.contains(e.target)) {
580
+ dropdown.classList.remove('open');
581
+ }
582
+ });
583
+ }
584
+
585
+ function initStatsRangeBtns() {
586
+ document.querySelectorAll('.stats-range-btn').forEach(btn => {
587
+ btn.addEventListener('click', () => {
588
+ document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
589
+ btn.classList.add('active');
590
+ statsRange = btn.dataset.range;
591
+ // 清空日期选择器,显示全部数据
592
+ document.getElementById('stats-start-date').value = '';
593
+ document.getElementById('stats-end-date').value = '';
594
+ loadStats();
595
+ });
596
+ });
597
+ // 日期选择器变化时自动加载
598
+ document.getElementById('stats-start-date').addEventListener('change', loadStats);
599
+ document.getElementById('stats-end-date').addEventListener('change', loadStats);
600
+ }
601
+
350
602
  function generateToken() {
351
603
  const arr = new Uint8Array(24);
352
604
  crypto.getRandomValues(arr);
@@ -385,9 +637,11 @@ function initSimpleDropdown(dropdownId, onChange) {
385
637
  }
386
638
 
387
639
  async function init() {
388
- await Promise.all([loadProxies(), loadProviders()]);
640
+ await Promise.all([loadProxies(), loadProviders(), loadStats()]);
389
641
  initProviderDropdown();
390
642
  initModelDropdown();
643
+ initStatsDropdown();
644
+ initStatsRangeBtns();
391
645
  initSimpleDropdown('auth-dropdown', (val) => {
392
646
  const enabled = val === 'true';
393
647
  document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
@@ -395,7 +649,12 @@ async function init() {
395
649
  document.getElementById('proxy-auth-token').value = generateToken();
396
650
  }
397
651
  });
398
- initSimpleDropdown('protocol-dropdown');
652
+ initSimpleDropdown('protocol-dropdown', (val) => {
653
+ document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
654
+ });
655
+ // 初始状态:根据当前协议值决定 Azure 字段显示
656
+ const initProto = document.getElementById('target-protocol').value;
657
+ document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
399
658
  }
400
659
 
401
660
  // ==================== 代理地址复制 ====================
@@ -541,6 +800,11 @@ function openModal(id = null) {
541
800
  selectProvider(p.providerId || '');
542
801
  selectModel(p.defaultModel || '');
543
802
  document.getElementById('target-key').placeholder = p.hasApiKey ? '已设置(留空则不修改)' : 'sk-...';
803
+ // Azure 字段从供应商配置读取
804
+ const provider = providers.find(pr => pr.id === p.providerId);
805
+ document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
806
+ document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
807
+ document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
544
808
  } else {
545
809
  document.getElementById('proxy-id').value = '';
546
810
  // 重置认证下拉框
@@ -552,6 +816,9 @@ function openModal(id = null) {
552
816
  selectProvider('');
553
817
  selectModel('');
554
818
  document.getElementById('target-key').placeholder = 'sk-...';
819
+ document.getElementById('target-azure-deployment').value = '';
820
+ document.getElementById('target-azure-version').value = '';
821
+ document.getElementById('azure-fields').style.display = 'none';
555
822
  }
556
823
 
557
824
  updateModelAddState();
@@ -593,6 +860,10 @@ async function handleSubmit(e) {
593
860
  const providerUpdates = {};
594
861
  if (apiKey) providerUpdates.apiKey = apiKey;
595
862
  if (protocol) providerUpdates.protocol = protocol;
863
+ const azureDeployment = document.getElementById('target-azure-deployment').value.trim();
864
+ const azureApiVersion = document.getElementById('target-azure-version').value.trim();
865
+ providerUpdates.azureDeployment = azureDeployment || '';
866
+ providerUpdates.azureApiVersion = azureApiVersion || '';
596
867
  if (Object.keys(providerUpdates).length > 0) {
597
868
  try {
598
869
  const res = await fetch(`/api/providers/${providerId}`, {
package/public/index.html CHANGED
@@ -24,10 +24,63 @@
24
24
  </div>
25
25
  </div>
26
26
 
27
+ <!-- Token 用量统计 -->
28
+ <section class="card stats-panel">
29
+ <div class="card-header">
30
+ <h2>Token 用量统计 <span class="stats-estimated-badge" id="stats-estimated-badge" style="display:none">含估算</span></h2>
31
+ <div class="stats-controls">
32
+ <div class="model-dropdown" id="stats-proxy-dropdown">
33
+ <div class="model-dropdown-trigger stats-filter-trigger" id="stats-proxy-dropdown-trigger">
34
+ <span id="stats-proxy-dropdown-value">全部代理</span>
35
+ <span class="model-dropdown-arrow">&#9662;</span>
36
+ </div>
37
+ <div class="model-dropdown-menu" id="stats-proxy-dropdown-menu">
38
+ <div class="model-dropdown-options" id="stats-proxy-dropdown-options"></div>
39
+ </div>
40
+ </div>
41
+ <div class="stats-range-btns">
42
+ <button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
43
+ <button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
44
+ <button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
45
+ <span class="stats-date-sep">|</span>
46
+ <input type="date" id="stats-start-date" class="stats-date-input" placeholder="开始日期">
47
+ <span class="stats-date-sep">~</span>
48
+ <input type="date" id="stats-end-date" class="stats-date-input" placeholder="结束日期">
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <div class="stats-summary" id="stats-summary">
53
+ <div class="stats-summary-item">
54
+ <span class="stats-summary-value" id="stats-total-tokens">-</span>
55
+ <span class="stats-summary-label">总 Token</span>
56
+ </div>
57
+ <div class="stats-summary-item">
58
+ <span class="stats-summary-value" id="stats-prompt-tokens">-</span>
59
+ <span class="stats-summary-label">输入 Token</span>
60
+ </div>
61
+ <div class="stats-summary-item">
62
+ <span class="stats-summary-value" id="stats-completion-tokens">-</span>
63
+ <span class="stats-summary-label">输出 Token</span>
64
+ </div>
65
+ <div class="stats-summary-item">
66
+ <span class="stats-summary-value" id="stats-total-requests">-</span>
67
+ <span class="stats-summary-label">请求数</span>
68
+ </div>
69
+ </div>
70
+ <div id="stats-breakdown" class="stats-breakdown">
71
+ <div class="empty">暂无数据</div>
72
+ </div>
73
+ </section>
74
+
27
75
  <section class="card">
28
76
  <div class="card-header">
29
77
  <h2>代理列表</h2>
30
- <button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
78
+ <div class="card-header-actions">
79
+ <button class="btn" onclick="exportConfig()">导出配置</button>
80
+ <button class="btn" onclick="document.getElementById('import-file').click()">导入配置</button>
81
+ <input type="file" id="import-file" accept=".json" style="display:none" onchange="handleImportFile(event)">
82
+ <button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
83
+ </div>
31
84
  </div>
32
85
  <div id="proxy-list" class="proxy-list">
33
86
  <div class="empty">加载中...</div>
@@ -111,6 +164,7 @@
111
164
  <div class="model-dropdown-options" id="protocol-dropdown-options">
112
165
  <div class="model-option selected" data-value="openai"><span class="model-option-name">OpenAI</span></div>
113
166
  <div class="model-option" data-value="anthropic"><span class="model-option-name">Anthropic</span></div>
167
+ <div class="model-option" data-value="gemini"><span class="model-option-name">Gemini</span></div>
114
168
  </div>
115
169
  </div>
116
170
  </div>
@@ -139,6 +193,16 @@
139
193
  <input type="password" id="target-key" placeholder="sk-...">
140
194
  </div>
141
195
  </div>
196
+ <div class="form-row" id="azure-fields" style="display:none">
197
+ <div class="form-group">
198
+ <label>Azure Deployment <span style="color:#64748b;font-size:0.75rem">(仅 Azure 用户填写)</span></label>
199
+ <input type="text" id="target-azure-deployment" placeholder="仅 Azure 用户填写">
200
+ </div>
201
+ <div class="form-group">
202
+ <label>Azure API Version <span style="color:#64748b;font-size:0.75rem">(仅 Azure 用户填写)</span></label>
203
+ <input type="text" id="target-azure-version" placeholder="仅 Azure 用户填写">
204
+ </div>
205
+ </div>
142
206
  </div>
143
207
  </div>
144
208
 
@@ -163,6 +227,51 @@
163
227
  </div>
164
228
  </div>
165
229
 
230
+ <!-- 导入预览弹窗 -->
231
+ <div class="modal" id="import-modal">
232
+ <div class="modal-content" style="max-width:500px">
233
+ <div class="modal-header">
234
+ <h3>导入配置</h3>
235
+ <button class="btn-close" onclick="closeImportModal()">&times;</button>
236
+ </div>
237
+ <div class="import-preview" id="import-preview">
238
+ <div class="import-stats">
239
+ <div class="import-stat">
240
+ <span class="import-stat-value" id="import-providers-count">0</span>
241
+ <span class="import-stat-label">供应商</span>
242
+ </div>
243
+ <div class="import-stat">
244
+ <span class="import-stat-value" id="import-proxies-count">0</span>
245
+ <span class="import-stat-label">代理</span>
246
+ </div>
247
+ </div>
248
+ <div class="import-mode">
249
+ <label>导入模式</label>
250
+ <div class="import-mode-options">
251
+ <label class="import-mode-option">
252
+ <input type="radio" name="import-mode" value="merge" checked>
253
+ <span>
254
+ <strong>合并</strong>
255
+ <small>按 ID 去重:新增导入项,同 ID 覆盖</small>
256
+ </span>
257
+ </label>
258
+ <label class="import-mode-option">
259
+ <input type="radio" name="import-mode" value="overwrite">
260
+ <span>
261
+ <strong>覆盖</strong>
262
+ <small>完全替换现有配置</small>
263
+ </span>
264
+ </label>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ <div class="modal-footer">
269
+ <button class="btn" onclick="closeImportModal()">取消</button>
270
+ <button class="btn btn-primary" onclick="confirmImport()">确认导入</button>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
166
275
  <script src="app.js"></script>
167
276
  </body>
168
277
  </html>