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/app.js CHANGED
@@ -211,6 +211,10 @@ function selectProvider(id) {
211
211
  const models = provider?.models || [];
212
212
  selectModel(models[0] || '');
213
213
  updateModelAddState();
214
+ // 同步 Azure 字段
215
+ document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
216
+ document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
217
+ document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
214
218
  }
215
219
 
216
220
  // ==================== Model 下拉框 ====================
@@ -345,8 +349,239 @@ function updateModelAddState() {
345
349
  }
346
350
  }
347
351
 
352
+ // ==================== 配置导入/导出 ====================
353
+
354
+ let importData = null;
355
+
356
+ async function exportConfig() {
357
+ try {
358
+ const res = await fetch('/api/config/export');
359
+ const data = await res.json();
360
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
361
+ const url = URL.createObjectURL(blob);
362
+ const a = document.createElement('a');
363
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
364
+ a.href = url;
365
+ a.download = `config-backup-${date}.json`;
366
+ a.click();
367
+ URL.revokeObjectURL(url);
368
+ showToast('配置已导出');
369
+ } catch (err) {
370
+ showToast('导出失败: ' + err.message, true);
371
+ }
372
+ }
373
+
374
+ function handleImportFile(e) {
375
+ const file = e.target.files[0];
376
+ if (!file) return;
377
+ e.target.value = '';
378
+
379
+ const reader = new FileReader();
380
+ reader.onload = () => {
381
+ try {
382
+ const data = JSON.parse(reader.result);
383
+ if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
384
+ showToast('配置格式错误:需要 providers 和 proxies 数组', true);
385
+ return;
386
+ }
387
+ importData = data;
388
+ document.getElementById('import-providers-count').textContent = data.providers.length;
389
+ document.getElementById('import-proxies-count').textContent = data.proxies.length;
390
+ document.getElementById('import-modal').classList.add('active');
391
+ } catch (err) {
392
+ showToast('文件解析失败: ' + err.message, true);
393
+ }
394
+ };
395
+ reader.readAsText(file);
396
+ }
397
+
398
+ function closeImportModal() {
399
+ document.getElementById('import-modal').classList.remove('active');
400
+ importData = null;
401
+ }
402
+
403
+ async function confirmImport() {
404
+ if (!importData) return;
405
+ const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
406
+
407
+ if (mode === 'overwrite') {
408
+ const ok = await showConfirm('确认<strong>覆盖</strong>现有配置?此操作不可撤销。');
409
+ if (!ok) return;
410
+ }
411
+
412
+ try {
413
+ const res = await fetch('/api/config/import', {
414
+ method: 'POST',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({ config: importData, mode }),
417
+ });
418
+ const result = await res.json();
419
+
420
+ if (!res.ok) {
421
+ showToast(result.error || '导入失败', true);
422
+ return;
423
+ }
424
+
425
+ closeImportModal();
426
+ await Promise.all([loadProxies(), loadProviders()]);
427
+
428
+ const added = result.added;
429
+ let msg = `导入成功(${mode === 'overwrite' ? '覆盖' : '合并'})`;
430
+ if (added) msg += `:新增 ${added.providers} 供应商、${added.proxies} 代理`;
431
+
432
+ const restart = await showConfirm(`${msg}。<br><br>运行中的代理需要重启才能应用变更,新增的代理需要手动启动。<br><br>是否立即重启所有代理?`);
433
+ if (restart) {
434
+ await restartAllProxies();
435
+ }
436
+ } catch (err) {
437
+ showToast('导入失败: ' + err.message, true);
438
+ }
439
+ }
440
+
441
+ async function restartAllProxies() {
442
+ try {
443
+ for (const p of proxies) {
444
+ if (p.running) {
445
+ await fetch(`/api/proxies/${p.id}/stop`, { method: 'POST' });
446
+ }
447
+ }
448
+ for (const p of proxies) {
449
+ await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
450
+ }
451
+ await loadProxies();
452
+ showToast('所有代理已重启');
453
+ } catch (err) {
454
+ showToast('重启失败: ' + err.message, true);
455
+ }
456
+ }
457
+
348
458
  // ==================== 初始化 ====================
349
459
 
