protocol-proxy 2.5.1 → 2.7.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.
@@ -154,20 +154,35 @@ function normalizeConfig(config) {
154
154
  function loadConfig() {
155
155
  try {
156
156
  if (!fs.existsSync(CONFIG_PATH)) {
157
- configCache = { providers: [], proxies: [] };
157
+ configCache = { providers: [], proxies: [], settings: {} };
158
158
  return configCache;
159
159
  }
160
160
  const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
161
161
  let config = normalizeConfig(JSON.parse(raw));
162
162
  config = migrateTargetToProvider(config);
163
+ if (!config.settings) config.settings = {};
163
164
  configCache = config;
164
165
  return configCache;
165
166
  } catch (err) {
166
167
  console.error('加载配置失败:', err.message);
167
- return configCache || { providers: [], proxies: [] };
168
+ return configCache || { providers: [], proxies: [], settings: {} };
168
169
  }
169
170
  }
170
171
 
172
+ function getSettings() {
173
+ return loadConfig().settings || {};
174
+ }
175
+
176
+ function setSetting(key, value) {
177
+ const config = loadConfig();
178
+ if (!config.settings) config.settings = {};
179
+ config.settings[key] = value;
180
+ saveConfig(config);
181
+ }
182
+
183
+ const SNAPSHOT_DIR = path.join(os.homedir(), '.protocol-proxy', 'snapshots');
184
+ const MAX_SNAPSHOTS = 30;
185
+
171
186
  function saveConfig(config) {
172
187
  try {
173
188
  const normalizedConfig = normalizeConfig(config);
@@ -186,6 +201,67 @@ function saveConfig(config) {
186
201
  }
187
202
  }
188
203
 
204
+ function saveSnapshot(reason) {
205
+ try {
206
+ if (!fs.existsSync(CONFIG_PATH)) return;
207
+ if (!fs.existsSync(SNAPSHOT_DIR)) fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
208
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
209
+ const name = `${ts}_${reason || 'save'}.json`;
210
+ fs.copyFileSync(CONFIG_PATH, path.join(SNAPSHOT_DIR, name));
211
+ // 清理旧快照
212
+ const files = fs.readdirSync(SNAPSHOT_DIR)
213
+ .filter(f => f.endsWith('.json'))
214
+ .sort();
215
+ while (files.length > MAX_SNAPSHOTS) {
216
+ const oldest = files.shift();
217
+ fs.unlinkSync(path.join(SNAPSHOT_DIR, oldest));
218
+ }
219
+ } catch (err) {
220
+ console.error('[Snapshot] 保存快照失败:', err.message);
221
+ }
222
+ }
223
+
224
+ function getSnapshots() {
225
+ try {
226
+ if (!fs.existsSync(SNAPSHOT_DIR)) return [];
227
+ return fs.readdirSync(SNAPSHOT_DIR)
228
+ .filter(f => f.endsWith('.json'))
229
+ .sort().reverse()
230
+ .map(f => {
231
+ const fullPath = path.join(SNAPSHOT_DIR, f);
232
+ const stat = fs.statSync(fullPath);
233
+ const name = f.replace('.json', '');
234
+ const [ts, ...reasonParts] = name.split('_');
235
+ return {
236
+ file: f,
237
+ timestamp: stat.mtime.toISOString(),
238
+ reason: reasonParts.join('_') || 'save',
239
+ size: stat.size,
240
+ };
241
+ });
242
+ } catch (err) {
243
+ console.error('[Snapshot] 读取快照列表失败:', err.message);
244
+ return [];
245
+ }
246
+ }
247
+
248
+ function restoreSnapshot(file) {
249
+ try {
250
+ if (!/^[\w\-]+\.json$/.test(file)) return { error: '非法文件名' };
251
+ const snapshotPath = path.join(SNAPSHOT_DIR, file);
252
+ if (!fs.existsSync(snapshotPath)) return { error: '快照不存在' };
253
+ const content = fs.readFileSync(snapshotPath, 'utf-8');
254
+ const config = JSON.parse(content);
255
+ // 先对当前配置做快照,以便回滚本次操作
256
+ saveSnapshot('before-rollback');
257
+ saveConfig(config);
258
+ return { success: true };
259
+ } catch (err) {
260
+ console.error('[Snapshot] 恢复快照失败:', err.message);
261
+ return { error: err.message };
262
+ }
263
+ }
264
+
189
265
  // ==================== 供应商 CRUD ====================
190
266
 
191
267
  function getProviders() {
@@ -280,6 +356,11 @@ function removeProxy(id) {
280
356
  module.exports = {
281
357
  loadConfig,
282
358
  saveConfig,
359
+ saveSnapshot,
360
+ getSnapshots,
361
+ restoreSnapshot,
362
+ getSettings,
363
+ setSetting,
283
364
  getProviders,
284
365
  getProviderById,
285
366
  addProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -7,6 +7,49 @@ let statsRange = 'daily';
7
7
  let statsProxyId = '';
8
8
  let providerPoolItems = [];
9
9
 
10
+ // ==================== 主题切换 ====================
11
+
12
+ const THEMES = [
13
+ { id: 'dark', icon: '☾', label: '深色' },
14
+ { id: 'light', icon: '☀', label: '浅色' },
15
+ { id: 'pure-black', icon: '●', label: '纯黑' },
16
+ { id: 'neon', icon: '⚡', label: '霓虹' },
17
+ { id: 'amber', icon: '◈', label: '琥珀' },
18
+ ];
19
+
20
+ function applyTheme(theme) {
21
+ const t = THEMES.find(t => t.id === theme) || THEMES[0];
22
+ document.documentElement.setAttribute('data-theme', t.id);
23
+ const icon = document.getElementById('theme-icon');
24
+ const label = document.getElementById('theme-label');
25
+ if (icon) icon.textContent = t.icon;
26
+ if (label) label.textContent = t.label;
27
+ localStorage.setItem('theme', t.id);
28
+ }
29
+
30
+ function toggleTheme() {
31
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
32
+ const idx = THEMES.findIndex(t => t.id === current);
33
+ const next = THEMES[(idx + 1) % THEMES.length];
34
+ applyTheme(next.id);
35
+ fetch('/api/settings', {
36
+ method: 'PUT',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ theme: next.id }),
39
+ }).catch(() => {});
40
+ }
41
+
42
+ // 初始化主题:优先服务端,fallback 到 localStorage
43
+ (async () => {
44
+ try {
45
+ const res = await fetch('/api/settings');
46
+ const settings = await res.json();
47
+ applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
48
+ } catch {
49
+ applyTheme(localStorage.getItem('theme') || 'dark');
50
+ }
51
+ })();
52
+
10
53
  // ==================== 数据加载 ====================
11
54
 
12
55
  async function loadProxies() {
@@ -587,6 +630,59 @@ function initModelDropdown() {
587
630
  });
588
631
  }
589
632
 
633
+ async function importModels() {
634
+ const providerId = document.getElementById('provider-id').value;
635
+ if (!providerId) {
636
+ showToast('请先选择供应商', true);
637
+ return;
638
+ }
639
+ const btn = document.getElementById('model-import-btn');
640
+ btn.disabled = true;
641
+ btn.textContent = '导入中...';
642
+ try {
643
+ const apiKeys = collectApiKeys();
644
+ const res = await fetch(`/api/providers/${providerId}/available-models`, {
645
+ method: 'POST',
646
+ headers: { 'Content-Type': 'application/json' },
647
+ body: JSON.stringify({ apiKeys }),
648
+ });
649
+ const data = await res.json();
650
+ if (!data.models || data.models.length === 0) {
651
+ showToast(data.message || '未获取到模型', true);
652
+ return;
653
+ }
654
+ const provider = providers.find(p => p.id === providerId);
655
+ const existing = new Set(provider?.models || []);
656
+ const newModels = data.models.filter(m => !existing.has(m));
657
+ if (newModels.length === 0) {
658
+ showToast(`已全部存在,共 ${data.models.length} 个模型`);
659
+ // 即使没有新模型,也尝试自动选择第一个
660
+ if (!document.getElementById('target-model').value && data.models.length > 0) {
661
+ selectModel(data.models[0]);
662
+ }
663
+ return;
664
+ }
665
+ const merged = [...(provider?.models || []), ...newModels];
666
+ await fetch(`/api/providers/${providerId}`, {
667
+ method: 'PUT',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({ models: merged }),
670
+ });
671
+ await loadProviders();
672
+ renderModelOptions();
673
+ // 自动选择默认模型
674
+ if (!document.getElementById('target-model').value) {
675
+ selectModel(newModels[0] || data.models[0]);
676
+ }
677
+ showToast(`已导入 ${newModels.length} 个新模型(共 ${data.models.length} 个)`);
678
+ } catch (err) {
679
+ showToast('导入失败: ' + err.message, true);
680
+ } finally {
681
+ btn.disabled = false;
682
+ btn.textContent = '自动导入';
683
+ }
684
+ }
685
+
590
686
  function renderModelOptions() {
591
687
  const container = document.getElementById('model-dropdown-options');
592
688
  const providerId = document.getElementById('provider-id').value;
@@ -983,6 +1079,33 @@ async function init() {
983
1079
  // 初始状态:根据当前协议值决定 Azure 字段显示
984
1080
  const initProto = document.getElementById('target-protocol').value;
985
1081
  document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
1082
+
1083
+ // 快捷键
1084
+ document.addEventListener('keydown', (e) => {
1085
+ // Esc 关闭最上层弹窗
1086
+ if (e.key === 'Escape') {
1087
+ if (document.getElementById('confirm-modal').classList.contains('active')) return;
1088
+ if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
1089
+ if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
1090
+ if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
1091
+ if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
1092
+ if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
1093
+ }
1094
+ // Ctrl+S 保存表单
1095
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
1096
+ if (document.getElementById('modal').classList.contains('active')) {
1097
+ e.preventDefault();
1098
+ document.getElementById('proxy-form').requestSubmit();
1099
+ }
1100
+ }
1101
+ // Ctrl+N 新建代理
1102
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
1103
+ if (!document.getElementById('modal').classList.contains('active')) {
1104
+ e.preventDefault();
1105
+ openModal();
1106
+ }
1107
+ }
1108
+ });
986
1109
  }
987
1110
 
988
1111
  // ==================== 代理地址复制 ====================
@@ -1037,6 +1160,149 @@ function showToast(msg, isError) {
1037
1160
  setTimeout(() => toast.remove(), 2000);
1038
1161
  }
1039
1162
 
1163
+ // ==================== 批量操作 ====================
1164
+
1165
+ async function startAllProxies() {
1166
+ try {
1167
+ const res = await fetch('/api/proxies/start-all', { method: 'POST' });
1168
+ const data = await res.json();
1169
+ await loadProxies();
1170
+ const started = data.results.filter(r => r.success).length;
1171
+ const skipped = data.results.filter(r => r.skipped).length;
1172
+ const failed = data.results.filter(r => !r.success && !r.skipped).length;
1173
+ let msg = `启动完成:${started} 个启动`;
1174
+ if (skipped > 0) msg += `,${skipped} 个已在运行`;
1175
+ if (failed > 0) msg += `,${failed} 个失败`;
1176
+ showToast(msg, failed > 0);
1177
+ } catch (err) {
1178
+ showToast('批量启动失败: ' + err.message, true);
1179
+ }
1180
+ }
1181
+
1182
+ async function stopAllProxies() {
1183
+ const ok = await showConfirm('确定要停止所有运行中的代理吗?', '全部停止');
1184
+ if (!ok) return;
1185
+ try {
1186
+ const res = await fetch('/api/proxies/stop-all', { method: 'POST' });
1187
+ const data = await res.json();
1188
+ await loadProxies();
1189
+ showToast(`已停止 ${data.results.length} 个代理`);
1190
+ } catch (err) {
1191
+ showToast('批量停止失败: ' + err.message, true);
1192
+ }
1193
+ }
1194
+
1195
+ // ==================== 日志查看 ====================
1196
+
1197
+ async function openLogViewer() {
1198
+ document.getElementById('log-modal').classList.add('active');
1199
+ await loadLogs();
1200
+ }
1201
+
1202
+ function closeLogViewer() {
1203
+ document.getElementById('log-modal').classList.remove('active');
1204
+ }
1205
+
1206
+ async function loadLogs() {
1207
+ const container = document.getElementById('log-content');
1208
+ const lines = document.getElementById('log-lines-select').value;
1209
+ container.textContent = '加载中...';
1210
+ try {
1211
+ const res = await fetch(`/api/logs?lines=${lines}`);
1212
+ const data = await res.json();
1213
+ document.getElementById('log-total').textContent = data.total ? `(共 ${data.total} 行)` : '';
1214
+ if (!data.lines || data.lines.length === 0) {
1215
+ container.textContent = '暂无日志';
1216
+ return;
1217
+ }
1218
+ container.innerHTML = data.lines.map(line => {
1219
+ let cls = 'log-line';
1220
+ if (/error|fail|失败/i.test(line)) cls += ' log-error';
1221
+ else if (/warn|警告/i.test(line)) cls += ' log-warn';
1222
+ return `<div class="${cls}">${escapeHtml(line)}</div>`;
1223
+ }).join('');
1224
+ container.scrollTop = container.scrollHeight;
1225
+ } catch (err) {
1226
+ container.textContent = '加载失败: ' + err.message;
1227
+ }
1228
+ }
1229
+
1230
+ // ==================== 版本历史 ====================
1231
+
1232
+ async function openHistoryViewer() {
1233
+ document.getElementById('history-modal').classList.add('active');
1234
+ await loadHistory();
1235
+ }
1236
+
1237
+ function closeHistoryViewer() {
1238
+ document.getElementById('history-modal').classList.remove('active');
1239
+ }
1240
+
1241
+ async function loadHistory() {
1242
+ const container = document.getElementById('history-content');
1243
+ container.textContent = '加载中...';
1244
+ try {
1245
+ const res = await fetch('/api/config/history');
1246
+ const data = await res.json();
1247
+ if (!data.snapshots || data.snapshots.length === 0) {
1248
+ container.innerHTML = '<div class="empty">暂无历史版本</div>';
1249
+ return;
1250
+ }
1251
+ const REASON_LABELS = {
1252
+ 'create-proxy': '创建代理',
1253
+ 'update-proxy': '更新代理',
1254
+ 'delete-proxy': '删除代理',
1255
+ 'import-merge': '导入配置(合并)',
1256
+ 'import-overwrite': '导入配置(覆盖)',
1257
+ 'before-rollback': '回滚前备份',
1258
+ 'save': '保存',
1259
+ };
1260
+ container.innerHTML = `<div class="history-list">` + data.snapshots.map(s => {
1261
+ const date = new Date(s.timestamp);
1262
+ const timeStr = date.toLocaleString('zh-CN', { hour12: false });
1263
+ const label = REASON_LABELS[s.reason] || s.reason;
1264
+ const sizeStr = s.size > 1024 ? `${(s.size / 1024).toFixed(1)} KB` : `${s.size} B`;
1265
+ return `
1266
+ <div class="history-item">
1267
+ <div class="history-info">
1268
+ <span class="history-time">${timeStr}</span>
1269
+ <span class="history-reason">${escapeHtml(label)}</span>
1270
+ <span class="history-size">${sizeStr}</span>
1271
+ </div>
1272
+ <button class="btn btn-sm history-rollback-btn" data-file="${escapeHtml(s.file)}">恢复</button>
1273
+ </div>
1274
+ `;
1275
+ }).join('') + '</div>';
1276
+ container.querySelectorAll('.history-rollback-btn').forEach(btn => {
1277
+ btn.addEventListener('click', () => rollbackToSnapshot(btn.dataset.file));
1278
+ });
1279
+ } catch (err) {
1280
+ container.textContent = '加载失败: ' + err.message;
1281
+ }
1282
+ }
1283
+
1284
+ async function rollbackToSnapshot(file) {
1285
+ const ok = await showConfirm('确认恢复到此版本?<br>当前配置会先自动备份。', '确认恢复');
1286
+ if (!ok) return;
1287
+ try {
1288
+ const res = await fetch('/api/config/rollback', {
1289
+ method: 'POST',
1290
+ headers: { 'Content-Type': 'application/json' },
1291
+ body: JSON.stringify({ file }),
1292
+ });
1293
+ const data = await res.json();
1294
+ if (!res.ok) {
1295
+ showToast(data.error || '恢复失败', true);
1296
+ return;
1297
+ }
1298
+ closeHistoryViewer();
1299
+ await Promise.all([loadProxies(), loadProviders()]);
1300
+ showToast('已恢复到历史版本');
1301
+ } catch (err) {
1302
+ showToast('恢复失败: ' + err.message, true);
1303
+ }
1304
+ }
1305
+
1040
1306
  // ==================== 渲染代理列表 ====================
1041
1307
 
1042
1308
  const ROUTING_LABELS = {
@@ -1046,14 +1312,34 @@ const ROUTING_LABELS = {
1046
1312
  fastest: '最快优先',
1047
1313
  };
1048
1314
 
1315
+ function getFilteredProxies() {
1316
+ const q = (document.getElementById('proxy-search-input')?.value || '').trim().toLowerCase();
1317
+ if (!q) return proxies;
1318
+ return proxies.filter(p => {
1319
+ const name = (p.name || '').toLowerCase();
1320
+ const port = String(p.port || '');
1321
+ const provider = (p.providerName || '').toLowerCase();
1322
+ return name.includes(q) || port.includes(q) || provider.includes(q);
1323
+ });
1324
+ }
1325
+
1326
+ function filterProxies() {
1327
+ renderProxies();
1328
+ }
1329
+
1049
1330
  function renderProxies() {
1050
1331
  const container = document.getElementById('proxy-list');
1332
+ const list = getFilteredProxies();
1051
1333
  if (proxies.length === 0) {
1052
1334
  container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
1053
1335
  return;
1054
1336
  }
1337
+ if (list.length === 0) {
1338
+ container.innerHTML = '<div class="empty">没有匹配的代理</div>';
1339
+ return;
1340
+ }
1055
1341
 
1056
- container.innerHTML = proxies.map(p => {
1342
+ container.innerHTML = list.map(p => {
1057
1343
  // Build unified provider rows: primary first, then pool entries
1058
1344
  const primaryRow = {
1059
1345
  name: p.providerName || p.providerUrl || '-',
@@ -1201,6 +1487,140 @@ function closeModal() {
1201
1487
  editingProviderId = null;
1202
1488
  }
1203
1489
 
1490
+ function showTestResultModal(data) {
1491
+ const modal = document.getElementById('test-result-modal');
1492
+ const icon = document.getElementById('test-result-icon');
1493
+ const summary = document.getElementById('test-result-summary');
1494
+ const list = document.getElementById('test-result-list');
1495
+ const closeBtn = document.getElementById('test-result-close');
1496
+
1497
+ if (data.failed === 0) {
1498
+ icon.textContent = '✓';
1499
+ icon.style.background = 'rgba(6, 78, 59, 0.4)';
1500
+ icon.style.color = '#34d399';
1501
+ icon.style.borderColor = 'rgba(52, 211, 153, 0.15)';
1502
+ summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试通过`;
1503
+ } else if (data.passed === 0) {
1504
+ icon.textContent = '✗';
1505
+ icon.style.background = 'rgba(127, 29, 29, 0.4)';
1506
+ icon.style.color = '#f87171';
1507
+ icon.style.borderColor = 'rgba(248, 113, 113, 0.15)';
1508
+ summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试失败`;
1509
+ } else {
1510
+ icon.textContent = '!';
1511
+ icon.style.background = 'rgba(69, 26, 3, 0.4)';
1512
+ icon.style.color = '#fbbf24';
1513
+ icon.style.borderColor = 'rgba(251, 191, 36, 0.15)';
1514
+ summary.innerHTML = `<strong>${data.passed}</strong> 条通过,<strong>${data.failed}</strong> 条失败`;
1515
+ }
1516
+
1517
+ list.innerHTML = data.results.map(r => `
1518
+ <div class="test-result-item ${r.ok ? 'test-ok' : 'test-fail'}">
1519
+ <div class="test-result-row">
1520
+ <span class="test-result-status">${r.ok ? '✓' : '✗'}</span>
1521
+ <span class="test-result-alias">${escapeHtml(r.alias || `Key #${r.index + 1}`)}</span>
1522
+ ${r.latencyMs != null ? `<span class="test-result-latency">${r.latencyMs}ms</span>` : ''}
1523
+ </div>
1524
+ ${r.message ? `<div class="test-result-error">${escapeHtml(r.message)}</div>` : ''}
1525
+ </div>
1526
+ `).join('');
1527
+
1528
+ modal.classList.add('active');
1529
+ closeBtn.onclick = () => modal.classList.remove('active');
1530
+ }
1531
+
1532
+ function clearKeyErrors() {
1533
+ document.querySelectorAll('#api-keys-list .api-key-entry').forEach(row => {
1534
+ row.querySelector('.api-key-input')?.style.removeProperty('border-color');
1535
+ row.querySelector('.api-key-display')?.style.removeProperty('border-color');
1536
+ row.querySelector('.api-key-error')?.remove();
1537
+ });
1538
+ }
1539
+
1540
+ function markKeyErrors(data) {
1541
+ const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
1542
+ for (const r of data.results) {
1543
+ if (!r.ok) {
1544
+ const row = rows[r.index];
1545
+ if (row) {
1546
+ const el = row.querySelector('.api-key-input') || row.querySelector('.api-key-display');
1547
+ if (el) el.style.borderColor = '#ef4444';
1548
+ if (r.message) {
1549
+ const errDiv = document.createElement('div');
1550
+ errDiv.className = 'api-key-error';
1551
+ errDiv.textContent = r.message;
1552
+ // Insert after the API Key form-group
1553
+ const keyGroup = row.querySelectorAll('.form-group')[1];
1554
+ if (keyGroup) keyGroup.appendChild(errDiv);
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ async function testConnection() {
1562
+ const providerId = document.getElementById('provider-id').value;
1563
+ if (!providerId) {
1564
+ showToast('请先选择供应商', true);
1565
+ return;
1566
+ }
1567
+ const protocol = document.getElementById('target-protocol').value;
1568
+ const apiKeys = collectApiKeys();
1569
+ const model = document.getElementById('target-model').value.trim() || '';
1570
+ const btn = document.getElementById('test-connection-btn');
1571
+ btn.disabled = true;
1572
+ btn.textContent = '测试中...';
1573
+ clearKeyErrors();
1574
+ try {
1575
+ const res = await fetch(`/api/providers/${providerId}/test`, {
1576
+ method: 'POST',
1577
+ headers: { 'Content-Type': 'application/json' },
1578
+ body: JSON.stringify({ protocol, apiKeys, model }),
1579
+ });
1580
+ const data = await res.json();
1581
+ if (!data.results || data.results.length === 0) {
1582
+ showToast(data.message || '没有可用的 API Key', true);
1583
+ return;
1584
+ }
1585
+ markKeyErrors(data);
1586
+ showTestResultModal(data);
1587
+ } catch (err) {
1588
+ showToast('测试请求失败: ' + err.message, true);
1589
+ } finally {
1590
+ btn.disabled = false;
1591
+ btn.textContent = '测试连接';
1592
+ }
1593
+ }
1594
+
1595
+ async function autoTestForSave() {
1596
+ const providerId = document.getElementById('provider-id').value;
1597
+ if (!providerId) return true;
1598
+ const protocol = document.getElementById('target-protocol').value;
1599
+ const apiKeys = collectApiKeys();
1600
+ const model = document.getElementById('target-model').value.trim() || '';
1601
+ clearKeyErrors();
1602
+ try {
1603
+ const res = await fetch(`/api/providers/${providerId}/test`, {
1604
+ method: 'POST',
1605
+ headers: { 'Content-Type': 'application/json' },
1606
+ body: JSON.stringify({ protocol, apiKeys, model }),
1607
+ });
1608
+ const data = await res.json();
1609
+ if (!data.results || data.results.length === 0) return true;
1610
+ if (data.failed === 0) {
1611
+ showToast(`${data.total} 条 API Key 测试通过`);
1612
+ return true;
1613
+ }
1614
+ markKeyErrors(data);
1615
+ return await showConfirm(
1616
+ `${data.passed} 条通过,${data.failed} 条失败。<br><br>是否仍然保存?`,
1617
+ '仍然保存'
1618
+ );
1619
+ } catch (err) {
1620
+ return true;
1621
+ }
1622
+ }
1623
+
1204
1624
  async function handleSubmit(e) {
1205
1625
  e.preventDefault();
1206
1626
 
@@ -1210,6 +1630,21 @@ async function handleSubmit(e) {
1210
1630
  return;
1211
1631
  }
1212
1632
 
1633
+ // 保存前自动测试:如果有 API Key 被修改,先测试连接
1634
+ const hasModifiedKeys = !!document.querySelector('#api-keys-list .api-key-entry[data-masked="false"], #api-keys-list .api-key-entry[data-new="true"]');
1635
+ if (hasModifiedKeys) {
1636
+ const saveBtn = document.querySelector('.modal-footer .btn-primary');
1637
+ saveBtn.disabled = true;
1638
+ saveBtn.textContent = '测试中...';
1639
+ try {
1640
+ const canProceed = await autoTestForSave();
1641
+ if (!canProceed) return;
1642
+ } finally {
1643
+ saveBtn.disabled = false;
1644
+ saveBtn.textContent = '保存';
1645
+ }
1646
+ }
1647
+
1213
1648
  const port = parseInt(document.getElementById('proxy-port').value);
1214
1649
 
1215
1650
  const conflict = proxies.find(p => p.id !== editingId && p.port === port);