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/lib/converters/anthropic-to-openai.js +2 -3
- package/lib/proxy-server.js +99 -8
- package/lib/stats-store.js +287 -0
- package/package.json +1 -1
- package/public/app.js +253 -2
- package/public/index.html +105 -1
- package/public/style.css +282 -0
- package/server.js +150 -1
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">▾</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
|
-
<
|
|
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()">×</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>
|