460
+ // ==================== Token 用量统计 ====================
461
+
462
+ let statsRange = 'daily';
463
+ let statsProxyId = '';
464
+
465
+ async function loadStats() {
466
+ try {
467
+ const params = new URLSearchParams({ range: statsRange });
468
+ if (statsProxyId) params.set('proxyId', statsProxyId);
469
+ const res = await fetch('/api/stats?' + params);
470
+ const data = await res.json();
471
+ renderStatsSummary(data.summary);
472
+ renderStatsBreakdown(data);
473
+ renderStatsProxyOptions(data.proxies || []);
474
+ } catch (err) {
475
+ console.error('加载统计失败:', err);
476
+ }
477
+ }
478
+
479
+ function renderStatsSummary(summary) {
480
+ document.getElementById('stats-total-tokens').textContent = formatTokens(summary.total);
481
+ document.getElementById('stats-prompt-tokens').textContent = formatTokens(summary.prompt);
482
+ document.getElementById('stats-completion-tokens').textContent = formatTokens(summary.completion);
483
+ document.getElementById('stats-total-requests').textContent = summary.requests.toLocaleString();
484
+ const badge = document.getElementById('stats-estimated-badge');
485
+ if (badge) badge.style.display = summary.hasEstimated ? 'inline' : 'none';
486
+ }
487
+
488
+ function formatTokens(n) {
489
+ if (!n || n === 0) return '0';
490
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
491
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
492
+ return n.toLocaleString();
493
+ }
494
+
495
+ function renderStatsBreakdown(data) {
496
+ const container = document.getElementById('stats-breakdown');
497
+ const { byProvider, byModel, summary } = data;
498
+
499
+ if (!byProvider || byProvider.length === 0) {
500
+ container.innerHTML = '<div class="empty">暂无数据</div>';
501
+ return;
502
+ }
503
+
504
+ let html = '<table class="stats-table"><thead><tr>';
505
+ html += '<th>供应商</th><th>模型</th><th style="text-align:right">请求数</th>';
506
+ html += '<th style="text-align:right">输入 Token</th><th style="text-align:right">输出 Token</th>';
507
+ html += '<th style="text-align:right">合计</th>';
508
+ html += '</tr></thead><tbody>';
509
+
510
+ for (const item of byModel) {
511
+ const prefix = item.hasEstimated ? '~' : '';
512
+ html += '<tr>';
513
+ html += `<td class="provider-cell">${escapeHtml(item.provider)}</td>`;
514
+ html += `<td class="model-cell"><code>${escapeHtml(item.model)}</code></td>`;
515
+ html += `<td class="num">${item.requests.toLocaleString()}</td>`;
516
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.prompt)}</td>`;
517
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.completion)}</td>`;
518
+ html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.total)}</td>`;
519
+ html += '</tr>';
520
+ }
521
+
522
+ html += '</tbody>';
523
+ html += '<tfoot><tr>';
524
+ html += '<td colspan="2">合计</td>';
525
+ html += `<td class="num">${summary.requests.toLocaleString()}</td>`;
526
+ html += `<td class="num">${formatTokens(summary.prompt)}</td>`;
527
+ html += `<td class="num">${formatTokens(summary.completion)}</td>`;
528
+ html += `<td class="num">${formatTokens(summary.total)}</td>`;
529
+ html += '</tr></tfoot></table>';
530
+
531
+ container.innerHTML = html;
532
+ }
533
+
534
+ function renderStatsProxyOptions(proxyList) {
535
+ const container = document.getElementById('stats-proxy-dropdown-options');
536
+ container.innerHTML = `<div class="model-option${!statsProxyId ? ' selected' : ''}" data-proxy-id="">
537
+ <span class="model-option-name">全部代理</span>
538
+ </div>` + proxyList.map(p => `
539
+ <div class="model-option${p.id === statsProxyId ? ' selected' : ''}" data-proxy-id="${escapeHtml(p.id)}">
540
+ <span class="model-option-name">${escapeHtml(p.name)}</span>
541
+ ${p.providerName ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.providerName)}</span>` : ''}
542
+ </div>
543
+ `).join('');
544
+
545
+ container.querySelectorAll('.model-option').forEach(opt => {
546
+ opt.addEventListener('click', () => {
547
+ statsProxyId = opt.dataset.proxyId;
548
+ document.getElementById('stats-proxy-dropdown-value').textContent =
549
+ statsProxyId ? (proxyList.find(p => p.id === statsProxyId)?.name || '全部代理') : '全部代理';
550
+ document.getElementById('stats-proxy-dropdown').classList.remove('open');
551
+ container.querySelectorAll('.model-option').forEach(o => o.classList.remove('selected'));
552
+ opt.classList.add('selected');
553
+ loadStats();
554
+ });
555
+ });
556
+ }
557
+
558
+ function initStatsDropdown() {
559
+ const trigger = document.getElementById('stats-proxy-dropdown-trigger');
560
+ const dropdown = document.getElementById('stats-proxy-dropdown');
561
+
562
+ trigger.addEventListener('click', (e) => {
563
+ e.stopPropagation();
564
+ dropdown.classList.toggle('open');
565
+ });
566
+
567
+ document.addEventListener('click', (e) => {
568
+ if (!dropdown.contains(e.target)) {
569
+ dropdown.classList.remove('open');
570
+ }
571
+ });
572
+ }
573
+
574
+ function initStatsRangeBtns() {
575
+ document.querySelectorAll('.stats-range-btn').forEach(btn => {
576
+ btn.addEventListener('click', () => {
577
+ document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
578
+ btn.classList.add('active');
579
+ statsRange = btn.dataset.range;
580
+ loadStats();
581
+ });
582
+ });
583
+ }
584
+
350
585
  function generateToken() {
351
586
  const arr = new Uint8Array(24);
352
587
  crypto.getRandomValues(arr);
@@ -385,9 +620,11 @@ function initSimpleDropdown(dropdownId, onChange) {
385
620
  }
386
621
 
387
622
  async function init() {
388
- await Promise.all([loadProxies(), loadProviders()]);
623
+ await Promise.all([loadProxies(), loadProviders(), loadStats()]);
389
624
  initProviderDropdown();
390
625
  initModelDropdown();
626
+ initStatsDropdown();
627
+ initStatsRangeBtns();
391
628
  initSimpleDropdown('auth-dropdown', (val) => {
392
629
  const enabled = val === 'true';
393
630
  document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
@@ -395,7 +632,9 @@ async function init() {
395
632
  document.getElementById('proxy-auth-token').value = generateToken();
396
633
  }
397
634
  });
398
- initSimpleDropdown('protocol-dropdown');
635
+ initSimpleDropdown('protocol-dropdown', (val) => {
636
+ document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
637
+ });
399
638
  }
400
639
 
401
640
  // ==================== 代理地址复制 ====================
@@ -541,6 +780,11 @@ function openModal(id = null) {
541
780
  selectProvider(p.providerId || '');
542
781
  selectModel(p.defaultModel || '');
543
782
  document.getElementById('target-key').placeholder = p.hasApiKey ? '已设置(留空则不修改)' : 'sk-...';
783
+ // Azure 字段从供应商配置读取
784
+ const provider = providers.find(pr => pr.id === p.providerId);
785
+ document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
786
+ document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
787
+ document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
544
788
  } else {
545
789
  document.getElementById('proxy-id').value = '';
546
790
  // 重置认证下拉框
@@ -552,6 +796,9 @@ function openModal(id = null) {
552
796
  selectProvider('');
553
797
  selectModel('');
554
798
  document.getElementById('target-key').placeholder = 'sk-...';
799
+ document.getElementById('target-azure-deployment').value = '';
800
+ document.getElementById('target-azure-version').value = '';
801
+ document.getElementById('azure-fields').style.display = 'none';
555
802
  }
556
803
 
557
804
  updateModelAddState();
@@ -593,6 +840,10 @@ async function handleSubmit(e) {
593
840
  const providerUpdates = {};
594
841
  if (apiKey) providerUpdates.apiKey = apiKey;
595
842
  if (protocol) providerUpdates.protocol = protocol;
843
+ const azureDeployment = document.getElementById('target-azure-deployment').value.trim();
844
+ const azureApiVersion = document.getElementById('target-azure-version').value.trim();
845
+ providerUpdates.azureDeployment = azureDeployment || '';
846
+ providerUpdates.azureApiVersion = azureApiVersion || '';
596
847
  if (Object.keys(providerUpdates).length > 0) {
597
848
  try {
598
849
  const res = await fetch(`/api/providers/${providerId}`, {
package/public/index.html CHANGED
@@ -24,10 +24,59 @@
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
+ </div>
46
+ </div>
47
+ </div>
48
+ <div class="stats-summary" id="stats-summary">
49
+ <div class="stats-summary-item">
50
+ <span class="stats-summary-value" id="stats-total-tokens">-</span>
51
+ <span class="stats-summary-label">总 Token</span>
52
+ </div>
53
+ <div class="stats-summary-item">
54
+ <span class="stats-summary-value" id="stats-prompt-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-completion-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-total-requests">-</span>
63
+ <span class="stats-summary-label">请求数</span>
64
+ </div>
65
+ </div>
66
+ <div id="stats-breakdown" class="stats-breakdown">
67
+ <div class="empty">暂无数据</div>
68
+ </div>
69
+ </section>
70
+
27
71
  <section class="card">
28
72
  <div class="card-header">
29
73
  <h2>代理列表</h2>
30
- <button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
74
+ <div class="card-header-actions">
75
+ <button class="btn" onclick="exportConfig()">导出配置</button>
76
+ <button class="btn" onclick="document.getElementById('import-file').click()">导入配置</button>
77
+ <input type="file" id="import-file" accept=".json" style="display:none" onchange="handleImportFile(event)">
78
+ <button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
79
+ </div>
31
80
  </div>
32
81
  <div id="proxy-list" class="proxy-list">
33
82
  <div class="empty">加载中...</div>
@@ -139,6 +188,16 @@
139
188
  <input type="password" id="target-key" placeholder="sk-...">
140
189
  </div>
141
190
  </div>
191
+ <div class="form-row" id="azure-fields" style="display:none">
192
+ <div class="form-group">
193
+ <label>Azure Deployment</label>
194
+ <input type="text" id="target-azure-deployment" placeholder="gpt-4o">
195
+ </div>
196
+ <div class="form-group">
197
+ <label>Azure API Version</label>
198
+ <input type="text" id="target-azure-version" placeholder="2024-02-01">
199
+ </div>
200
+ </div>
142
201
  </div>
143
202
  </div>
144
203
 
@@ -163,6 +222,51 @@
163
222
  </div>
164
223
  </div>
165
224
 
225
+ <!-- 导入预览弹窗 -->
226
+ <div class="modal" id="import-modal">
227
+ <div class="modal-content" style="max-width:500px">
228
+ <div class="modal-header">
229
+ <h3>导入配置</h3>
230
+ <button class="btn-close" onclick="closeImportModal()">&times;</button>
231
+ </div>
232
+ <div class="import-preview" id="import-preview">
233
+ <div class="import-stats">
234
+ <div class="import-stat">
235
+ <span class="import-stat-value" id="import-providers-count">0</span>
236
+ <span class="import-stat-label">供应商</span>
237
+ </div>
238
+ <div class="import-stat">
239
+ <span class="import-stat-value" id="import-proxies-count">0</span>
240
+ <span class="import-stat-label">代理</span>
241
+ </div>
242
+ </div>
243
+ <div class="import-mode">
244
+ <label>导入模式</label>
245
+ <div class="import-mode-options">
246
+ <label class="import-mode-option">
247
+ <input type="radio" name="import-mode" value="merge" checked>
248
+ <span>
249
+ <strong>合并</strong>
250
+ <small>按 ID 去重:新增导入项,同 ID 覆盖</small>
251
+ </span>
252
+ </label>
253
+ <label class="import-mode-option">
254
+ <input type="radio" name="import-mode" value="overwrite">
255
+ <span>
256
+ <strong>覆盖</strong>
257
+ <small>完全替换现有配置</small>
258
+ </span>
259
+ </label>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ <div class="modal-footer">
264
+ <button class="btn" onclick="closeImportModal()">取消</button>
265
+ <button class="btn btn-primary" onclick="confirmImport()">确认导入</button>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
166
270
  <script src="app.js"></script>
167
271
  </body>
168
272
  </html>