protocol-proxy 2.8.0 → 2.8.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
@@ -1,1876 +1,1590 @@
1
- let proxies = [];
2
- let providers = [];
3
- let editingId = null;
4
- let editingProviderId = null;
5
- let importData = null;
6
- let statsRange = 'daily';
7
- let statsProxyId = '';
8
- let providerPoolItems = [];
9
- let keyHealth = {};
10
- let statsAutoRefreshTimer = null;
11
-
12
- // ==================== 主题切换 ====================
13
-
14
- const THEMES = [
15
- { id: 'dark', icon: '☾', label: '深色' },
16
- { id: 'light', icon: '', label: '浅色' },
17
- { id: 'pure-black', icon: '●', label: '纯黑' },
18
- { id: 'neon', icon: '⚡', label: '霓虹' },
19
- { id: 'amber', icon: '◈', label: '琥珀' },
20
- ];
21
-
22
- function applyTheme(theme) {
23
- const t = THEMES.find(t => t.id === theme) || THEMES[0];
24
- document.documentElement.setAttribute('data-theme', t.id);
25
- const icon = document.getElementById('theme-icon');
26
- const label = document.getElementById('theme-label');
27
- if (icon) icon.textContent = t.icon;
28
- if (label) label.textContent = t.label;
29
- localStorage.setItem('theme', t.id);
30
- }
31
-
32
- function toggleTheme() {
33
- const current = document.documentElement.getAttribute('data-theme') || 'dark';
34
- const idx = THEMES.findIndex(t => t.id === current);
35
- const next = THEMES[(idx + 1) % THEMES.length];
36
- applyTheme(next.id);
37
- fetch('/api/settings', {
38
- method: 'PUT',
39
- headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify({ theme: next.id }),
41
- }).catch(() => {});
42
- }
43
-
44
- // 初始化主题:优先服务端,fallback 到 localStorage
45
- (async () => {
46
- try {
47
- const res = await fetch('/api/settings');
48
- const settings = await res.json();
49
- applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
50
- } catch {
51
- applyTheme(localStorage.getItem('theme') || 'dark');
52
- }
53
- })();
54
-
55
- // ==================== 数据加载 ====================
56
-
57
- async function loadProxies() {
58
- try {
59
- const res = await fetch('/api/proxies');
60
- proxies = await res.json();
61
- renderProxies();
62
- updateStats();
63
- } catch (err) {
64
- console.error('加载代理失败:', err);
65
- document.getElementById('proxy-list').innerHTML =
66
- '<div class="empty">加载失败,请刷新重试</div>';
67
- }
68
- }
69
-
70
- async function loadProviders() {
71
- try {
72
- const res = await fetch('/api/providers');
73
- providers = await res.json();
74
- } catch (err) {
75
- console.error('加载供应商失败:', err);
76
- providers = [];
77
- }
78
- }
79
-
80
- function updateStats() {
81
- document.getElementById('stat-total').textContent = proxies.length;
82
- document.getElementById('stat-running').textContent =
83
- proxies.filter(p => p.running).length;
84
- }
85
-
86
- async function loadKeyHealth() {
87
- try {
88
- const res = await fetch('/api/key-health');
89
- keyHealth = await res.json();
90
- refreshHealthUI();
91
- } catch (err) {
92
- console.error('加载 Key 健康状态失败:', err);
93
- }
94
- }
95
-
96
- function refreshHealthUI() {
97
- // 更新每个代理卡片上的健康点
98
- document.querySelectorAll('.health-dot[data-provider]').forEach(dot => {
99
- const h = keyHealth[dot.dataset.provider];
100
- dot.className = 'health-dot';
101
- if (!h || h.status === 'unknown') { dot.classList.add('health-unknown'); dot.title = '未检测'; }
102
- else if (h.status === 'healthy') { dot.classList.add('health-ok'); dot.title = 'Key 正常'; }
103
- else if (h.status === 'partial') { dot.classList.add('health-warn'); dot.title = '部分 Key 异常'; }
104
- else { dot.classList.add('health-error'); dot.title = 'Key 全部异常'; }
105
- });
106
- // 更新汇总卡片
107
- renderProviderHealthSummary();
108
- }
109
-
110
- function parseProviderPool(value) {
111
- const text = (value || '').trim();
112
- if (!text) return [];
113
- const seen = new Set();
114
- const items = [];
115
- for (const part of text.split(/[\n,]/)) {
116
- const token = part.trim();
117
- if (!token) continue;
118
- const [providerIdRaw, modelRaw, weightRaw] = token.split(':');
119
- const providerId = (providerIdRaw || '').trim();
120
- if (!providerId) continue;
121
- const model = (modelRaw || '').trim();
122
- const key = `${providerId}\0${model}`;
123
- if (seen.has(key)) continue;
124
- seen.add(key);
125
- items.push({ providerId, model, weight: Math.max(1, parseInt((weightRaw || '1').trim(), 10) || 1) });
126
- }
127
- return items;
128
- }
129
-
130
- function formatProviderPool(pool) {
131
- if (!Array.isArray(pool) || pool.length === 0) return '';
132
- return pool.map(item => {
133
- const w = Math.max(1, parseInt(item.weight, 10) || 1);
134
- return item.model ? `${item.providerId}:${item.model}:${w}` : `${item.providerId}::${w}`;
135
- }).join(', ');
136
- }
137
-
138
- function syncSimpleDropdown(dropdownId, value, hiddenInputId) {
139
- const dropdown = document.getElementById(dropdownId);
140
- if (!dropdown) return value;
141
- const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
142
- const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
143
- const options = Array.from(dropdown.querySelectorAll('.model-option'));
144
- const nextValue = options.some(opt => opt.dataset.value === value)
145
- ? value
146
- : (options[0]?.dataset.value || '');
147
- options.forEach(opt => opt.classList.toggle('selected', opt.dataset.value === nextValue));
148
- if (hiddenInput) hiddenInput.value = nextValue;
149
- const selected = options.find(opt => opt.dataset.value === nextValue);
150
- if (valueEl && selected) {
151
- valueEl.textContent = selected.querySelector('.model-option-name')?.textContent || valueEl.textContent;
152
- }
153
- return nextValue;
154
- }
155
-
156
- function syncProviderPoolState(items) {
157
- providerPoolItems = Array.isArray(items)
158
- ? items
159
- .filter(item => item && item.providerId)
160
- .map(item => ({
161
- providerId: item.providerId,
162
- model: typeof item.model === 'string' ? item.model : '',
163
- weight: Math.max(1, parseInt(item.weight, 10) || 1),
164
- }))
165
- : [];
166
- renderProviderPoolEditor();
167
- }
168
-
169
- function addProviderToPool(providerId, model) {
170
- if (!providerId) return;
171
- const m = model || '';
172
- if (providerPoolItems.some(item => item.providerId === providerId && (item.model || '') === m)) return;
173
- providerPoolItems = [...providerPoolItems, { providerId, model: m, weight: 1 }];
174
- renderProviderPoolEditor();
175
- }
176
-
177
- function removeProviderFromPool(providerId, model) {
178
- const m = model || '';
179
- providerPoolItems = providerPoolItems.filter(item => !(item.providerId === providerId && (item.model || '') === m));
180
- renderProviderPoolEditor();
181
- }
182
-
183
- function updateProviderPoolWeight(providerId, model, weight) {
184
- const m = model || '';
185
- providerPoolItems = providerPoolItems.map(item => (
186
- item.providerId === providerId && (item.model || '') === m
187
- ? { ...item, weight: Math.max(1, parseInt(weight, 10) || 1) }
188
- : item
189
- ));
190
- }
191
-
192
- function renderProviderPoolEditor() {
193
- const container = document.getElementById('provider-pool-list');
194
- const select = document.getElementById('provider-pool-dropdown-options');
195
- const valueEl = document.getElementById('provider-pool-dropdown-value');
196
- const dropdown = document.getElementById('provider-pool-dropdown');
197
- if (!container || !select || !valueEl || !dropdown) return;
198
-
199
- const primaryId = document.getElementById('provider-id').value;
200
- const defaultModel = document.getElementById('target-model').value;
201
- // All providers available (including primary, for different models)
202
- const available = providers.filter(p => p.id);
203
-
204
- // Build dropdown: show providers, each expandable to models
205
- select.innerHTML = available.length === 0
206
- ? '<div class="model-option"><span class="model-option-name">暂无可添加供应商</span></div>'
207
- : available.map(p => {
208
- const models = p.models || [];
209
- const isPrimary = p.id === primaryId;
210
- // Filter out already-added provider+model combos
211
- const usedModels = new Set(
212
- providerPoolItems
213
- .filter(item => item.providerId === p.id)
214
- .map(item => item.model || '')
215
- );
216
- // For primary provider, also exclude its default model (already in use)
217
- if (isPrimary && defaultModel) usedModels.add(defaultModel);
218
- const availModels = models.filter(m => !usedModels.has(m));
219
- // "any model" not available for primary (already covered by defaultModel)
220
- const anyModelUsed = usedModels.has('');
221
- const showAnyModel = !isPrimary && !anyModelUsed;
222
- return `
223
- <div class="pool-provider-group" data-pool-provider="${escapeHtml(p.id)}">
224
- <div class="model-option pool-provider-trigger" data-pool-provider-id="${escapeHtml(p.id)}">
225
- <span class="model-option-name">${escapeHtml(p.name)}</span>
226
- ${p.url ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>` : ''}
227
- <span class="pool-provider-arrow">&#9656;</span>
228
- </div>
229
- <div class="pool-model-sublist" data-pool-models-for="${escapeHtml(p.id)}">
230
- ${showAnyModel ? `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model=""><span class="model-option-name">不指定模型(使用请求模型)</span></div>` : ''}
231
- ${availModels.map(m => `<div class="model-option pool-model-option" data-pool-provider-id="${escapeHtml(p.id)}" data-pool-model="${escapeHtml(m)}"><span class="model-option-name">${escapeHtml(m)}</span></div>`).join('')}
232
- ${availModels.length === 0 && !showAnyModel ? '<div class="model-option"><span class="model-option-name">该供应商所有模型已添加</span></div>' : ''}
233
- </div>
234
- </div>
235
- `;
236
- }).join('');
237
-
238
- valueEl.textContent = available.length === 0 ? '暂无可添加供应商' : '从供应商列表添加';
239
-
240
- // Provider click → toggle model sub-list
241
- select.querySelectorAll('.pool-provider-trigger').forEach(trigger => {
242
- trigger.addEventListener('click', (e) => {
243
- e.stopPropagation();
244
- const group = trigger.closest('.pool-provider-group');
245
- const wasOpen = group.classList.contains('open');
246
- // Close all other sub-lists
247
- select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
248
- if (!wasOpen) group.classList.add('open');
249
- });
250
- });
251
-
252
- // Model click → add to pool
253
- select.querySelectorAll('.pool-model-option').forEach(opt => {
254
- opt.addEventListener('click', () => {
255
- addProviderToPool(opt.dataset.poolProviderId, opt.dataset.poolModel || '');
256
- dropdown.classList.remove('open');
257
- select.querySelectorAll('.pool-provider-group').forEach(g => g.classList.remove('open'));
258
- });
259
- });
260
-
261
- // Render pool items
262
- container.innerHTML = providerPoolItems.length === 0
263
- ? '<div class="provider-pool-empty">暂无备选供应商,使用上方下拉框添加</div>'
264
- : providerPoolItems.map(item => {
265
- const provider = providers.find(p => p.id === item.providerId);
266
- const modelLabel = item.model || '使用请求模型';
267
- return `
268
- <div class="provider-pool-item">
269
- <div class="provider-pool-main">
270
- <div class="provider-pool-name">${escapeHtml(provider?.name || item.providerId)}</div>
271
- <div class="provider-pool-meta">${escapeHtml(provider?.url || '')}</div>
272
- </div>
273
- <div class="provider-pool-model">
274
- <label>模型</label>
275
- <span class="provider-pool-model-value">${escapeHtml(modelLabel)}</span>
276
- </div>
277
- <div class="provider-pool-weight">
278
- <label>权重</label>
279
- <input type="number" min="1" step="1" value="${Math.max(1, parseInt(item.weight, 10) || 1)}" data-weight-provider="${escapeHtml(item.providerId)}" data-weight-model="${escapeHtml(item.model || '')}">
280
- </div>
281
- <button type="button" class="provider-pool-remove" data-remove-provider="${escapeHtml(item.providerId)}" data-remove-model="${escapeHtml(item.model || '')}">移除</button>
282
- </div>
283
- `;
284
- }).join('');
285
-
286
- container.querySelectorAll('[data-weight-provider]').forEach(input => {
287
- const handler = () => updateProviderPoolWeight(input.dataset.weightProvider, input.dataset.weightModel, input.value);
288
- input.addEventListener('change', handler);
289
- input.addEventListener('input', handler);
290
- });
291
-
292
- container.querySelectorAll('[data-remove-provider]').forEach(btn => {
293
- btn.addEventListener('click', () => removeProviderFromPool(btn.dataset.removeProvider, btn.dataset.removeModel));
294
- });
295
- }
296
-
297
- // ==================== 供应商下拉框 ====================
298
-
299
- function initProviderDropdown() {
300
- const trigger = document.getElementById('provider-dropdown-trigger');
301
- const dropdown = document.getElementById('provider-dropdown');
302
- const addNameInput = document.getElementById('provider-add-name');
303
- const addUrlInput = document.getElementById('provider-add-url');
304
- const addBtn = document.getElementById('provider-add-btn');
305
-
306
- trigger.addEventListener('click', (e) => {
307
- e.stopPropagation();
308
- dropdown.classList.toggle('open');
309
- if (dropdown.classList.contains('open')) {
310
- editingProviderId = null;
311
- addNameInput.value = '';
312
- addUrlInput.value = '';
313
- addUrlInput.disabled = false;
314
- addBtn.textContent = '添加';
315
- renderProviderOptions();
316
- addNameInput.focus();
317
- }
318
- });
319
-
320
- document.addEventListener('click', (e) => {
321
- if (!dropdown.contains(e.target)) {
322
- dropdown.classList.remove('open');
323
- }
324
- });
325
-
326
- addBtn.addEventListener('click', async () => {
327
- const name = addNameInput.value.trim();
328
- const url = addUrlInput.value.trim();
329
- if (!name || !url) {
330
- showToast('请填写供应商名称和地址', true);
331
- return;
332
- }
333
- try {
334
- let res;
335
- if (editingProviderId) {
336
- // 更新模式
337
- res = await fetch(`/api/providers/${editingProviderId}`, {
338
- method: 'PUT',
339
- headers: { 'Content-Type': 'application/json' },
340
- body: JSON.stringify({ name }),
341
- });
342
- } else {
343
- // 新增模式
344
- res = await fetch('/api/providers', {
345
- method: 'POST',
346
- headers: { 'Content-Type': 'application/json' },
347
- body: JSON.stringify({ name, url }),
348
- });
349
- }
350
- if (!res.ok) {
351
- const err = await res.json();
352
- showToast(err.error || '操作失败', true);
353
- return;
354
- }
355
- const provider = await res.json();
356
- editingProviderId = null;
357
- addUrlInput.disabled = false;
358
- addBtn.textContent = '添加';
359
- addNameInput.value = '';
360
- addUrlInput.value = '';
361
- await loadProviders();
362
- selectProvider(provider.id);
363
- renderProviderOptions();
364
- } catch (err) {
365
- showToast('操作失败: ' + err.message, true);
366
- }
367
- });
368
-
369
- addUrlInput.addEventListener('keydown', (e) => {
370
- if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
371
- if (e.key === 'Escape') dropdown.classList.remove('open');
372
- });
373
- addNameInput.addEventListener('keydown', (e) => {
374
- if (e.key === 'Escape') dropdown.classList.remove('open');
375
- });
376
- }
377
-
378
- function renderProviderOptions() {
379
- const container = document.getElementById('provider-dropdown-options');
380
- const currentId = document.getElementById('provider-id').value;
381
-
382
- container.innerHTML = providers.map(p => `
383
- <div class="model-option${p.id === currentId ? ' selected' : ''}" data-id="${escapeHtml(p.id)}">
384
- <span class="model-option-name">${escapeHtml(p.name)}</span>
385
- ${p.name !== p.url ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>` : ''}
386
- <span style="margin-left:auto;display:flex;gap:4px">
387
- <button type="button" class="model-option-delete" data-edit-id="${escapeHtml(p.id)}" title="编辑此供应商" style="color:#60a5fa;font-size:14px">&#9998;</button>
388
- <button type="button" class="model-option-delete" data-delete-id="${escapeHtml(p.id)}" title="删除此供应商">&times;</button>
389
- </span>
390
- </div>
391
- `).join('');
392
-
393
- if (providers.length === 0) {
394
- container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无供应商,请在下方添加</div>';
395
- }
396
-
397
- container.querySelectorAll('.model-option').forEach(opt => {
398
- opt.addEventListener('click', (e) => {
399
- if (e.target.closest('.model-option-delete')) return;
400
- selectProvider(opt.dataset.id);
401
- document.getElementById('provider-dropdown').classList.remove('open');
402
- });
403
- });
404
-
405
- // 编辑供应商
406
- container.querySelectorAll('[data-edit-id]').forEach(btn => {
407
- btn.addEventListener('click', async (e) => {
408
- e.stopPropagation();
409
- const id = btn.dataset.editId;
410
- try {
411
- const res = await fetch(`/api/providers/${id}`);
412
- if (!res.ok) throw new Error('加载失败');
413
- const p = await res.json();
414
- editingProviderId = id;
415
- document.getElementById('provider-add-name').value = p.name;
416
- document.getElementById('provider-add-url').value = p.url;
417
- document.getElementById('provider-add-url').disabled = true;
418
- document.getElementById('provider-add-btn').textContent = '更新';
419
- } catch (err) {
420
- showToast('加载供应商失败: ' + err.message, true);
421
- }
422
- });
423
- });
424
-
425
- // 删除供应商
426
- container.querySelectorAll('[data-delete-id]').forEach(btn => {
427
- btn.addEventListener('click', async (e) => {
428
- e.stopPropagation();
429
- const id = btn.dataset.deleteId;
430
- const p = providers.find(pr => pr.id === id);
431
- const ok = await showConfirm(`确定要删除供应商 <strong>${escapeHtml(p?.name || '')}</strong> 吗?`);
432
- if (!ok) return;
433
- try {
434
- const res = await fetch(`/api/providers/${id}`, { method: 'DELETE' });
435
- if (!res.ok) {
436
- const err = await res.json();
437
- showToast(err.error || '删除失败', true);
438
- return;
439
- }
440
- await loadProviders();
441
- if (document.getElementById('provider-id').value === id) {
442
- selectProvider('');
443
- }
444
- renderProviderOptions();
445
- } catch (err) {
446
- showToast('删除失败: ' + err.message, true);
447
- }
448
- });
449
- });
450
- }
451
-
452
- function attachMaskedKeyClick(span) {
453
- span.style.cursor = 'pointer';
454
- span.title = '点击修改 API Key';
455
- span.addEventListener('click', () => {
456
- const row = span.closest('.api-key-entry');
457
- const group = span.parentElement;
458
- const input = document.createElement('input');
459
- input.type = 'password';
460
- input.className = 'api-key-input';
461
- input.placeholder = '输入新的 API Key...';
462
- group.replaceChild(input, span);
463
- input.focus();
464
- input.addEventListener('blur', async () => {
465
- const val = input.value.trim();
466
- if (!val) {
467
- restoreMaskedSpan(group, input, row);
468
- return;
469
- }
470
- if (await showConfirm('确认修改此 API Key?<br>取消将恢复为 ****', '确认修改')) {
471
- row.dataset.masked = 'false';
472
- } else {
473
- row.dataset.masked = 'true';
474
- restoreMaskedSpan(group, input, row);
475
- }
476
- });
477
- });
478
- }
479
-
480
- function restoreMaskedSpan(group, input, row) {
481
- const restored = document.createElement('span');
482
- restored.className = 'api-key-display';
483
- restored.textContent = '****';
484
- group.replaceChild(restored, input);
485
- attachMaskedKeyClick(restored);
486
- }
487
-
488
- function renderApiKeys(provider) {
489
- const container = document.getElementById('api-keys-list');
490
- if (!container) return;
491
- const keys = provider?.apiKeys || [];
492
- const hasKeys = keys.length > 0;
493
- const items = hasKeys ? keys : [{ alias: '', masked: false, key: '', index: 0 }];
494
- container.innerHTML = items.map((k, i) => `
495
- <div class="form-row api-key-entry" data-index="${k.index ?? i}" data-masked="${k.masked ? 'true' : 'false'}" ${!hasKeys ? 'data-new="true"' : ''}>
496
- <div class="form-group">
497
- <label>别名</label>
498
- <input type="text" class="api-key-alias" value="${escapeHtml(k.alias || '')}" placeholder="可选">
499
- </div>
500
- <div class="form-group">
501
- <label>API Key</label>
502
- ${k.masked
503
- ? `<span class="api-key-display">****</span>`
504
- : `<input type="password" class="api-key-input" value="${escapeHtml(k.key || '')}" placeholder="sk-...">`
505
- }
506
- </div>
507
- <label class="toggle-switch" title="${k.enabled !== false ? '已启用' : '已禁用'}">
508
- <input type="checkbox" class="api-key-enabled" ${k.enabled !== false ? 'checked' : ''}>
509
- <span class="toggle-slider"></span>
510
- </label>
511
- <button type="button" class="api-key-remove" title="移除">&times;</button>
512
- </div>
513
- `).join('');
514
-
515
- container.querySelectorAll('.api-key-display').forEach(span => {
516
- attachMaskedKeyClick(span);
517
- });
518
-
519
- container.querySelectorAll('.api-key-remove').forEach(btn => {
520
- btn.addEventListener('click', () => {
521
- btn.closest('.api-key-entry').remove();
522
- if (container.children.length === 0) renderApiKeys(null);
523
- });
524
- });
525
- }
526
-
527
- function collectApiKeys() {
528
- const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
529
- return Array.from(rows).map(row => {
530
- const alias = row.querySelector('.api-key-alias')?.value.trim() || '';
531
- const enabled = row.querySelector('.api-key-enabled')?.checked !== false;
532
- const isMasked = row.dataset.masked === 'true';
533
- if (isMasked) {
534
- return { alias, masked: true, index: parseInt(row.dataset.index, 10), enabled };
535
- }
536
- const key = row.querySelector('.api-key-input')?.value.trim() || '';
537
- if (!key) return null;
538
- return { key, alias, enabled };
539
- }).filter(Boolean);
540
- }
541
-
542
- function initApiKeyAddBtn() {
543
- const btn = document.getElementById('api-key-add-btn');
544
- if (!btn) return;
545
- btn.addEventListener('click', () => {
546
- const container = document.getElementById('api-keys-list');
547
- const row = document.createElement('div');
548
- row.className = 'form-row api-key-entry';
549
- row.dataset.new = 'true';
550
- row.innerHTML = `
551
- <div class="form-group"><label>别名</label><input type="text" class="api-key-alias" placeholder="可选"></div>
552
- <div class="form-group"><label>API Key</label><input type="password" class="api-key-input" placeholder="sk-..."></div>
553
- <label class="toggle-switch" title="已启用"><input type="checkbox" class="api-key-enabled" checked><span class="toggle-slider"></span></label>
554
- <button type="button" class="api-key-remove" title="移除">&times;</button>
555
- `;
556
- container.appendChild(row);
557
- row.querySelector('.api-key-alias').focus();
558
- row.querySelector('.api-key-remove').addEventListener('click', () => {
559
- row.remove();
560
- if (container.children.length === 0) renderApiKeys(null);
561
- });
562
- });
563
- }
564
-
565
- function hasUnsavedMaskedKeyEdits() {
566
- return !!document.querySelector('#api-keys-list .api-key-entry[data-index][data-masked="false"]');
567
- }
568
-
569
- async function selectProvider(id) {
570
- if (hasUnsavedMaskedKeyEdits() && !await showConfirm('当前有未保存的 API Key 修改,切换供应商将丢失,确认切换?', '确认切换')) return;
571
- const provider = providers.find(p => p.id === id);
572
- document.getElementById('provider-id').value = id || '';
573
- const protocol = provider ? provider.protocol : '';
574
- document.getElementById('target-protocol').value = protocol;
575
- // 同步协议自定义下拉框
576
- document.querySelectorAll('#protocol-dropdown .model-option').forEach(o => o.classList.remove('selected'));
577
- const protoOpt = document.querySelector(`#protocol-dropdown .model-option[data-value="${protocol}"]`);
578
- if (protoOpt) {
579
- protoOpt.classList.add('selected');
580
- document.getElementById('protocol-dropdown-value').textContent = protoOpt.querySelector('.model-option-name').textContent;
581
- } else {
582
- document.getElementById('protocol-dropdown-value').textContent = '选择协议...';
583
- }
584
- document.getElementById('provider-dropdown-value').textContent = provider
585
- ? (provider.name !== provider.url ? `${provider.name} - ${provider.url}` : provider.url)
586
- : '选择供应商...';
587
- // 切换供应商后模型自动选为该供应商模型列表的第一个
588
- const models = provider?.models || [];
589
- selectModel(models[0] || '');
590
- updateModelAddState();
591
- // 同步 API Keys
592
- renderApiKeys(provider);
593
- // 同步 Azure 字段
594
- document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
595
- document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
596
- document.getElementById('azure-fields').style.display = protocol === 'openai' ? '' : 'none';
597
- // Only remove pool entries matching this provider's default model (allow other models)
598
- const currentModel = models[0] || '';
599
- providerPoolItems = providerPoolItems.filter(item => !(item.providerId === id && (!item.model || item.model === currentModel)));
600
- renderProviderPoolEditor();
601
- }
602
-
603
- // ==================== Model 下拉框 ====================
604
-
605
- function initModelDropdown() {
606
- const trigger = document.getElementById('model-dropdown-trigger');
607
- const dropdown = document.getElementById('model-dropdown');
608
- const addInput = document.getElementById('model-add-input');
609
- const addBtn = document.getElementById('model-add-btn');
610
-
611
- trigger.addEventListener('click', (e) => {
612
- e.stopPropagation();
613
- dropdown.classList.toggle('open');
614
- if (dropdown.classList.contains('open')) {
615
- addInput.value = '';
616
- addInput.focus();
617
- }
618
- });
619
-
620
- document.addEventListener('click', (e) => {
621
- if (!dropdown.contains(e.target)) {
622
- dropdown.classList.remove('open');
623
- }
624
- });
625
-
626
- addBtn.addEventListener('click', async () => {
627
- const providerId = document.getElementById('provider-id').value;
628
- if (!providerId) {
629
- showToast('请先选择供应商', true);
630
- return;
631
- }
632
- const name = addInput.value.trim();
633
- if (!name) return;
634
- const provider = providers.find(p => p.id === providerId);
635
- if (!provider) return;
636
- const models = [...(provider.models || []), name];
637
- try {
638
- await fetch(`/api/providers/${providerId}`, {
639
- method: 'PUT',
640
- headers: { 'Content-Type': 'application/json' },
641
- body: JSON.stringify({ models }),
642
- });
643
- await loadProviders();
644
- selectModel(name);
645
- renderModelOptions();
646
- addInput.value = '';
647
- addInput.focus();
648
- } catch (err) {
649
- showToast('添加模型失败: ' + err.message, true);
650
- }
651
- });
652
-
653
- addInput.addEventListener('keydown', (e) => {
654
- if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
655
- if (e.key === 'Escape') dropdown.classList.remove('open');
656
- });
657
- }
658
-
659
- async function importModels() {
660
- const providerId = document.getElementById('provider-id').value;
661
- if (!providerId) {
662
- showToast('请先选择供应商', true);
663
- return;
664
- }
665
- const btn = document.getElementById('model-import-btn');
666
- btn.disabled = true;
667
- btn.textContent = '导入中...';
668
- try {
669
- const apiKeys = collectApiKeys();
670
- const res = await fetch(`/api/providers/${providerId}/available-models`, {
671
- method: 'POST',
672
- headers: { 'Content-Type': 'application/json' },
673
- body: JSON.stringify({ apiKeys }),
674
- });
675
- const data = await res.json();
676
- if (!data.models || data.models.length === 0) {
677
- showToast(data.message || '未获取到模型', true);
678
- return;
679
- }
680
- const provider = providers.find(p => p.id === providerId);
681
- const existing = new Set(provider?.models || []);
682
- const newModels = data.models.filter(m => !existing.has(m));
683
- if (newModels.length === 0) {
684
- showToast(`已全部存在,共 ${data.models.length} 个模型`);
685
- // 即使没有新模型,也尝试自动选择第一个
686
- if (!document.getElementById('target-model').value && data.models.length > 0) {
687
- selectModel(data.models[0]);
688
- }
689
- return;
690
- }
691
- const merged = [...(provider?.models || []), ...newModels];
692
- await fetch(`/api/providers/${providerId}`, {
693
- method: 'PUT',
694
- headers: { 'Content-Type': 'application/json' },
695
- body: JSON.stringify({ models: merged }),
696
- });
697
- await loadProviders();
698
- renderModelOptions();
699
- // 自动选择默认模型
700
- if (!document.getElementById('target-model').value) {
701
- selectModel(newModels[0] || data.models[0]);
702
- }
703
- showToast(`已导入 ${newModels.length} 个新模型(共 ${data.models.length} 个)`);
704
- } catch (err) {
705
- showToast('导入失败: ' + err.message, true);
706
- } finally {
707
- btn.disabled = false;
708
- btn.textContent = '自动导入';
709
- }
710
- }
711
-
712
- function renderModelOptions() {
713
- const container = document.getElementById('model-dropdown-options');
714
- const providerId = document.getElementById('provider-id').value;
715
- const provider = providers.find(p => p.id === providerId);
716
- const models = provider?.models || [];
717
- const current = document.getElementById('target-model').value;
718
-
719
- if (!providerId) {
720
- container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">请先选择供应商</div>';
721
- } else if (models.length === 0) {
722
- container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无模型,请在下方添加</div>';
723
- } else {
724
- container.innerHTML = models.map(m => `
725
- <div class="model-option${m === current ? ' selected' : ''}" data-model="${escapeHtml(m)}">
726
- <span class="model-option-name">${escapeHtml(m)}</span>
727
- <button type="button" class="model-option-delete" data-delete="${escapeHtml(m)}" title="删除此模型">&times;</button>
728
- </div>
729
- `).join('');
730
- }
731
-
732
- container.querySelectorAll('.model-option').forEach(opt => {
733
- opt.addEventListener('click', (e) => {
734
- if (e.target.closest('.model-option-delete')) return;
735
- selectModel(opt.dataset.model);
736
- document.getElementById('model-dropdown').classList.remove('open');
737
- });
738
- });
739
-
740
- container.querySelectorAll('.model-option-delete').forEach(btn => {
741
- btn.addEventListener('click', async (e) => {
742
- e.stopPropagation();
743
- const name = btn.dataset.delete;
744
- const ok = await showConfirm(`确定要删除模型 <strong>${escapeHtml(name)}</strong> 吗?`);
745
- if (!ok) return;
746
- const provider = providers.find(p => p.id === providerId);
747
- if (!provider) return;
748
- const models = (provider.models || []).filter(m => m !== name);
749
- try {
750
- await fetch(`/api/providers/${providerId}`, {
751
- method: 'PUT',
752
- headers: { 'Content-Type': 'application/json' },
753
- body: JSON.stringify({ models }),
754
- });
755
- await loadProviders();
756
- if (document.getElementById('target-model').value === name) {
757
- selectModel('');
758
- }
759
- renderModelOptions();
760
- } catch (err) {
761
- showToast('删除模型失败: ' + err.message, true);
762
- }
763
- });
764
- });
765
- }
766
-
767
- function selectModel(value) {
768
- document.getElementById('target-model').value = value || '';
769
- document.getElementById('model-dropdown-value').textContent = value || '选择模型...';
770
- renderModelOptions();
771
- }
772
-
773
- function updateModelAddState() {
774
- const providerId = document.getElementById('provider-id').value;
775
- const addInput = document.getElementById('model-add-input');
776
- const addBtn = document.getElementById('model-add-btn');
777
- if (providerId) {
778
- addInput.disabled = false;
779
- addBtn.disabled = false;
780
- addInput.placeholder = '输入模型名称';
781
- } else {
782
- addInput.disabled = true;
783
- addBtn.disabled = true;
784
- addInput.placeholder = '请先选择供应商';
785
- }
786
- }
787
-
788
- // ==================== 配置导入/导出 ====================
789
-
790
- async function exportConfig() {
791
- try {
792
- const res = await fetch('/api/config/export');
793
- const data = await res.json();
794
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
795
- const url = URL.createObjectURL(blob);
796
- const a = document.createElement('a');
797
- const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
798
- a.href = url;
799
- a.download = `config-backup-${date}.json`;
800
- a.click();
801
- URL.revokeObjectURL(url);
802
- showToast('配置已导出');
803
- } catch (err) {
804
- showToast('导出失败: ' + err.message, true);
805
- }
806
- }
807
-
808
- function handleImportFile(e) {
809
- const file = e.target.files[0];
810
- if (!file) return;
811
- e.target.value = '';
812
-
813
- const reader = new FileReader();
814
- reader.onload = () => {
815
- try {
816
- const data = JSON.parse(reader.result);
817
- if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
818
- showToast('配置格式错误:需要 providers proxies 数组', true);
819
- return;
820
- }
821
- importData = data;
822
- document.getElementById('import-providers-count').textContent = data.providers.length;
823
- document.getElementById('import-proxies-count').textContent = data.proxies.length;
824
- document.getElementById('import-modal').classList.add('active');
825
- } catch (err) {
826
- showToast('文件解析失败: ' + err.message, true);
827
- }
828
- };
829
- reader.readAsText(file);
830
- }
831
-
832
- function closeImportModal() {
833
- document.getElementById('import-modal').classList.remove('active');
834
- importData = null;
835
- }
836
-
837
- async function confirmImport() {
838
- if (!importData) return;
839
- const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
840
-
841
- if (mode === 'overwrite') {
842
- const ok = await showConfirm('确认<strong>覆盖</strong>现有配置?此操作不可撤销。');
843
- if (!ok) return;
844
- }
845
-
846
- try {
847
- const res = await fetch('/api/config/import', {
848
- method: 'POST',
849
- headers: { 'Content-Type': 'application/json' },
850
- body: JSON.stringify({ config: importData, mode }),
851
- });
852
- const result = await res.json();
853
-
854
- if (!res.ok) {
855
- showToast(result.error || '导入失败', true);
856
- return;
857
- }
858
-
859
- closeImportModal();
860
- await Promise.all([loadProxies(), loadProviders()]);
861
-
862
- const added = result.added;
863
- let msg = `导入成功(${mode === 'overwrite' ? '覆盖' : '合并'})`;
864
- if (added) msg += `:新增 ${added.providers} 供应商、${added.proxies} 代理`;
865
-
866
- const restart = await showConfirm(`${msg}。<br><br>运行中的代理需要重启才能应用变更,新增的代理需要手动启动。<br><br>是否立即重启所有代理?`);
867
- if (restart) {
868
- await restartAllProxies();
869
- }
870
- } catch (err) {
871
- showToast('导入失败: ' + err.message, true);
872
- }
873
- }
874
-
875
- async function restartAllProxies() {
876
- try {
877
- // 先停掉所有运行中的代理(不管 ID 是否匹配新配置)
878
- const statusRes = await fetch('/api/status');
879
- const status = await statusRes.json();
880
- const runningIds = (status.running || []).map(r => r.id);
881
- for (const id of runningIds) {
882
- await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
883
- }
884
- // 重新加载配置
885
- await loadProxies();
886
- // 按新配置启动所有代理
887
- for (const p of proxies) {
888
- await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
889
- }
890
- await loadProxies();
891
- showToast('所有代理已重启');
892
- } catch (err) {
893
- showToast('重启失败: ' + err.message, true);
894
- }
895
- }
896
-
897
- // ==================== 初始化 ====================
898
-
899
- // ==================== Token 用量统计 ====================
900
-
901
- async function loadStats() {
902
- try {
903
- const params = new URLSearchParams({ range: statsRange });
904
- if (statsProxyId) params.set('proxyId', statsProxyId);
905
- const startDate = document.getElementById('stats-start-date')?.value;
906
- const endDate = document.getElementById('stats-end-date')?.value;
907
- if (startDate) params.set('startDate', startDate);
908
- if (endDate) params.set('endDate', endDate);
909
- const res = await fetch('/api/stats?' + params);
910
- const data = await res.json();
911
- renderStatsSummary(data.summary);
912
- renderStatsBreakdown(data);
913
- renderStatsProxyOptions(data.proxies || []);
914
- } catch (err) {
915
- console.error('加载统计失败:', err);
916
- }
917
- }
918
-
919
- function renderStatsSummary(summary) {
920
- document.getElementById('stats-total-tokens').textContent = formatTokens(summary.total);
921
- document.getElementById('stats-prompt-tokens').textContent = formatTokens(summary.prompt);
922
- document.getElementById('stats-completion-tokens').textContent = formatTokens(summary.completion);
923
- document.getElementById('stats-total-requests').textContent = summary.requests.toLocaleString();
924
- const badge = document.getElementById('stats-estimated-badge');
925
- if (badge) badge.style.display = summary.hasEstimated ? 'inline' : 'none';
926
- }
927
-
928
- function formatTokens(n) {
929
- if (!n || n === 0) return '0';
930
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
931
- if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
932
- return n.toLocaleString();
933
- }
934
-
935
- function renderStatsBreakdown(data) {
936
- const container = document.getElementById('stats-breakdown');
937
- const { byProvider, byModel, summary } = data;
938
-
939
- if (!byProvider || byProvider.length === 0) {
940
- container.innerHTML = '<div class="empty">暂无数据</div>';
941
- return;
942
- }
943
-
944
- let html = '<table class="stats-table"><thead><tr>';
945
- html += '<th>供应商</th><th>模型</th><th style="text-align:right">请求数</th>';
946
- html += '<th style="text-align:right">输入 Token</th><th style="text-align:right">输出 Token</th>';
947
- html += '<th style="text-align:right">合计</th>';
948
- html += '</tr></thead><tbody>';
949
-
950
- for (const item of byModel) {
951
- const prefix = item.hasEstimated ? '~' : '';
952
- html += '<tr>';
953
- html += `<td class="provider-cell">${escapeHtml(item.provider)}</td>`;
954
- html += `<td class="model-cell"><code>${escapeHtml(item.model)}</code></td>`;
955
- html += `<td class="num">${item.requests.toLocaleString()}</td>`;
956
- html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.prompt)}</td>`;
957
- html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.completion)}</td>`;
958
- html += `<td class="num">${prefix ? `<span class="num-estimated" title="估算值">~</span>` : ''}${formatTokens(item.total)}</td>`;
959
- html += '</tr>';
960
- }
961
-
962
- html += '</tbody>';
963
- html += '<tfoot><tr>';
964
- html += '<td colspan="2">合计</td>';
965
- html += `<td class="num">${summary.requests.toLocaleString()}</td>`;
966
- html += `<td class="num">${formatTokens(summary.prompt)}</td>`;
967
- html += `<td class="num">${formatTokens(summary.completion)}</td>`;
968
- html += `<td class="num">${formatTokens(summary.total)}</td>`;
969
- html += '</tr></tfoot></table>';
970
-
971
- container.innerHTML = html;
972
- }
973
-
974
- function renderStatsProxyOptions(proxyList) {
975
- const container = document.getElementById('stats-proxy-dropdown-options');
976
- container.innerHTML = `<div class="model-option${!statsProxyId ? ' selected' : ''}" data-proxy-id="">
977
- <span class="model-option-name">全部代理</span>
978
- </div>` + proxyList.map(p => `
979
- <div class="model-option${p.id === statsProxyId ? ' selected' : ''}" data-proxy-id="${escapeHtml(p.id)}">
980
- <span class="model-option-name">${escapeHtml(p.name)}</span>
981
- ${p.providerName ? `<span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.providerName)}</span>` : ''}
982
- </div>
983
- `).join('');
984
-
985
- container.querySelectorAll('.model-option').forEach(opt => {
986
- opt.addEventListener('click', () => {
987
- statsProxyId = opt.dataset.proxyId;
988
- document.getElementById('stats-proxy-dropdown-value').textContent =
989
- statsProxyId ? (proxyList.find(p => p.id === statsProxyId)?.name || '全部代理') : '全部代理';
990
- document.getElementById('stats-proxy-dropdown').classList.remove('open');
991
- container.querySelectorAll('.model-option').forEach(o => o.classList.remove('selected'));
992
- opt.classList.add('selected');
993
- loadStats();
994
- });
995
- });
996
- }
997
-
998
- function initStatsDropdown() {
999
- const trigger = document.getElementById('stats-proxy-dropdown-trigger');
1000
- const dropdown = document.getElementById('stats-proxy-dropdown');
1001
-
1002
- trigger.addEventListener('click', (e) => {
1003
- e.stopPropagation();
1004
- dropdown.classList.toggle('open');
1005
- });
1006
-
1007
- document.addEventListener('click', (e) => {
1008
- if (!dropdown.contains(e.target)) {
1009
- dropdown.classList.remove('open');
1010
- }
1011
- });
1012
- }
1013
-
1014
- function initStatsRangeBtns() {
1015
- document.querySelectorAll('.stats-range-btn').forEach(btn => {
1016
- btn.addEventListener('click', () => {
1017
- document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
1018
- btn.classList.add('active');
1019
- statsRange = btn.dataset.range;
1020
- // 清除自动刷新
1021
- if (statsAutoRefreshTimer) { clearInterval(statsAutoRefreshTimer); statsAutoRefreshTimer = null; }
1022
- if (statsRange === 'hourly') {
1023
- // 实时模式:设为今天 + 30 秒自动刷新
1024
- const today = new Date().toISOString().slice(0, 10);
1025
- document.getElementById('stats-start-date').value = today;
1026
- document.getElementById('stats-end-date').value = today;
1027
- statsAutoRefreshTimer = setInterval(loadStats, 30000);
1028
- } else {
1029
- document.getElementById('stats-start-date').value = '';
1030
- document.getElementById('stats-end-date').value = '';
1031
- }
1032
- loadStats();
1033
- });
1034
- });
1035
- // 日期选择器变化时自动加载
1036
- document.getElementById('stats-start-date').addEventListener('change', loadStats);
1037
- document.getElementById('stats-end-date').addEventListener('change', loadStats);
1038
- }
1039
-
1040
- function generateToken() {
1041
- const arr = new Uint8Array(24);
1042
- crypto.getRandomValues(arr);
1043
- return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
1044
- }
1045
-
1046
- function initSimpleDropdown(dropdownId, onChange, hiddenInputId) {
1047
- const dropdown = document.getElementById(dropdownId);
1048
- const trigger = dropdown.querySelector('.model-dropdown-trigger');
1049
- const valueEl = dropdown.querySelector('[id$="-dropdown-value"]');
1050
- const hiddenInput = document.getElementById(hiddenInputId || dropdownId.replace('-dropdown', ''));
1051
- const opts = dropdown.querySelectorAll('.model-option');
1052
-
1053
- trigger.addEventListener('click', (e) => {
1054
- e.stopPropagation();
1055
- dropdown.classList.toggle('open');
1056
- });
1057
-
1058
- document.addEventListener('click', (e) => {
1059
- if (!dropdown.contains(e.target)) {
1060
- dropdown.classList.remove('open');
1061
- }
1062
- });
1063
-
1064
- opts.forEach(opt => {
1065
- opt.addEventListener('click', () => {
1066
- const val = syncSimpleDropdown(dropdownId, opt.dataset.value, hiddenInput?.id);
1067
- onChange?.(val);
1068
- dropdown.classList.remove('open');
1069
- });
1070
- });
1071
-
1072
- syncSimpleDropdown(dropdownId, hiddenInput?.value || opts[0]?.dataset.value || '', hiddenInput?.id);
1073
- }
1074
-
1075
- function initProviderPoolDropdown() {
1076
- const dropdown = document.getElementById('provider-pool-dropdown');
1077
- const trigger = document.getElementById('provider-pool-dropdown-trigger');
1078
- if (!dropdown || !trigger) return;
1079
-
1080
- trigger.addEventListener('click', (e) => {
1081
- e.stopPropagation();
1082
- renderProviderPoolEditor();
1083
- dropdown.classList.toggle('open');
1084
- });
1085
-
1086
- document.addEventListener('click', (e) => {
1087
- if (!dropdown.contains(e.target)) dropdown.classList.remove('open');
1088
- });
1089
- }
1090
-
1091
- async function init() {
1092
- // 默认统计范围:当天(HTML 内联脚本已优先设置,此处兜底)
1093
- const today = new Date().toISOString().slice(0, 10);
1094
- const sd = document.getElementById('stats-start-date');
1095
- const ed = document.getElementById('stats-end-date');
1096
- if (!sd.value) sd.value = today;
1097
- if (!ed.value) ed.value = today;
1098
- await Promise.all([loadProxies(), loadProviders(), loadStats()]);
1099
- // 延迟加载 health(等后端启动检测完成),之后每 5 分钟刷新
1100
- setTimeout(() => loadKeyHealth(), 6000);
1101
- setInterval(() => loadKeyHealth(), 5 * 60 * 1000);
1102
- renderProxies();
1103
- initProviderDropdown();
1104
- initModelDropdown();
1105
- initStatsDropdown();
1106
- initStatsRangeBtns();
1107
- initProviderPoolDropdown();
1108
- initApiKeyAddBtn();
1109
- initSimpleDropdown('auth-dropdown', (val) => {
1110
- const enabled = val === 'true';
1111
- document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
1112
- if (enabled && !document.getElementById('proxy-auth-token').value) {
1113
- document.getElementById('proxy-auth-token').value = generateToken();
1114
- }
1115
- });
1116
- initSimpleDropdown('protocol-dropdown', (val) => {
1117
- document.getElementById('azure-fields').style.display = val === 'openai' ? '' : 'none';
1118
- }, 'target-protocol');
1119
- initSimpleDropdown('routing-dropdown', (val) => {
1120
- document.getElementById('routing-strategy').value = val;
1121
- }, 'routing-strategy');
1122
- renderProviderPoolEditor();
1123
- // 初始状态:根据当前协议值决定 Azure 字段显示
1124
- const initProto = document.getElementById('target-protocol').value;
1125
- document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
1126
-
1127
- // 快捷键
1128
- document.addEventListener('keydown', (e) => {
1129
- // Esc 关闭最上层弹窗
1130
- if (e.key === 'Escape') {
1131
- if (document.getElementById('confirm-modal').classList.contains('active')) return;
1132
- if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
1133
- if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
1134
- if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
1135
- if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
1136
- if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
1137
- }
1138
- // Ctrl+S 保存表单
1139
- if ((e.ctrlKey || e.metaKey) && e.key === 's') {
1140
- if (document.getElementById('modal').classList.contains('active')) {
1141
- e.preventDefault();
1142
- document.getElementById('proxy-form').requestSubmit();
1143
- }
1144
- }
1145
- // Ctrl+N 新建代理
1146
- if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
1147
- if (!document.getElementById('modal').classList.contains('active')) {
1148
- e.preventDefault();
1149
- openModal();
1150
- }
1151
- }
1152
- });
1153
- }
1154
-
1155
- // ==================== 代理地址复制 ====================
1156
-
1157
- function getProxyUrl(port) {
1158
- return `http://localhost:${port}`;
1159
- }
1160
-
1161
- function copyProxyUrl(port, btn) {
1162
- const url = getProxyUrl(port);
1163
- navigator.clipboard.writeText(url).then(() => {
1164
- showToast('代理地址已复制');
1165
- const orig = btn.innerHTML;
1166
- btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> 已复制';
1167
- setTimeout(() => { btn.innerHTML = orig; }, 1500);
1168
- }).catch(() => {
1169
- showToast('复制失败,请手动复制', true);
1170
- });
1171
- }
1172
-
1173
- function showConfirm(text, okText = '删除') {
1174
- return new Promise(resolve => {
1175
- const modal = document.getElementById('confirm-modal');
1176
- document.getElementById('confirm-text').innerHTML = text;
1177
- const okBtn = document.getElementById('confirm-ok');
1178
- const cancelBtn = document.getElementById('confirm-cancel');
1179
- okBtn.textContent = okText;
1180
- modal.classList.add('active');
1181
-
1182
- function cleanup(result) {
1183
- modal.classList.remove('active');
1184
- okBtn.removeEventListener('click', onOk);
1185
- cancelBtn.removeEventListener('click', onCancel);
1186
- resolve(result);
1187
- }
1188
- function onOk() { cleanup(true); }
1189
- function onCancel() { cleanup(false); }
1190
-
1191
- okBtn.addEventListener('click', onOk);
1192
- cancelBtn.addEventListener('click', onCancel);
1193
- });
1194
- }
1195
-
1196
- function showToast(msg, isError) {
1197
- const existing = document.querySelector('.toast');
1198
- if (existing) existing.remove();
1199
- const toast = document.createElement('div');
1200
- toast.className = 'toast';
1201
- toast.textContent = msg;
1202
- if (isError) toast.style.background = '#ef4444';
1203
- document.body.appendChild(toast);
1204
- setTimeout(() => toast.remove(), 2000);
1205
- }
1206
-
1207
- // ==================== 批量操作 ====================
1208
-
1209
- async function startAllProxies() {
1210
- try {
1211
- const res = await fetch('/api/proxies/start-all', { method: 'POST' });
1212
- const data = await res.json();
1213
- await loadProxies();
1214
- const started = data.results.filter(r => r.success).length;
1215
- const skipped = data.results.filter(r => r.skipped).length;
1216
- const failed = data.results.filter(r => !r.success && !r.skipped).length;
1217
- let msg = `启动完成:${started} 个启动`;
1218
- if (skipped > 0) msg += `,${skipped} 个已在运行`;
1219
- if (failed > 0) msg += `,${failed} 个失败`;
1220
- showToast(msg, failed > 0);
1221
- } catch (err) {
1222
- showToast('批量启动失败: ' + err.message, true);
1223
- }
1224
- }
1225
-
1226
- async function stopAllProxies() {
1227
- const ok = await showConfirm('确定要停止所有运行中的代理吗?', '全部停止');
1228
- if (!ok) return;
1229
- try {
1230
- const res = await fetch('/api/proxies/stop-all', { method: 'POST' });
1231
- const data = await res.json();
1232
- await loadProxies();
1233
- showToast(`已停止 ${data.results.length} 个代理`);
1234
- } catch (err) {
1235
- showToast('批量停止失败: ' + err.message, true);
1236
- }
1237
- }
1238
-
1239
- // ==================== 日志查看 ====================
1240
-
1241
- async function openLogViewer() {
1242
- document.getElementById('log-modal').classList.add('active');
1243
- await loadLogs();
1244
- }
1245
-
1246
- function closeLogViewer() {
1247
- document.getElementById('log-modal').classList.remove('active');
1248
- }
1249
-
1250
- async function loadLogs() {
1251
- const container = document.getElementById('log-content');
1252
- const lines = document.getElementById('log-lines-select').value;
1253
- container.textContent = '加载中...';
1254
- try {
1255
- const res = await fetch(`/api/logs?lines=${lines}`);
1256
- const data = await res.json();
1257
- document.getElementById('log-total').textContent = data.total ? `(共 ${data.total} 行)` : '';
1258
- if (!data.lines || data.lines.length === 0) {
1259
- container.textContent = '暂无日志';
1260
- return;
1261
- }
1262
- container.innerHTML = data.lines.map(line => {
1263
- let cls = 'log-line';
1264
- if (/error|fail|失败/i.test(line)) cls += ' log-error';
1265
- else if (/warn|警告/i.test(line)) cls += ' log-warn';
1266
- return `<div class="${cls}">${escapeHtml(line)}</div>`;
1267
- }).join('');
1268
- container.scrollTop = container.scrollHeight;
1269
- } catch (err) {
1270
- container.textContent = '加载失败: ' + err.message;
1271
- }
1272
- }
1273
-
1274
- // ==================== 版本历史 ====================
1275
-
1276
- async function openHistoryViewer() {
1277
- document.getElementById('history-modal').classList.add('active');
1278
- await loadHistory();
1279
- }
1280
-
1281
- function closeHistoryViewer() {
1282
- document.getElementById('history-modal').classList.remove('active');
1283
- }
1284
-
1285
- async function loadHistory() {
1286
- const container = document.getElementById('history-content');
1287
- container.textContent = '加载中...';
1288
- try {
1289
- const res = await fetch('/api/config/history');
1290
- const data = await res.json();
1291
- if (!data.snapshots || data.snapshots.length === 0) {
1292
- container.innerHTML = '<div class="empty">暂无历史版本</div>';
1293
- return;
1294
- }
1295
- const REASON_LABELS = {
1296
- 'create-proxy': '创建代理',
1297
- 'update-proxy': '更新代理',
1298
- 'delete-proxy': '删除代理',
1299
- 'import-merge': '导入配置(合并)',
1300
- 'import-overwrite': '导入配置(覆盖)',
1301
- 'before-rollback': '回滚前备份',
1302
- 'save': '保存',
1303
- };
1304
- container.innerHTML = `<div class="history-list">` + data.snapshots.map(s => {
1305
- const date = new Date(s.timestamp);
1306
- const timeStr = date.toLocaleString('zh-CN', { hour12: false });
1307
- const label = REASON_LABELS[s.reason] || s.reason;
1308
- const sizeStr = s.size > 1024 ? `${(s.size / 1024).toFixed(1)} KB` : `${s.size} B`;
1309
- return `
1310
- <div class="history-item">
1311
- <div class="history-info">
1312
- <span class="history-time">${timeStr}</span>
1313
- <span class="history-reason">${escapeHtml(label)}</span>
1314
- <span class="history-size">${sizeStr}</span>
1315
- </div>
1316
- <button class="btn btn-sm history-rollback-btn" data-file="${escapeHtml(s.file)}">恢复</button>
1317
- </div>
1318
- `;
1319
- }).join('') + '</div>';
1320
- container.querySelectorAll('.history-rollback-btn').forEach(btn => {
1321
- btn.addEventListener('click', () => rollbackToSnapshot(btn.dataset.file));
1322
- });
1323
- } catch (err) {
1324
- container.textContent = '加载失败: ' + err.message;
1325
- }
1326
- }
1327
-
1328
- async function rollbackToSnapshot(file) {
1329
- const ok = await showConfirm('确认恢复到此版本?<br>当前配置会先自动备份。', '确认恢复');
1330
- if (!ok) return;
1331
- try {
1332
- const res = await fetch('/api/config/rollback', {
1333
- method: 'POST',
1334
- headers: { 'Content-Type': 'application/json' },
1335
- body: JSON.stringify({ file }),
1336
- });
1337
- const data = await res.json();
1338
- if (!res.ok) {
1339
- showToast(data.error || '恢复失败', true);
1340
- return;
1341
- }
1342
- closeHistoryViewer();
1343
- await Promise.all([loadProxies(), loadProviders()]);
1344
- showToast('已恢复到历史版本');
1345
- } catch (err) {
1346
- showToast('恢复失败: ' + err.message, true);
1347
- }
1348
- }
1349
-
1350
- // ==================== 渲染代理列表 ====================
1351
-
1352
- const ROUTING_LABELS = {
1353
- primary_fallback: '主备切换',
1354
- round_robin: '轮询',
1355
- weighted: '加权',
1356
- fastest: '最快优先',
1357
- };
1358
-
1359
- function getFilteredProxies() {
1360
- const q = (document.getElementById('proxy-search-input')?.value || '').trim().toLowerCase();
1361
- if (!q) return proxies;
1362
- return proxies.filter(p => {
1363
- const name = (p.name || '').toLowerCase();
1364
- const port = String(p.port || '');
1365
- const provider = (p.providerName || '').toLowerCase();
1366
- return name.includes(q) || port.includes(q) || provider.includes(q);
1367
- });
1368
- }
1369
-
1370
- function filterProxies() {
1371
- renderProxies();
1372
- }
1373
-
1374
- function healthDot(providerId) {
1375
- const h = keyHealth[providerId];
1376
- const cls = !h || h.status === 'unknown' ? 'health-unknown'
1377
- : h.status === 'healthy' ? 'health-ok'
1378
- : h.status === 'partial' ? 'health-warn' : 'health-error';
1379
- const title = !h || h.status === 'unknown' ? '未检测'
1380
- : h.status === 'healthy' ? 'Key 正常'
1381
- : h.status === 'partial' ? '部分 Key 异常' : 'Key 全部异常';
1382
- return `<span class="health-dot ${cls}" data-provider="${escapeHtml(providerId)}" title="${title}"></span>`;
1383
- }
1384
-
1385
- function renderProviderHealthSummary() {
1386
- const el = document.getElementById('provider-health-summary');
1387
- if (!el) return;
1388
- const allProviders = proxies.map(p => p.providerId).filter(Boolean);
1389
- const unique = [...new Set(allProviders)];
1390
- if (unique.length === 0) { el.style.display = 'none'; return; }
1391
- let healthy = 0, partial = 0, unhealthy = 0, unknown = 0;
1392
- for (const id of unique) {
1393
- const h = keyHealth[id];
1394
- if (!h || h.status === 'unknown') unknown++;
1395
- else if (h.status === 'healthy') healthy++;
1396
- else if (h.status === 'partial') partial++;
1397
- else unhealthy++;
1398
- }
1399
- el.style.display = '';
1400
- el.innerHTML = `
1401
- <div class="health-stat"><span class="health-dot health-ok"></span><span>正常 ${healthy}</span></div>
1402
- <div class="health-stat"><span class="health-dot health-warn"></span><span>部分异常 ${partial}</span></div>
1403
- <div class="health-stat"><span class="health-dot health-error"></span><span>异常 ${unhealthy}</span></div>
1404
- <div class="health-stat"><span class="health-dot health-unknown"></span><span>未检测 ${unknown}</span></div>
1405
- <button class="btn btn-sm" onclick="recheckKeys()">重新检测</button>
1406
- `;
1407
- }
1408
-
1409
- let rechecking = false;
1410
- async function recheckKeys() {
1411
- if (rechecking) return;
1412
- rechecking = true;
1413
- const btn = document.querySelector('.provider-health-summary .btn');
1414
- if (btn) { btn.disabled = true; btn.textContent = '检测中...'; }
1415
- showToast('正在检测...');
1416
- try {
1417
- await fetch('/api/key-health/check', { method: 'POST' });
1418
- await loadKeyHealth();
1419
- showToast('检测完成');
1420
- } catch (err) {
1421
- showToast('检测失败: ' + err.message, true);
1422
- } finally {
1423
- rechecking = false;
1424
- if (btn) { btn.disabled = false; btn.textContent = '重新检测'; }
1425
- }
1426
- }
1427
-
1428
- function renderProxies() {
1429
- const container = document.getElementById('proxy-list');
1430
- const list = getFilteredProxies();
1431
- renderProviderHealthSummary();
1432
- if (proxies.length === 0) {
1433
- container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
1434
- return;
1435
- }
1436
- if (list.length === 0) {
1437
- container.innerHTML = '<div class="empty">没有匹配的代理</div>';
1438
- return;
1439
- }
1440
-
1441
- container.innerHTML = list.map(p => {
1442
- // Build unified provider rows: primary first, then pool entries
1443
- const primaryRow = {
1444
- name: p.providerName || p.providerUrl || '-',
1445
- tag: '',
1446
- protocol: p.protocol || '-',
1447
- model: p.defaultModel || '-',
1448
- weight: Math.max(1, parseInt(p.providerWeight, 10) || 1),
1449
- };
1450
- const poolRows = (p.providerPool || []).map(item => {
1451
- const prov = providers.find(pr => pr.id === item.providerId);
1452
- return {
1453
- name: prov?.name || item.providerId,
1454
- tag: '备选',
1455
- protocol: prov?.protocol || p.protocol || '-',
1456
- model: item.model || '-',
1457
- weight: Math.max(1, parseInt(item.weight, 10) || 1),
1458
- };
1459
- });
1460
- const allRows = [primaryRow, ...poolRows];
1461
- const strategy = ROUTING_LABELS[p.routingStrategy] || p.routingStrategy;
1462
-
1463
- return `
1464
- <div class="proxy-item">
1465
- <div class="proxy-header">
1466
- <div class="proxy-title">
1467
- <h3>${escapeHtml(p.name)}</h3>
1468
- <span class="badge ${p.running ? 'badge-running' : 'badge-stopped'}">
1469
- ${p.running ? '运行中' : '已停止'}
1470
- </span>
1471
- </div>
1472
- <span class="proxy-routing-badge">${escapeHtml(strategy)}</span>
1473
- </div>
1474
- <div class="proxy-meta">
1475
- <span>端口: <strong>${p.port}</strong></span>
1476
- <span>供应商: ${healthDot(p.providerId)} ${escapeHtml(p.providerName || '-')}</span>
1477
- <span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
1478
- </div>
1479
- <div class="proxy-address">
1480
- <code>${escapeHtml(getProxyUrl(p.port))}</code>
1481
- <button class="copy-btn" onclick="copyProxyUrl(${p.port}, this)">
1482
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
1483
- 复制
1484
- </button>
1485
- </div>
1486
- <table class="target-table">
1487
- <thead>
1488
- <tr>
1489
- <th>供应商</th>
1490
- <th>协议</th>
1491
- <th>模型</th>
1492
- <th>权重</th>
1493
- </tr>
1494
- </thead>
1495
- <tbody>
1496
- ${allRows.map(r => `
1497
- <tr>
1498
- <td>${escapeHtml(r.name)}${r.tag ? `<span class="provider-tag">${r.tag}</span>` : ''}</td>
1499
- <td>
1500
- <span class="badge" style="background:${r.protocol==='openai'?'#0c4a6e':r.protocol==='anthropic'?'#581c87':'#064e3b'};color:${r.protocol==='openai'?'#7dd3fc':r.protocol==='anthropic'?'#e9d5ff':'#6ee7b7'}">
1501
- ${r.protocol}
1502
- </span>
1503
- </td>
1504
- <td><code>${escapeHtml(r.model)}</code></td>
1505
- <td>${r.weight}</td>
1506
- </tr>`).join('')}
1507
- </tbody>
1508
- </table>
1509
- <div class="proxy-actions">
1510
- ${p.running
1511
- ? `<button class="btn btn-danger" onclick="stopProxy('${p.id}')">停止</button>`
1512
- : `<button class="btn btn-success" onclick="startProxy('${p.id}')">启动</button>`
1513
- }
1514
- <button class="btn" onclick="editProxy('${p.id}')">编辑</button>
1515
- <button class="btn btn-danger" onclick="deleteProxy('${p.id}')">删除</button>
1516
- </div>
1517
- </div>
1518
- `}).join('');
1519
- }
1520
-
1521
- // ==================== 弹窗操作 ====================
1522
-
1523
- function openModal(id = null) {
1524
- editingId = id;
1525
- document.getElementById('modal').dataset.proxyId = id || '';
1526
- document.getElementById('modal-title').textContent = id ? '编辑代理' : '新建代理';
1527
- document.getElementById('proxy-form').reset();
1528
-
1529
- if (id) {
1530
- const p = proxies.find(x => x.id === id);
1531
- if (!p) return;
1532
- document.getElementById('proxy-id').value = p.id;
1533
- document.getElementById('proxy-name').value = p.name;
1534
- document.getElementById('proxy-port').value = p.port;
1535
- // 同步认证下拉框
1536
- const authVal = p.requireAuth ? 'true' : 'false';
1537
- document.getElementById('proxy-auth').value = authVal;
1538
- document.querySelectorAll('#auth-dropdown .model-option').forEach(o => o.classList.remove('selected'));
1539
- const authOpt = document.querySelector(`#auth-dropdown .model-option[data-value="${authVal}"]`);
1540
- if (authOpt) {
1541
- authOpt.classList.add('selected');
1542
- document.getElementById('auth-dropdown-value').textContent = authOpt.querySelector('.model-option-name').textContent;
1543
- }
1544
- document.getElementById('proxy-auth-token').value = p.authToken || '';
1545
- document.getElementById('auth-token-group').style.display = p.requireAuth ? 'block' : 'none';
1546
- selectProvider(p.providerId || '');
1547
- selectModel(p.defaultModel || '');
1548
- renderApiKeys(providers.find(pr => pr.id === p.providerId));
1549
- // Azure 字段从供应商配置读取
1550
- const provider = providers.find(pr => pr.id === p.providerId);
1551
- document.getElementById('target-azure-deployment').value = provider?.azureDeployment || '';
1552
- document.getElementById('target-azure-version').value = provider?.azureApiVersion || '';
1553
- document.getElementById('azure-fields').style.display = p.protocol === 'openai' ? '' : 'none';
1554
- document.getElementById('provider-weight').value = Math.max(1, parseInt(p.providerWeight, 10) || 1);
1555
- syncSimpleDropdown('routing-dropdown', p.routingStrategy || 'primary_fallback', 'routing-strategy');
1556
- syncProviderPoolState(p.providerPool || []);
1557
- } else {
1558
- document.getElementById('proxy-id').value = '';
1559
- // 重置认证下拉框
1560
- document.getElementById('proxy-auth').value = 'false';
1561
- document.querySelectorAll('#auth-dropdown .model-option').forEach(o => o.classList.remove('selected'));
1562
- document.querySelector('#auth-dropdown .model-option[data-value="false"]').classList.add('selected');
1563
- document.getElementById('auth-dropdown-value').textContent = '不启用';
1564
- document.getElementById('auth-token-group').style.display = 'none';
1565
- selectProvider('');
1566
- selectModel('');
1567
- renderApiKeys(null);
1568
- document.getElementById('target-azure-deployment').value = '';
1569
- document.getElementById('target-azure-version').value = '';
1570
- document.getElementById('azure-fields').style.display = 'none';
1571
- document.getElementById('provider-weight').value = 1;
1572
- syncSimpleDropdown('routing-dropdown', 'primary_fallback', 'routing-strategy');
1573
- syncProviderPoolState([]);
1574
- }
1575
-
1576
- updateModelAddState();
1577
- document.getElementById('modal').classList.add('active');
1578
- }
1579
-
1580
- function closeModal() {
1581
- document.getElementById('modal').classList.remove('active');
1582
- document.getElementById('model-dropdown').classList.remove('open');
1583
- document.getElementById('provider-dropdown').classList.remove('open');
1584
- document.getElementById('auth-dropdown').classList.remove('open');
1585
- document.getElementById('protocol-dropdown').classList.remove('open');
1586
- editingId = null;
1587
- editingProviderId = null;
1588
- }
1589
-
1590
- function showTestResultModal(data) {
1591
- const modal = document.getElementById('test-result-modal');
1592
- const icon = document.getElementById('test-result-icon');
1593
- const summary = document.getElementById('test-result-summary');
1594
- const list = document.getElementById('test-result-list');
1595
- const closeBtn = document.getElementById('test-result-close');
1596
-
1597
- if (data.failed === 0) {
1598
- icon.textContent = '✓';
1599
- icon.style.background = 'rgba(6, 78, 59, 0.4)';
1600
- icon.style.color = '#34d399';
1601
- icon.style.borderColor = 'rgba(52, 211, 153, 0.15)';
1602
- summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试通过`;
1603
- } else if (data.passed === 0) {
1604
- icon.textContent = '✗';
1605
- icon.style.background = 'rgba(127, 29, 29, 0.4)';
1606
- icon.style.color = '#f87171';
1607
- icon.style.borderColor = 'rgba(248, 113, 113, 0.15)';
1608
- summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试失败`;
1609
- } else {
1610
- icon.textContent = '!';
1611
- icon.style.background = 'rgba(69, 26, 3, 0.4)';
1612
- icon.style.color = '#fbbf24';
1613
- icon.style.borderColor = 'rgba(251, 191, 36, 0.15)';
1614
- summary.innerHTML = `<strong>${data.passed}</strong> 条通过,<strong>${data.failed}</strong> 条失败`;
1615
- }
1616
-
1617
- list.innerHTML = data.results.map(r => `
1618
- <div class="test-result-item ${r.ok ? 'test-ok' : 'test-fail'}">
1619
- <div class="test-result-row">
1620
- <span class="test-result-status">${r.ok ? '✓' : '✗'}</span>
1621
- <span class="test-result-alias">${escapeHtml(r.alias || `Key #${r.index + 1}`)}</span>
1622
- ${r.latencyMs != null ? `<span class="test-result-latency">${r.latencyMs}ms</span>` : ''}
1623
- </div>
1624
- ${r.message ? `<div class="test-result-error">${escapeHtml(r.message)}</div>` : ''}
1625
- </div>
1626
- `).join('');
1627
-
1628
- modal.classList.add('active');
1629
- closeBtn.onclick = () => modal.classList.remove('active');
1630
- }
1631
-
1632
- function clearKeyErrors() {
1633
- document.querySelectorAll('#api-keys-list .api-key-entry').forEach(row => {
1634
- row.querySelector('.api-key-input')?.style.removeProperty('border-color');
1635
- row.querySelector('.api-key-display')?.style.removeProperty('border-color');
1636
- row.querySelector('.api-key-error')?.remove();
1637
- });
1638
- }
1639
-
1640
- function markKeyErrors(data) {
1641
- const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
1642
- for (const r of data.results) {
1643
- if (!r.ok) {
1644
- const row = rows[r.index];
1645
- if (row) {
1646
- const el = row.querySelector('.api-key-input') || row.querySelector('.api-key-display');
1647
- if (el) el.style.borderColor = '#ef4444';
1648
- if (r.message) {
1649
- const errDiv = document.createElement('div');
1650
- errDiv.className = 'api-key-error';
1651
- errDiv.textContent = r.message;
1652
- // Insert after the API Key form-group
1653
- const keyGroup = row.querySelectorAll('.form-group')[1];
1654
- if (keyGroup) keyGroup.appendChild(errDiv);
1655
- }
1656
- }
1657
- }
1658
- }
1659
- }
1660
-
1661
- async function testConnection() {
1662
- const providerId = document.getElementById('provider-id').value;
1663
- if (!providerId) {
1664
- showToast('请先选择供应商', true);
1665
- return;
1666
- }
1667
- const protocol = document.getElementById('target-protocol').value;
1668
- const apiKeys = collectApiKeys();
1669
- const model = document.getElementById('target-model').value.trim() || '';
1670
- const btn = document.getElementById('test-connection-btn');
1671
- btn.disabled = true;
1672
- btn.textContent = '测试中...';
1673
- clearKeyErrors();
1674
- try {
1675
- const res = await fetch(`/api/providers/${providerId}/test`, {
1676
- method: 'POST',
1677
- headers: { 'Content-Type': 'application/json' },
1678
- body: JSON.stringify({ protocol, apiKeys, model }),
1679
- });
1680
- const data = await res.json();
1681
- if (!data.results || data.results.length === 0) {
1682
- showToast(data.message || '没有可用的 API Key', true);
1683
- return;
1684
- }
1685
- markKeyErrors(data);
1686
- showTestResultModal(data);
1687
- } catch (err) {
1688
- showToast('测试请求失败: ' + err.message, true);
1689
- } finally {
1690
- btn.disabled = false;
1691
- btn.textContent = '测试连接';
1692
- }
1693
- }
1694
-
1695
- async function autoTestForSave() {
1696
- const providerId = document.getElementById('provider-id').value;
1697
- if (!providerId) return true;
1698
- const protocol = document.getElementById('target-protocol').value;
1699
- const apiKeys = collectApiKeys();
1700
- const model = document.getElementById('target-model').value.trim() || '';
1701
- clearKeyErrors();
1702
- try {
1703
- const res = await fetch(`/api/providers/${providerId}/test`, {
1704
- method: 'POST',
1705
- headers: { 'Content-Type': 'application/json' },
1706
- body: JSON.stringify({ protocol, apiKeys, model }),
1707
- });
1708
- const data = await res.json();
1709
- if (!data.results || data.results.length === 0) return true;
1710
- if (data.failed === 0) {
1711
- showToast(`${data.total} 条 API Key 测试通过`);
1712
- return true;
1713
- }
1714
- markKeyErrors(data);
1715
- return await showConfirm(
1716
- `${data.passed} 条通过,${data.failed} 条失败。<br><br>是否仍然保存?`,
1717
- '仍然保存'
1718
- );
1719
- } catch (err) {
1720
- return true;
1721
- }
1722
- }
1723
-
1724
- async function handleSubmit(e) {
1725
- e.preventDefault();
1726
-
1727
- const providerId = document.getElementById('provider-id').value;
1728
- if (!providerId) {
1729
- showToast('请选择供应商', true);
1730
- return;
1731
- }
1732
-
1733
- // 保存前自动测试:如果有 API Key 被修改,先测试连接
1734
- const hasModifiedKeys = !!document.querySelector('#api-keys-list .api-key-entry[data-masked="false"], #api-keys-list .api-key-entry[data-new="true"]');
1735
- if (hasModifiedKeys) {
1736
- const saveBtn = document.querySelector('.modal-footer .btn-primary');
1737
- saveBtn.disabled = true;
1738
- saveBtn.textContent = '测试中...';
1739
- try {
1740
- const canProceed = await autoTestForSave();
1741
- if (!canProceed) return;
1742
- } finally {
1743
- saveBtn.disabled = false;
1744
- saveBtn.textContent = '保存';
1745
- }
1746
- }
1747
-
1748
- const port = parseInt(document.getElementById('proxy-port').value);
1749
-
1750
- const conflict = proxies.find(p => p.id !== editingId && p.port === port);
1751
- if (conflict) {
1752
- showToast(`端口 ${port} 已被代理「${conflict.name}」占用`, true);
1753
- return;
1754
- }
1755
-
1756
- const apiKeys = collectApiKeys();
1757
- const protocol = document.getElementById('target-protocol').value;
1758
- const defaultModel = document.getElementById('target-model').value.trim() || '';
1759
-
1760
- // 同步更新供应商配置
1761
- const providerUpdates = {};
1762
- providerUpdates.apiKeys = apiKeys;
1763
- if (protocol) providerUpdates.protocol = protocol;
1764
- const azureDeployment = document.getElementById('target-azure-deployment').value.trim();
1765
- const azureApiVersion = document.getElementById('target-azure-version').value.trim();
1766
- providerUpdates.azureDeployment = azureDeployment || '';
1767
- providerUpdates.azureApiVersion = azureApiVersion || '';
1768
- if (Object.keys(providerUpdates).length > 0) {
1769
- try {
1770
- const res = await fetch(`/api/providers/${providerId}`, {
1771
- method: 'PUT',
1772
- headers: { 'Content-Type': 'application/json' },
1773
- body: JSON.stringify(providerUpdates),
1774
- });
1775
- if (!res.ok) {
1776
- const err = await res.json();
1777
- showToast('供应商配置保存失败: ' + (err.error || '未知错误'), true);
1778
- }
1779
- await loadProviders();
1780
- } catch (err) {
1781
- showToast('供应商配置保存失败: ' + err.message, true);
1782
- }
1783
- }
1784
-
1785
- const payload = {
1786
- name: document.getElementById('proxy-name').value.trim(),
1787
- port,
1788
- requireAuth: document.getElementById('proxy-auth').value === 'true',
1789
- authToken: document.getElementById('proxy-auth-token').value.trim() || null,
1790
- providerId,
1791
- defaultModel,
1792
- providerWeight: Math.max(1, parseInt(document.getElementById('provider-weight').value, 10) || 1),
1793
- routingStrategy: document.getElementById('routing-strategy').value || 'primary_fallback',
1794
- providerPool: providerPoolItems,
1795
- };
1796
-
1797
- try {
1798
- const url = editingId ? `/api/proxies/${editingId}` : '/api/proxies';
1799
- const method = editingId ? 'PUT' : 'POST';
1800
- const res = await fetch(url, {
1801
- method,
1802
- headers: { 'Content-Type': 'application/json' },
1803
- body: JSON.stringify(payload),
1804
- });
1805
-
1806
- const result = await res.json();
1807
-
1808
- if (!res.ok) {
1809
- showToast(result.error || '操作失败', true);
1810
- await loadProxies();
1811
- return;
1812
- }
1813
-
1814
- closeModal();
1815
- await loadProxies();
1816
- } catch (err) {
1817
- showToast('网络错误: ' + err.message, true);
1818
- await loadProxies();
1819
- }
1820
- }
1821
-
1822
- // ==================== 代理操作 ====================
1823
-
1824
- async function startProxy(id) {
1825
- try {
1826
- await fetch(`/api/proxies/${id}/start`, { method: 'POST' });
1827
- await loadProxies();
1828
- } catch (err) {
1829
- showToast('启动失败: ' + err.message, true);
1830
- }
1831
- }
1832
-
1833
- async function stopProxy(id) {
1834
- try {
1835
- await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
1836
- await loadProxies();
1837
- } catch (err) {
1838
- showToast('停止失败: ' + err.message, true);
1839
- }
1840
- }
1841
-
1842
- async function deleteProxy(id) {
1843
- const p = proxies.find(x => x.id === id);
1844
- const ok = await showConfirm(`确定要删除代理配置 <strong>${escapeHtml(p?.name || '')}</strong> 吗?`);
1845
- if (!ok) return;
1846
- try {
1847
- await fetch(`/api/proxies/${id}`, { method: 'DELETE' });
1848
- await loadProxies();
1849
- } catch (err) {
1850
- showToast('删除失败: ' + err.message, true);
1851
- }
1852
- }
1853
-
1854
- async function editProxy(id) {
1855
- try {
1856
- const res = await fetch(`/api/proxies/${id}`);
1857
- if (!res.ok) throw new Error('加载失败');
1858
- const full = await res.json();
1859
- const idx = proxies.findIndex(p => p.id === id);
1860
- if (idx !== -1) proxies[idx] = { ...proxies[idx], ...full };
1861
- openModal(id);
1862
- } catch (err) {
1863
- showToast('加载代理配置失败: ' + err.message, true);
1864
- }
1865
- }
1866
-
1867
- // ==================== 工具函数 ====================
1868
-
1869
- function escapeHtml(text) {
1870
- if (!text) return '';
1871
- const div = document.createElement('div');
1872
- div.textContent = text;
1873
- return div.innerHTML;
1874
- }
1875
-
1876
- init();
1
+ // ================================================
2
+ // Protocol Proxy — App Logic
3
+ // ================================================
4
+
5
+ // ---------- State ----------
6
+ let proxies = [];
7
+ let providers = [];
8
+ let keyHealth = {};
9
+ let requestLogs = [];
10
+ let ws = null;
11
+ let statsRange = 'daily';
12
+ let statsProxyId = '';
13
+ let importData = null;
14
+ let editingProxyId = null;
15
+ let editingProviderId = null;
16
+ let currentPage = 'dashboard';
17
+ let providerPoolItems = [];
18
+ let providerModelTags = [];
19
+ let providerKeys = [];
20
+
21
+ // ---------- Theme ----------
22
+ const THEMES = [
23
+ { id: 'dark', icon: '\u263E', label: '\u6df1\u8272' },
24
+ { id: 'light', icon: '\u2600', label: '\u6d45\u8272' },
25
+ { id: 'midnight', icon: '\u2726', label: '\u5348\u591c\u7d2b' },
26
+ { id: 'forest', icon: '\u2638', label: '\u68ee\u6797\u7eff' },
27
+ { id: 'sunset', icon: '\u2605', label: '\u65e5\u843d\u6a59' },
28
+ { id: 'ocean', icon: '\u265B', label: '\u6d77\u6d0b\u9752' },
29
+ { id: 'sakura', icon: '\u273F', label: '\u6a31\u82b1\u7c89' },
30
+ ];
31
+
32
+ function applyTheme(themeId) {
33
+ const t = THEMES.find(t => t.id === themeId) || THEMES[0];
34
+ document.documentElement.setAttribute('data-theme', t.id);
35
+ const icon = document.getElementById('theme-icon');
36
+ const label = document.getElementById('theme-label');
37
+ const select = document.getElementById('settings-theme');
38
+ if (icon) icon.textContent = t.icon;
39
+ if (label) label.textContent = t.label;
40
+ if (select) select.value = t.id;
41
+ localStorage.setItem('theme', t.id);
42
+ }
43
+
44
+ function cycleTheme() {
45
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
46
+ const idx = THEMES.findIndex(t => t.id === current);
47
+ const next = THEMES[(idx + 1) % THEMES.length];
48
+ applyTheme(next.id);
49
+ fetch('/api/settings', {
50
+ method: 'PUT',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ theme: next.id }),
53
+ }).catch(() => {});
54
+ }
55
+
56
+ (async () => {
57
+ try {
58
+ const res = await fetch('/api/settings');
59
+ const settings = await res.json();
60
+ applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
61
+ } catch {
62
+ applyTheme(localStorage.getItem('theme') || 'dark');
63
+ }
64
+ })();
65
+
66
+ // ---------- Navigation ----------
67
+ function navigateTo(page) {
68
+ currentPage = page;
69
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
70
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
71
+ const target = document.getElementById('page-' + page);
72
+ if (target) target.classList.add('active');
73
+ const nav = document.querySelector('.nav-item[data-page="' + page + '"]');
74
+ if (nav) nav.classList.add('active');
75
+
76
+ const titles = {
77
+ dashboard: '\u603b\u89c8',
78
+ proxies: '\u4ee3\u7406\u7ba1\u7406',
79
+ providers: '\u4f9b\u5e94\u5546\u7ba1\u7406',
80
+ stats: '\u7528\u91cf\u7edf\u8ba1',
81
+ 'request-logs': '\u8bf7\u6c42\u65e5\u5fd7',
82
+ 'system-logs': '\u7cfb\u7edf\u65e5\u5fd7',
83
+ settings: '\u8bbe\u7f6e',
84
+ };
85
+ document.getElementById('page-title').textContent = titles[page] || page;
86
+
87
+ // Refresh data for specific pages
88
+ if (page === 'dashboard') refreshDashboard();
89
+ if (page === 'proxies') renderProxies();
90
+ if (page === 'providers') renderProviders();
91
+ if (page === 'stats') loadStats();
92
+ if (page === 'system-logs') loadLogs();
93
+ if (page === 'request-logs') renderRequestLogs();
94
+ }
95
+
96
+ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
97
+ item.addEventListener('click', (e) => {
98
+ e.preventDefault();
99
+ navigateTo(item.dataset.page);
100
+ });
101
+ });
102
+
103
+ // ---------- Data Loading ----------
104
+ async function loadProxies() {
105
+ try {
106
+ const res = await fetch('/api/proxies');
107
+ proxies = await res.json();
108
+ document.getElementById('nav-proxy-count').textContent = proxies.length;
109
+ if (currentPage === 'proxies') renderProxies();
110
+ if (currentPage === 'dashboard') renderDashProxies();
111
+ updateDashStats();
112
+ populateProxyFilterOptions();
113
+ } catch (err) {
114
+ console.error('loadProxies error:', err);
115
+ }
116
+ }
117
+
118
+ async function loadProviders() {
119
+ try {
120
+ const res = await fetch('/api/providers');
121
+ providers = await res.json();
122
+ document.getElementById('nav-provider-count').textContent = providers.length;
123
+ if (currentPage === 'providers') renderProviders();
124
+ if (currentPage === 'dashboard') renderDashProviderHealth();
125
+ updateDashStats();
126
+ populateProxyProviderSelect();
127
+ } catch (err) {
128
+ console.error('loadProviders error:', err);
129
+ }
130
+ }
131
+
132
+ async function loadKeyHealth() {
133
+ try {
134
+ const res = await fetch('/api/key-health');
135
+ keyHealth = await res.json();
136
+ renderDashProviderHealth();
137
+ updateTopbarHealth();
138
+ } catch (err) {
139
+ console.error('loadKeyHealth error:', err);
140
+ }
141
+ }
142
+
143
+ async function loadStats() {
144
+ try {
145
+ const params = new URLSearchParams({ range: statsRange });
146
+ if (statsProxyId) params.set('proxyId', statsProxyId);
147
+ const start = document.getElementById('stats-start')?.value;
148
+ const end = document.getElementById('stats-end')?.value;
149
+ if (start) params.set('startDate', start);
150
+ if (end) params.set('endDate', end);
151
+ const res = await fetch('/api/stats?' + params);
152
+ const data = await res.json();
153
+ renderStats(data);
154
+ updateDashStats(data);
155
+ } catch (err) {
156
+ console.error('loadStats error:', err);
157
+ }
158
+ }
159
+
160
+ async function loadLogs() {
161
+ try {
162
+ const lines = document.getElementById('log-lines')?.value || 200;
163
+ const res = await fetch('/api/logs?lines=' + lines);
164
+ const data = await res.json();
165
+ renderLogs(data.lines || []);
166
+ } catch (err) {
167
+ console.error('loadLogs error:', err);
168
+ }
169
+ }
170
+
171
+ function refreshDashboard() {
172
+ renderDashProxies();
173
+ renderDashProviderHealth();
174
+ renderDashRecentRequests();
175
+ }
176
+
177
+ // ---------- Dashboard Rendering ----------
178
+ function updateDashStats(statsData) {
179
+ const running = proxies.filter(p => p.running).length;
180
+ document.getElementById('dash-running').textContent = running;
181
+ document.getElementById('dash-total').textContent = proxies.length;
182
+
183
+ if (statsData) {
184
+ document.getElementById('dash-tokens').textContent = formatTokens(statsData.summary?.total || 0);
185
+ document.getElementById('dash-tokens-sub').textContent = (statsData.summary?.hasEstimated ? '\u542b\u4f30\u7b97 ' : '') + '\u4eca\u65e5';
186
+ document.getElementById('dash-requests').textContent = (statsData.summary?.requests || 0).toLocaleString();
187
+ document.getElementById('dash-requests-sub').textContent = statsData.byProvider?.length + ' \u4e2a\u4f9b\u5e94\u5546';
188
+ }
189
+
190
+ const healthStatuses = Object.values(keyHealth);
191
+ const unhealthy = healthStatuses.filter(h => h.status === 'unhealthy').length;
192
+ const partial = healthStatuses.filter(h => h.status === 'partial').length;
193
+ if (unhealthy > 0) {
194
+ document.getElementById('dash-health').textContent = unhealthy + '\u4e2a\u5f02\u5e38';
195
+ document.getElementById('dash-health').style.color = 'var(--error)';
196
+ document.getElementById('dash-health-sub').textContent = partial > 0 ? partial + ' \u4e2a\u90e8\u5206\u5f02\u5e38' : '\u9700\u8981\u5173\u6ce8';
197
+ } else if (partial > 0) {
198
+ document.getElementById('dash-health').textContent = partial + '\u4e2a\u8b66\u544a';
199
+ document.getElementById('dash-health').style.color = 'var(--warning)';
200
+ document.getElementById('dash-health-sub').textContent = '\u90e8\u5206 Key \u5f02\u5e38';
201
+ } else {
202
+ document.getElementById('dash-health').textContent = '\u6b63\u5e38';
203
+ document.getElementById('dash-health').style.color = 'var(--success)';
204
+ document.getElementById('dash-health-sub').textContent = '\u5168\u90e8\u4f9b\u5e94\u5546\u5065\u5eb7';
205
+ }
206
+ }
207
+
208
+ function renderDashProxies() {
209
+ const container = document.getElementById('dash-proxy-list');
210
+ if (!container) return;
211
+ if (proxies.length === 0) {
212
+ container.innerHTML = '<div class="empty-sm">\u6682\u65e0\u4ee3\u7406\u914d\u7f6e</div>';
213
+ return;
214
+ }
215
+ container.innerHTML = proxies.slice(0, 6).map(p => {
216
+ const provider = providers.find(pr => pr.id === p.providerId);
217
+ return `
218
+ <div class="proxy-mini-item" onclick="navigateTo('proxies')">
219
+ <div class="proxy-mini-status ${p.running ? 'running' : 'stopped'}"></div>
220
+ <div class="proxy-mini-info">
221
+ <div class="proxy-mini-name">${escapeHtml(p.name)}</div>
222
+ <div class="proxy-mini-meta">${escapeHtml(provider?.name || p.providerId)}</div>
223
+ </div>
224
+ <div class="proxy-mini-port">:${p.port}</div>
225
+ </div>
226
+ `;
227
+ }).join('');
228
+ }
229
+
230
+ function renderDashProviderHealth() {
231
+ const container = document.getElementById('dash-provider-health');
232
+ if (!container) return;
233
+ if (providers.length === 0) {
234
+ container.innerHTML = '<div class="empty-sm">\u6682\u65e0\u4f9b\u5e94\u5546</div>';
235
+ return;
236
+ }
237
+ container.innerHTML = providers.map(p => {
238
+ const h = keyHealth[p.id];
239
+ let statusClass = 'unknown';
240
+ let statusText = '\u672a\u68c0\u6d4b';
241
+ if (h) {
242
+ if (h.status === 'healthy') { statusClass = 'healthy'; statusText = '\u6b63\u5e38'; }
243
+ else if (h.status === 'partial') { statusClass = 'partial'; statusText = '\u90e8\u5206\u5f02\u5e38'; }
244
+ else if (h.status === 'unhealthy') { statusClass = 'unhealthy'; statusText = '\u5f02\u5e38'; }
245
+ }
246
+ return `
247
+ <div class="provider-health-item">
248
+ <div class="provider-health-name">${escapeHtml(p.name)}</div>
249
+ <div class="provider-health-status ${statusClass}">${statusText}</div>
250
+ </div>
251
+ `;
252
+ }).join('');
253
+ }
254
+
255
+ function renderDashRecentRequests() {
256
+ const tbody = document.getElementById('dash-recent-requests');
257
+ if (!tbody) return;
258
+ const recent = requestLogs.slice(0, 8);
259
+ if (recent.length === 0) {
260
+ tbody.innerHTML = '<tr><td colspan="6" class="empty-cell">\u6682\u65e0\u8bf7\u6c42\u8bb0\u5f55</td></tr>';
261
+ return;
262
+ }
263
+ tbody.innerHTML = recent.map(r => renderRequestLogRow(r, true)).join('');
264
+ }
265
+
266
+ function updateTopbarHealth() {
267
+ const container = document.getElementById('topbar-health');
268
+ if (!container) return;
269
+ const statuses = Object.values(keyHealth);
270
+ const unhealthy = statuses.filter(s => s.status === 'unhealthy').length;
271
+ const partial = statuses.filter(s => s.status === 'partial').length;
272
+ if (unhealthy > 0) {
273
+ container.innerHTML = `<span class="health-dot error"></span>${unhealthy} \u4e2a\u4f9b\u5e94\u5546\u5f02\u5e38`;
274
+ } else if (partial > 0) {
275
+ container.innerHTML = `<span class="health-dot warn"></span>${partial} \u4e2a\u4f9b\u5e94\u5546\u8b66\u544a`;
276
+ } else if (statuses.length > 0) {
277
+ container.innerHTML = `<span class="health-dot ok"></span>\u5168\u90e8\u6b63\u5e38`;
278
+ } else {
279
+ container.innerHTML = '';
280
+ }
281
+ }
282
+
283
+ // ---------- Proxy Page ----------
284
+ function renderProxies() {
285
+ const grid = document.getElementById('proxy-grid');
286
+ if (!grid) return;
287
+ const search = (document.getElementById('proxy-search')?.value || '').toLowerCase();
288
+ const filtered = proxies.filter(p =>
289
+ !search ||
290
+ p.name.toLowerCase().includes(search) ||
291
+ String(p.port).includes(search) ||
292
+ (p.providerName || '').toLowerCase().includes(search)
293
+ );
294
+
295
+ if (filtered.length === 0 && proxies.length === 0) {
296
+ grid.innerHTML = `
297
+ <div class="empty-state">
298
+ <div class="empty-icon"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg></div>
299
+ <p>\u8fd8\u6ca1\u6709\u914d\u7f6e\u4ee3\u7406</p>
300
+ <button class="btn btn-primary" onclick="openProxyModal()">\u521b\u5efa\u7b2c\u4e00\u4e2a\u4ee3\u7406</button>
301
+ </div>`;
302
+ return;
303
+ }
304
+ if (filtered.length === 0) {
305
+ grid.innerHTML = '<div class="empty-state"><p>\u6ca1\u6709\u5339\u914d\u7684\u4ee3\u7406</p></div>';
306
+ return;
307
+ }
308
+
309
+ grid.innerHTML = filtered.map(p => {
310
+ const provider = providers.find(pr => pr.id === p.providerId);
311
+ return `
312
+ <div class="proxy-card ${p.running ? 'running' : 'stopped'}">
313
+ <div class="proxy-card-header">
314
+ <div class="proxy-card-title">${escapeHtml(p.name)}</div>
315
+ <span class="proxy-card-badge ${p.running ? 'running' : 'stopped'}">${p.running ? '\u8fd0\u884c\u4e2d' : '\u5df2\u505c\u6b62'}</span>
316
+ </div>
317
+ <div class="proxy-card-meta">
318
+ <div class="proxy-card-meta-item">
319
+ <div class="proxy-card-meta-label">\u7aef\u53e3</div>
320
+ <div class="proxy-card-meta-value">:${p.port}</div>
321
+ </div>
322
+ <div class="proxy-card-meta-item">
323
+ <div class="proxy-card-meta-label">\u4f9b\u5e94\u5546</div>
324
+ <div class="proxy-card-meta-value">${escapeHtml(provider?.name || p.providerId)}</div>
325
+ </div>
326
+ <div class="proxy-card-meta-item">
327
+ <div class="proxy-card-meta-label">\u534f\u8bae</div>
328
+ <div class="proxy-card-meta-value">${escapeHtml(p.protocol || provider?.protocol || 'openai')}</div>
329
+ </div>
330
+ <div class="proxy-card-meta-item">
331
+ <div class="proxy-card-meta-label">\u8def\u7531</div>
332
+ <div class="proxy-card-meta-value">${formatRouting(p.routingStrategy)}</div>
333
+ </div>
334
+ </div>
335
+ ${p.defaultModel ? `<div style="margin-bottom:8px;font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">\u9ed8\u8ba4\u6a21\u578b: ${escapeHtml(p.defaultModel)}</div>` : ''}
336
+ ${p.providerPool && p.providerPool.length > 0 ? `<div class="proxy-pool-preview">\u5907\u9009: ${p.providerPool.map(item => { const fp = providers.find(x => x.id === item.providerId); return escapeHtml(fp?.name || item.providerId); }).join(' / ')}</div>` : ''}
337
+ <div class="proxy-card-actions">
338
+ ${p.running
339
+ ? `<button class="btn btn-sm" onclick="stopProxy('${p.id}')">\u505c\u6b62</button>`
340
+ : `<button class="btn btn-sm btn-primary" onclick="startProxy('${p.id}')">\u542f\u52a8</button>`
341
+ }
342
+ <button class="btn btn-sm" onclick="editProxy('${p.id}')">\u7f16\u8f91</button>
343
+ <button class="btn btn-sm" onclick="copyProxyUrl('${p.id}')">\u590d\u5236\u5730\u5740</button>
344
+ <button class="btn btn-sm" style="color:var(--error)" onclick="deleteProxy('${p.id}')">\u5220\u9664</button>
345
+ </div>
346
+ </div>
347
+ `;
348
+ }).join('');
349
+ }
350
+
351
+ function filterProxies() {
352
+ renderProxies();
353
+ }
354
+
355
+ function formatRouting(s) {
356
+ const map = {
357
+ primary_fallback: '\u4e3b\u5907',
358
+ round_robin: '\u8f6e\u8be2',
359
+ weighted: '\u52a0\u6743',
360
+ fastest: '\u6700\u5feb',
361
+ };
362
+ return map[s] || s;
363
+ }
364
+
365
+ async function startProxy(id) {
366
+ try {
367
+ await fetch(`/api/proxies/${id}/start`, { method: 'POST' });
368
+ await loadProxies();
369
+ showToast('\u4ee3\u7406\u5df2\u542f\u52a8');
370
+ } catch (err) {
371
+ showToast('\u542f\u52a8\u5931\u8d25: ' + err.message, true);
372
+ }
373
+ }
374
+
375
+ async function stopProxy(id) {
376
+ try {
377
+ await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
378
+ await loadProxies();
379
+ showToast('\u4ee3\u7406\u5df2\u505c\u6b62');
380
+ } catch (err) {
381
+ showToast('\u505c\u6b62\u5931\u8d25: ' + err.message, true);
382
+ }
383
+ }
384
+
385
+ async function startAllProxies() {
386
+ try {
387
+ const res = await fetch('/api/proxies/start-all', { method: 'POST' });
388
+ const data = await res.json();
389
+ await loadProxies();
390
+ const success = data.results?.filter(r => r.success).length || 0;
391
+ showToast(`\u542f\u52a8\u5b8c\u6210: ${success} / ${data.results?.length || 0}`);
392
+ } catch (err) {
393
+ showToast('\u6279\u91cf\u542f\u52a8\u5931\u8d25: ' + err.message, true);
394
+ }
395
+ }
396
+
397
+ async function stopAllProxies() {
398
+ try {
399
+ await fetch('/api/proxies/stop-all', { method: 'POST' });
400
+ await loadProxies();
401
+ showToast('\u5168\u90e8\u4ee3\u7406\u5df2\u505c\u6b62');
402
+ } catch (err) {
403
+ showToast('\u6279\u91cf\u505c\u6b62\u5931\u8d25: ' + err.message, true);
404
+ }
405
+ }
406
+
407
+ async function deleteProxy(id) {
408
+ const p = proxies.find(x => x.id === id);
409
+ if (!p) return;
410
+ const ok = await showConfirm(`\u786e\u5b9a\u5220\u9664\u4ee3\u7406 <strong>${escapeHtml(p.name)}</strong>\uff1f`);
411
+ if (!ok) return;
412
+ try {
413
+ await fetch(`/api/proxies/${id}`, { method: 'DELETE' });
414
+ await loadProxies();
415
+ showToast('\u4ee3\u7406\u5df2\u5220\u9664');
416
+ } catch (err) {
417
+ showToast('\u5220\u9664\u5931\u8d25: ' + err.message, true);
418
+ }
419
+ }
420
+
421
+ function copyProxyUrl(id) {
422
+ const p = proxies.find(x => x.id === id);
423
+ if (!p) return;
424
+ const url = `http://localhost:${p.port}`;
425
+ navigator.clipboard.writeText(url).then(() => showToast('\u5730\u5740\u5df2\u590d\u5236'));
426
+ }
427
+
428
+ // ---------- Proxy Modal ----------
429
+ function openProxyModal() {
430
+ editingProxyId = null;
431
+ document.getElementById('proxy-modal-title').textContent = '\u65b0\u5efa\u4ee3\u7406';
432
+ document.getElementById('proxy-id').value = '';
433
+ document.getElementById('proxy-name').value = '';
434
+ document.getElementById('proxy-port').value = '';
435
+ document.getElementById('proxy-auth').value = 'false';
436
+ document.getElementById('proxy-auth-token').value = '';
437
+ document.getElementById('proxy-auth-token-group').style.display = 'none';
438
+ document.getElementById('proxy-provider').value = '';
439
+ document.getElementById('proxy-model').innerHTML = '<option value="">\u4f7f\u7528\u8bf7\u6c42\u6a21\u578b</option>';
440
+ document.getElementById('proxy-routing').value = 'primary_fallback';
441
+ document.getElementById('proxy-weight').value = '1';
442
+ providerPoolItems = [];
443
+ renderPoolEditor();
444
+ populateProxyProviderSelect();
445
+ showModal('proxy-modal');
446
+ }
447
+
448
+ function editProxy(id) {
449
+ const p = proxies.find(x => x.id === id);
450
+ if (!p) return;
451
+ editingProxyId = id;
452
+ document.getElementById('proxy-modal-title').textContent = '\u7f16\u8f91\u4ee3\u7406';
453
+ document.getElementById('proxy-id').value = p.id;
454
+ document.getElementById('proxy-name').value = p.name;
455
+ document.getElementById('proxy-port').value = p.port;
456
+ document.getElementById('proxy-auth').value = p.requireAuth ? 'true' : 'false';
457
+ document.getElementById('proxy-auth-token').value = p.authToken || '';
458
+ document.getElementById('proxy-auth-token-group').style.display = p.requireAuth ? '' : 'none';
459
+ document.getElementById('proxy-provider').value = p.providerId || '';
460
+ document.getElementById('proxy-routing').value = p.routingStrategy || 'primary_fallback';
461
+ document.getElementById('proxy-weight').value = p.providerWeight || 1;
462
+ populateProxyProviderSelect();
463
+ updateProxyModelSelect(p.providerId, p.defaultModel);
464
+ providerPoolItems = Array.isArray(p.providerPool) ? p.providerPool.map(x => ({...x})) : [];
465
+ renderPoolEditor();
466
+ showModal('proxy-modal');
467
+ }
468
+
469
+ function closeProxyModal() {
470
+ hideModal('proxy-modal');
471
+ }
472
+
473
+
474
+ function populateProxyProviderSelect() {
475
+ const select = document.getElementById('proxy-provider');
476
+ const current = select.value;
477
+ select.innerHTML = '<option value="">\u9009\u62e9\u4f9b\u5e94\u5546...</option>' +
478
+ providers.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('');
479
+ select.value = current || '';
480
+ }
481
+
482
+ function updateProxyModelSelect(providerId, selectedModel) {
483
+ const select = document.getElementById('proxy-model');
484
+ const provider = providers.find(p => p.id === providerId);
485
+ const models = provider?.models || [];
486
+ select.innerHTML = '<option value="">\u4f7f\u7528\u8bf7\u6c42\u6a21\u578b</option>' +
487
+ models.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
488
+ if (selectedModel) select.value = selectedModel;
489
+ }
490
+
491
+ function renderPoolEditor() {
492
+ const container = document.getElementById('proxy-pool-editor');
493
+ if (providerPoolItems.length === 0) {
494
+ container.innerHTML = '<div class="pool-empty">\u6682\u65e0\u5907\u9009\u4f9b\u5e94\u5546</div>';
495
+ return;
496
+ }
497
+ container.innerHTML = providerPoolItems.map((item, i) => {
498
+ const provider = providers.find(p => p.id === item.providerId);
499
+ const models = provider?.models || [];
500
+ return `
501
+ <div class="pool-item">
502
+ <select class="pool-item-select" onchange="updatePoolProvider(${i}, this.value)">
503
+ <option value="">\u9009\u62e9\u4f9b\u5e94\u5546...</option>
504
+ ${providers.map(p => `<option value="${escapeHtml(p.id)}" ${p.id === item.providerId ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('')}
505
+ </select>
506
+ <select class="pool-item-select" onchange="updatePoolModel(${i}, this.value)">
507
+ <option value="">\u9ed8\u8ba4\u6a21\u578b</option>
508
+ ${models.map(m => `<option value="${escapeHtml(m)}" ${m === item.model ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
509
+ </select>
510
+ <input type="number" min="1" value="${item.weight || 1}" onchange="updatePoolWeight(${i}, this.value)" style="width:50px" title="\u6743\u91cd">
511
+ <button type="button" class="pool-item-remove" onclick="removePoolItem(${i})">&times;</button>
512
+ </div>
513
+ `;
514
+ }).join('');
515
+ }
516
+
517
+ function addPoolItem() {
518
+ if (providers.length === 0) {
519
+ showToast('\u8bf7\u5148\u521b\u5efa\u4f9b\u5e94\u5546', true);
520
+ return;
521
+ }
522
+ providerPoolItems.push({ providerId: '', model: '', weight: 1 });
523
+ renderPoolEditor();
524
+ }
525
+
526
+ function updatePoolProvider(index, providerId) {
527
+ providerPoolItems[index].providerId = providerId;
528
+ providerPoolItems[index].model = '';
529
+ renderPoolEditor();
530
+ }
531
+
532
+ function updatePoolModel(index, model) {
533
+ providerPoolItems[index].model = model;
534
+ }
535
+
536
+ function removePoolItem(index) {
537
+ providerPoolItems.splice(index, 1);
538
+ renderPoolEditor();
539
+ }
540
+
541
+ function updatePoolWeight(index, value) {
542
+ providerPoolItems[index].weight = Math.max(1, parseInt(value) || 1);
543
+ }
544
+
545
+ async function handleProxySubmit(e) {
546
+ e.preventDefault();
547
+ const payload = {
548
+ name: document.getElementById('proxy-name').value.trim(),
549
+ port: parseInt(document.getElementById('proxy-port').value),
550
+ requireAuth: document.getElementById('proxy-auth').value === 'true',
551
+ authToken: document.getElementById('proxy-auth-token').value.trim() || null,
552
+ providerId: document.getElementById('proxy-provider').value,
553
+ defaultModel: document.getElementById('proxy-model').value,
554
+ routingStrategy: document.getElementById('proxy-routing').value,
555
+ providerWeight: parseInt(document.getElementById('proxy-weight').value) || 1,
556
+ providerPool: providerPoolItems,
557
+ };
558
+ if (!payload.name || !payload.port || !payload.providerId) {
559
+ showToast('\u8bf7\u586b\u5199\u5b8c\u6574\u4fe1\u606f', true);
560
+ return;
561
+ }
562
+ try {
563
+ if (editingProxyId) {
564
+ await fetch(`/api/proxies/${editingProxyId}`, {
565
+ method: 'PUT',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify(payload),
568
+ });
569
+ showToast('\u4ee3\u7406\u5df2\u66f4\u65b0');
570
+ } else {
571
+ await fetch('/api/proxies', {
572
+ method: 'POST',
573
+ headers: { 'Content-Type': 'application/json' },
574
+ body: JSON.stringify(payload),
575
+ });
576
+ showToast('\u4ee3\u7406\u5df2\u521b\u5efa');
577
+ }
578
+ closeProxyModal();
579
+ await loadProxies();
580
+ } catch (err) {
581
+ showToast('\u4fdd\u5b58\u5931\u8d25: ' + err.message, true);
582
+ }
583
+ }
584
+
585
+ async function testConnectionFromModal() {
586
+ const providerId = document.getElementById('proxy-provider').value;
587
+ if (!providerId) {
588
+ showToast('\u8bf7\u5148\u9009\u62e9\u4f9b\u5e94\u5546', true);
589
+ return;
590
+ }
591
+ await testProviderConnection(providerId);
592
+ }
593
+
594
+ // ---------- Provider Page ----------
595
+ function renderProviders() {
596
+ const grid = document.getElementById('provider-grid');
597
+ if (!grid) return;
598
+ const search = (document.getElementById('provider-search')?.value || '').toLowerCase();
599
+ const filtered = providers.filter(p =>
600
+ !search ||
601
+ p.name.toLowerCase().includes(search) ||
602
+ p.url.toLowerCase().includes(search)
603
+ );
604
+
605
+ if (filtered.length === 0 && providers.length === 0) {
606
+ grid.innerHTML = `
607
+ <div class="empty-state">
608
+ <div class="empty-icon"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg></div>
609
+ <p>\u8fd8\u6ca1\u6709\u914d\u7f6e\u4f9b\u5e94\u5546</p>
610
+ <button class="btn btn-primary" onclick="openProviderModal()">\u521b\u5efa\u7b2c\u4e00\u4e2a\u4f9b\u5e94\u5546</button>
611
+ </div>`;
612
+ return;
613
+ }
614
+ if (filtered.length === 0) {
615
+ grid.innerHTML = '<div class="empty-state"><p>\u6ca1\u6709\u5339\u914d\u7684\u4f9b\u5e94\u5546</p></div>';
616
+ return;
617
+ }
618
+
619
+ grid.innerHTML = filtered.map(p => {
620
+ const h = keyHealth[p.id];
621
+ let statusDot = '';
622
+ if (h) {
623
+ const color = h.status === 'healthy' ? 'var(--success)' : h.status === 'partial' ? 'var(--warning)' : 'var(--error)';
624
+ statusDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-left:8px;"></span>`;
625
+ }
626
+ return `
627
+ <div class="provider-card">
628
+ <div class="provider-card-header">
629
+ <div class="provider-card-name">${escapeHtml(p.name)}${statusDot}</div>
630
+ <span class="provider-card-protocol">${escapeHtml(p.protocol)}</span>
631
+ </div>
632
+ <div class="provider-card-url">${escapeHtml(p.url)}</div>
633
+ <div class="provider-card-models">
634
+ ${(p.models || []).slice(0, 6).map(m => `<span class="provider-card-model">${escapeHtml(m)}</span>`).join('')}
635
+ ${(p.models || []).length > 6 ? `<span class="provider-card-model">+${p.models.length - 6}</span>` : ''}
636
+ </div>
637
+ <div class="provider-card-keys">${(p.apiKeys || []).length} \u4e2a API Key</div>
638
+ <div class="provider-card-actions">
639
+ <button class="btn btn-sm" onclick="editProvider('${p.id}')">\u7f16\u8f91</button>
640
+ <button class="btn btn-sm" onclick="testProviderConnection('${p.id}')">\u6d4b\u8bd5</button>
641
+ <button class="btn btn-sm" style="color:var(--error)" onclick="deleteProvider('${p.id}')">\u5220\u9664</button>
642
+ </div>
643
+ </div>
644
+ `;
645
+ }).join('');
646
+ }
647
+
648
+ function filterProviders() {
649
+ renderProviders();
650
+ }
651
+
652
+ function openProviderModal() {
653
+ editingProviderId = null;
654
+ document.getElementById('provider-modal-title').textContent = '\u65b0\u5efa\u4f9b\u5e94\u5546';
655
+ document.getElementById('provider-edit-id').value = '';
656
+ document.getElementById('provider-name').value = '';
657
+ document.getElementById('provider-protocol').value = 'openai';
658
+ document.getElementById('provider-url').value = '';
659
+ providerModelTags = [];
660
+ renderModelTags();
661
+ providerKeys = [{ key: '', alias: '', index: 0, enabled: true }];
662
+ renderProviderKeys();
663
+ document.getElementById('provider-azure-row').style.display = 'none';
664
+ document.getElementById('provider-azure-deployment').value = '';
665
+ document.getElementById('provider-azure-version').value = '';
666
+ showModal('provider-modal');
667
+ }
668
+
669
+ function editProvider(id) {
670
+ const p = providers.find(x => x.id === id);
671
+ if (!p) return;
672
+ editingProviderId = id;
673
+ document.getElementById('provider-modal-title').textContent = '\u7f16\u8f91\u4f9b\u5e94\u5546';
674
+ document.getElementById('provider-edit-id').value = p.id;
675
+ document.getElementById('provider-name').value = p.name;
676
+ document.getElementById('provider-protocol').value = p.protocol || 'openai';
677
+ document.getElementById('provider-url').value = p.url;
678
+ providerModelTags = [...(p.models || [])];
679
+ renderModelTags();
680
+ providerKeys = (p.apiKeys || []).map((k, i) => ({ ...k, index: typeof k.index === 'number' ? k.index : i }));
681
+ if (providerKeys.length === 0) providerKeys = [{ key: '', alias: '', index: 0 }];
682
+ renderProviderKeys();
683
+ const isAzure = p.protocol === 'openai' && p.azureDeployment;
684
+ document.getElementById('provider-azure-row').style.display = isAzure ? 'grid' : 'none';
685
+ document.getElementById('provider-azure-deployment').value = p.azureDeployment || '';
686
+ document.getElementById('provider-azure-version').value = p.azureApiVersion || '';
687
+ showModal('provider-modal');
688
+ }
689
+
690
+ function closeProviderModal() {
691
+ hideModal('provider-modal');
692
+ }
693
+
694
+ function renderModelTags() {
695
+ const list = document.getElementById('provider-models-list');
696
+ list.innerHTML = providerModelTags.map((tag, i) => `
697
+ <span class="tag">${escapeHtml(tag)}<button type="button" class="tag-remove" onclick="removeModelTag(${i})">&times;</button></span>
698
+ `).join('');
699
+ }
700
+
701
+ function handleModelTagInput(e) {
702
+ if (e.key === 'Enter') {
703
+ e.preventDefault();
704
+ const val = e.target.value.trim();
705
+ if (val && !providerModelTags.includes(val)) {
706
+ providerModelTags.push(val);
707
+ renderModelTags();
708
+ e.target.value = '';
709
+ }
710
+ }
711
+ }
712
+
713
+ function removeModelTag(index) {
714
+ providerModelTags.splice(index, 1);
715
+ renderModelTags();
716
+ }
717
+
718
+ function renderProviderKeys() {
719
+ const container = document.getElementById('provider-keys-list');
720
+ container.innerHTML = providerKeys.map((k, i) => `
721
+ <div class="key-row" data-index="${k.index ?? i}" data-masked="${k.masked ? 'true' : 'false'}">
722
+ <input type="text" placeholder="\u522b\u540d" value="${escapeHtml(k.alias || '')}" oninput="providerKeys[${i}].alias = this.value">
723
+ ${k.masked
724
+ ? `<span class="api-key-masked" data-idx="${i}">sk-••••••••</span>`
725
+ : `<input type="password" placeholder="sk-..." value="${escapeHtml(k.key || '')}" oninput="providerKeys[${i}].key = this.value">`
726
+ }
727
+ <label class="toggle-switch" title="${k.enabled !== false ? '\u5df2\u542f\u7528' : '\u5df2\u7981\u7528'}">
728
+ <input type="checkbox" ${k.enabled !== false ? 'checked' : ''} onchange="providerKeys[${i}].enabled = this.checked">
729
+ <span class="toggle-slider"></span>
730
+ </label>
731
+ <button type="button" class="btn btn-sm" onclick="removeProviderKey(${i})">\u79fb\u9664</button>
732
+ </div>
733
+ `).join('');
734
+ attachMaskedKeyClicks();
735
+ }
736
+
737
+ function addProviderKey() {
738
+ providerKeys.push({ key: '', alias: '', index: providerKeys.length, enabled: true });
739
+ renderProviderKeys();
740
+ }
741
+
742
+ function removeProviderKey(index) {
743
+ providerKeys.splice(index, 1);
744
+ if (providerKeys.length === 0) providerKeys.push({ key: '', alias: '', index: 0 });
745
+ renderProviderKeys();
746
+ }
747
+
748
+ function attachMaskedKeyClicks() {
749
+ document.querySelectorAll('.api-key-masked').forEach(span => {
750
+ if (span._attached) return;
751
+ span._attached = true;
752
+ span.title = '点击修改';
753
+ span.addEventListener('click', () => {
754
+ const i = parseInt(span.dataset.idx, 10);
755
+ const row = span.closest('.key-row');
756
+ const group = span.parentElement;
757
+
758
+ // Replace span with input
759
+ const input = document.createElement('input');
760
+ input.type = 'password';
761
+ input.className = 'key-input';
762
+ input.placeholder = '输入新的 API Key...';
763
+ group.replaceChild(input, span);
764
+ input.focus();
765
+
766
+ input.addEventListener('blur', () => {
767
+ const val = input.value.trim();
768
+ if (!val) {
769
+ // Restore masked span
770
+ const restored = document.createElement('span');
771
+ restored.className = 'api-key-masked';
772
+ restored.dataset.idx = i;
773
+ restored.textContent = 'sk-••••••••';
774
+ group.replaceChild(restored, input);
775
+ attachMaskedKeyClicks();
776
+ return;
777
+ }
778
+ // Mark as edited — replace with password input
779
+ const newInput = document.createElement('input');
780
+ newInput.type = 'password';
781
+ newInput.className = 'key-input';
782
+ newInput.value = val;
783
+ group.replaceChild(newInput, input);
784
+ newInput.addEventListener('input', () => { providerKeys[i].key = newInput.value; });
785
+ providerKeys[i].key = val;
786
+ providerKeys[i].masked = false;
787
+ if (row) row.dataset.masked = 'false';
788
+ });
789
+ });
790
+ });
791
+ }
792
+
793
+ function collectProviderKeys() {
794
+ return providerKeys.map(k => {
795
+ const alias = (k.alias || '').trim();
796
+ const enabled = k.enabled !== false;
797
+ if (k.masked) {
798
+ // Existing key: if key was edited, send key; otherwise preserve
799
+ if (k.key && !k.masked) return { key: k.key.trim(), alias, enabled };
800
+ return { alias, masked: true, index: k.index, enabled };
801
+ }
802
+ const key = (k.key || '').trim();
803
+ if (!key) return null;
804
+ return { key, alias, enabled };
805
+ }).filter(Boolean);
806
+ }
807
+
808
+ async function handleProviderSubmit(e) {
809
+ e.preventDefault();
810
+ const payload = {
811
+ name: document.getElementById('provider-name').value.trim(),
812
+ protocol: document.getElementById('provider-protocol').value,
813
+ url: document.getElementById('provider-url').value.trim(),
814
+ models: providerModelTags,
815
+ apiKeys: collectProviderKeys(),
816
+ };
817
+ if (payload.protocol === 'openai') {
818
+ const azDep = document.getElementById('provider-azure-deployment').value.trim();
819
+ if (azDep) {
820
+ payload.azureDeployment = azDep;
821
+ payload.azureApiVersion = document.getElementById('provider-azure-version').value.trim() || '2024-02-01';
822
+ }
823
+ }
824
+ if (!payload.name || !payload.url) {
825
+ showToast('\u8bf7\u586b\u5199\u5b8c\u6574\u4fe1\u606f', true);
826
+ return;
827
+ }
828
+ try {
829
+ if (editingProviderId) {
830
+ await fetch(`/api/providers/${editingProviderId}`, {
831
+ method: 'PUT',
832
+ headers: { 'Content-Type': 'application/json' },
833
+ body: JSON.stringify(payload),
834
+ });
835
+ showToast('\u4f9b\u5e94\u5546\u5df2\u66f4\u65b0');
836
+ } else {
837
+ await fetch('/api/providers', {
838
+ method: 'POST',
839
+ headers: { 'Content-Type': 'application/json' },
840
+ body: JSON.stringify(payload),
841
+ });
842
+ showToast('\u4f9b\u5e94\u5546\u5df2\u521b\u5efa');
843
+ }
844
+ closeProviderModal();
845
+ await loadProviders();
846
+ } catch (err) {
847
+ showToast('\u4fdd\u5b58\u5931\u8d25: ' + err.message, true);
848
+ }
849
+ }
850
+
851
+ async function deleteProvider(id) {
852
+ const p = providers.find(x => x.id === id);
853
+ if (!p) return;
854
+ const ok = await showConfirm(`\u786e\u5b9a\u5220\u9664\u4f9b\u5e94\u5546 <strong>${escapeHtml(p.name)}</strong>\uff1f`);
855
+ if (!ok) return;
856
+ try {
857
+ const res = await fetch(`/api/providers/${id}`, { method: 'DELETE' });
858
+ if (!res.ok) {
859
+ const data = await res.json();
860
+ showToast(data.error || '\u5220\u9664\u5931\u8d25', true);
861
+ return;
862
+ }
863
+ await loadProviders();
864
+ showToast('\u4f9b\u5e94\u5546\u5df2\u5220\u9664');
865
+ } catch (err) {
866
+ showToast('\u5220\u9664\u5931\u8d25: ' + err.message, true);
867
+ }
868
+ }
869
+
870
+ async function fetchModelsForProvider(el) {
871
+ const url = document.getElementById('provider-url').value.trim();
872
+ const protocol = document.getElementById('provider-protocol').value;
873
+ if (!url) {
874
+ showToast('\u8bf7\u5148\u586b\u5199 API \u5730\u5740', true);
875
+ return;
876
+ }
877
+ const btn = el || document.activeElement;
878
+ if (btn) { btn.disabled = true; btn.textContent = '\u83b7\u53d6\u4e2d...'; }
879
+ try {
880
+ const key = providerKeys.find(k => k.key.trim())?.key.trim() || '';
881
+ const payload = { url, protocol, apiKey: key };
882
+ const azureDep = document.getElementById('provider-azure-deployment')?.value?.trim();
883
+ if (azureDep) {
884
+ payload.azureDeployment = azureDep;
885
+ payload.azureApiVersion = document.getElementById('provider-azure-version')?.value?.trim() || '2024-02-01';
886
+ }
887
+ const res = await fetch('/api/providers/available-models', {
888
+ method: 'POST',
889
+ headers: { 'Content-Type': 'application/json' },
890
+ body: JSON.stringify(payload),
891
+ });
892
+ if (!res.ok) throw new Error('HTTP ' + res.status);
893
+ const data = await res.json();
894
+ const models = data.models || [];
895
+ const existing = new Set(providerModelTags);
896
+ const newModels = models.filter(m => !existing.has(m));
897
+ providerModelTags.push(...newModels);
898
+ renderModelTags();
899
+ showToast(`\u5df2\u5bfc\u5165 ${newModels.length} \u4e2a\u6a21\u578b`);
900
+ } catch (err) {
901
+ showToast('\u83b7\u53d6\u5931\u8d25: ' + err.message, true);
902
+ } finally {
903
+ if (btn) { btn.disabled = false; btn.textContent = '\u81ea\u52a8\u83b7\u53d6\u6a21\u578b\u5217\u8868'; }
904
+ }
905
+ }
906
+
907
+ async function testProviderConnection(id, opts) {
908
+ const p = providers.find(x => x.id === id);
909
+ if (!p) return;
910
+ try {
911
+ const res = await fetch(`/api/providers/${id}/test`, {
912
+ method: 'POST',
913
+ headers: { 'Content-Type': 'application/json' },
914
+ body: JSON.stringify({
915
+ apiKeys: opts?.apiKeys || p.apiKeys,
916
+ models: p.models,
917
+ protocol: opts?.protocol || p.protocol,
918
+ }),
919
+ });
920
+ const data = await res.json();
921
+ showTestResult(data.ok, `${data.passed || 0}/${data.results?.length || 0} 个 Key 正常`, data.results || []);
922
+ } catch (err) {
923
+ showTestResult(false, '测试失败: ' + err.message, []);
924
+ }
925
+ }
926
+
927
+ async function testProviderFromModal() {
928
+ const url = document.getElementById('provider-url').value.trim();
929
+ const protocol = document.getElementById('provider-protocol').value;
930
+ if (!url) {
931
+ showToast('\u8bf7\u5148\u586b\u5199 API \u5730\u5740', true);
932
+ return;
933
+ }
934
+ const isNew = !editingProviderId;
935
+ if (isNew) {
936
+ // \u65b0\u5efa\u4f9b\u5e94\u5546\uff1a\u53ea\u6d4b\u65b0\u8f93\u5165\u7684 key\uff0c\u7528 /api/test-connection
937
+ const apiKeys = collectProviderKeys()
938
+ .filter(k => !k.masked && k.key)
939
+ .map(k => ({ key: k.key.trim(), alias: k.alias || '' }));
940
+ await testProviderConnectionDirect({
941
+ url,
942
+ protocol,
943
+ apiKeys,
944
+ models: providerModelTags,
945
+ azureDeployment: document.getElementById('provider-azure-deployment').value.trim(),
946
+ azureApiVersion: document.getElementById('provider-azure-version').value.trim(),
947
+ });
948
+ } else {
949
+ // \u7f16\u8f91\u5df2\u6709\u4f9b\u5e94\u5546\uff1a\u6536\u96c6\u8868\u5355\u4e2d\u7684\u6240\u6709 key\uff08\u542b\u65b0\u589e\u672a\u4fdd\u5b58\u7684\uff09\uff0c\u53d1\u7ed9\u540e\u7aef\u6d4b\u8bd5
950
+ const apiKeys = collectProviderKeys();
951
+ const protocol = document.getElementById('provider-protocol').value;
952
+ await testProviderConnection(editingProviderId, { apiKeys, protocol });
953
+ }
954
+ }
955
+
956
+ async function testProviderConnectionDirect(provider) {
957
+ const keys = (provider.apiKeys || []).filter(k => k.key);
958
+ if (keys.length === 0) {
959
+ showTestResult(false, '\u6ca1\u6709\u53ef\u7528\u7684 API Key', []);
960
+ return;
961
+ }
962
+ try {
963
+ const res = await fetch('/api/test-connection', {
964
+ method: 'POST',
965
+ headers: { 'Content-Type': 'application/json' },
966
+ body: JSON.stringify({
967
+ url: provider.url,
968
+ protocol: provider.protocol,
969
+ apiKeys: keys,
970
+ models: provider.models,
971
+ azureDeployment: provider.azureDeployment,
972
+ azureApiVersion: provider.azureApiVersion,
973
+ }),
974
+ });
975
+ const data = await res.json();
976
+ const passed = data.passed || 0;
977
+ const total = data.results?.length || 0;
978
+ showTestResult(data.ok, `${passed}/${total} \u4e2a Key \u6b63\u5e38`, data.results || []);
979
+ } catch (err) {
980
+ showTestResult(false, '\u6d4b\u8bd5\u5931\u8d25: ' + err.message, []);
981
+ }
982
+ }
983
+
984
+ function showTestResult(ok, summary, results) {
985
+ document.getElementById('test-summary').innerHTML = ok
986
+ ? `<span style="color:var(--success)">\u2713 ${escapeHtml(summary)}</span>`
987
+ : `<span style="color:var(--error)">\u2717 ${escapeHtml(summary)}</span>`;
988
+ document.getElementById('test-details').innerHTML = results.map(r => `
989
+ <div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border-subtle);font-size:13px">
990
+ <span style="color:${r.ok ? 'var(--success)' : 'var(--error)'};font-weight:600">${r.ok ? '\u2713' : '\u2717'}</span>
991
+ <span style="flex:1">${escapeHtml(r.alias || '\u672a\u547d\u540d')}</span>
992
+ ${r.latency ? `<span style="color:var(--text-muted);font-family:var(--font-mono);font-size:12px">${r.latency}ms</span>` : ''}
993
+ ${r.message ? `<span style="color:var(--error);font-size:12px">${escapeHtml(r.message)}</span>` : ''}
994
+ </div>
995
+ `).join('');
996
+ showModal('test-modal');
997
+ }
998
+
999
+ function closeTestModal() {
1000
+ hideModal('test-modal');
1001
+ }
1002
+ // ---------- Stats Page ----------
1003
+ function changeStatsRange(range) {
1004
+ statsRange = range;
1005
+ loadStats();
1006
+ }
1007
+
1008
+ function changeStatsProxy(proxyId) {
1009
+ statsProxyId = proxyId;
1010
+ loadStats();
1011
+ }
1012
+
1013
+ function renderStats(data) {
1014
+ const summary = data.summary || {};
1015
+ document.getElementById('stats-total').textContent = formatTokens(summary.total || 0);
1016
+ document.getElementById('stats-prompt').textContent = formatTokens(summary.prompt || 0);
1017
+ document.getElementById('stats-completion').textContent = formatTokens(summary.completion || 0);
1018
+ document.getElementById('stats-requests').textContent = (summary.requests || 0).toLocaleString();
1019
+ document.getElementById('stats-estimated-badge').style.display = summary.hasEstimated ? 'inline' : 'none';
1020
+
1021
+ const tbody = document.getElementById('stats-table-body');
1022
+ const byModel = data.byModel || [];
1023
+ if (byModel.length === 0) {
1024
+ tbody.innerHTML = '<tr><td colspan="6" class="empty-cell">\u6682\u65e0\u6570\u636e</td></tr>';
1025
+ return;
1026
+ }
1027
+ tbody.innerHTML = byModel.map(item => {
1028
+ const prefix = item.hasEstimated ? '~' : '';
1029
+ return `
1030
+ <tr>
1031
+ <td>${escapeHtml(item.provider)}</td>
1032
+ <td><code>${escapeHtml(item.model)}</code></td>
1033
+ <td class="num">${item.requests.toLocaleString()}</td>
1034
+ <td class="num">${prefix}${formatTokens(item.prompt)}</td>
1035
+ <td class="num">${prefix}${formatTokens(item.completion)}</td>
1036
+ <td class="num">${prefix}${formatTokens(item.total)}</td>
1037
+ </tr>
1038
+ `;
1039
+ }).join('');
1040
+ }
1041
+
1042
+ function populateProxyFilterOptions() {
1043
+ const selects = ['stats-proxy-filter', 'rq-proxy-filter'];
1044
+ selects.forEach(id => {
1045
+ const select = document.getElementById(id);
1046
+ if (!select) return;
1047
+ const current = select.value;
1048
+ select.innerHTML = '<option value="">\u5168\u90e8\u4ee3\u7406</option>' +
1049
+ proxies.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('');
1050
+ select.value = current || '';
1051
+ });
1052
+ }
1053
+
1054
+ async function exportStatsCSV() {
1055
+ try {
1056
+ const params = new URLSearchParams({ range: statsRange });
1057
+ if (statsProxyId) params.set('proxyId', statsProxyId);
1058
+ const start = document.getElementById('stats-start')?.value;
1059
+ const end = document.getElementById('stats-end')?.value;
1060
+ if (start) params.set('startDate', start);
1061
+ if (end) params.set('endDate', end);
1062
+ const res = await fetch('/api/stats?' + params);
1063
+ const data = await res.json();
1064
+ if (!data.byModel || data.byModel.length === 0) {
1065
+ showToast('\u5f53\u524d\u7b5b\u9009\u6761\u4ef6\u4e0b\u65e0\u6570\u636e\u53ef\u5bfc\u51fa', true);
1066
+ return;
1067
+ }
1068
+ const rows = [['\u4f9b\u5e94\u5546', '\u6a21\u578b', '\u8bf7\u6c42\u6570', '\u8f93\u5165Token', '\u8f93\u51faToken', '\u5408\u8ba1Token', '\u542b\u4f30\u7b97']];
1069
+ for (const item of data.byModel) {
1070
+ rows.push([item.provider, item.model, item.requests, item.prompt, item.completion, item.total, item.hasEstimated ? '\u662f' : '\u5426']);
1071
+ }
1072
+ const s = data.summary;
1073
+ rows.push(['\u5408\u8ba1', '', s.requests, s.prompt, s.completion, s.total, s.hasEstimated ? '\u662f' : '\u5426']);
1074
+ const csv = '\ufeff' + rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
1075
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
1076
+ const url = URL.createObjectURL(blob);
1077
+ const a = document.createElement('a');
1078
+ a.href = url;
1079
+ a.download = `stats-${statsRange}-${new Date().toISOString().slice(0,10)}.csv`;
1080
+ a.click();
1081
+ URL.revokeObjectURL(url);
1082
+ } catch (err) {
1083
+ showToast('\u5bfc\u51fa\u5931\u8d25: ' + err.message, true);
1084
+ }
1085
+ }
1086
+
1087
+ // ---------- Request Logs Page ----------
1088
+ function connectRequestLogWS() {
1089
+ const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1090
+ const wsUrl = `${wsProto}//${location.host}`;
1091
+ // Fallback: if no WS endpoint, use polling via a custom endpoint or skip
1092
+ // Since the backend may not have WS, we'll use a polling approach with the existing API
1093
+ // Actually, the original code used WebSocket. Let's try to connect.
1094
+ try {
1095
+ ws = new WebSocket(wsUrl);
1096
+ ws.onopen = () => {
1097
+ const btn = document.getElementById('rq-ws-btn');
1098
+ if (btn) { btn.textContent = '\u5b9e\u65f6'; btn.style.color = 'var(--success)'; }
1099
+ };
1100
+ ws.onmessage = (e) => {
1101
+ try {
1102
+ const msg = JSON.parse(e.data);
1103
+ const entry = msg.id ? msg : (msg.data || msg);
1104
+ if (entry && entry.id) {
1105
+ requestLogs.unshift(entry);
1106
+ if (requestLogs.length > 500) requestLogs.pop();
1107
+ if (currentPage === 'request-logs') {
1108
+ // Prepend single row instead of full re-render to preserve open detail rows
1109
+ const tbody = document.getElementById('rq-tbody');
1110
+ if (tbody) {
1111
+ const proxyFilter = document.getElementById('rq-proxy-filter')?.value || '';
1112
+ const statusFilter = document.getElementById('rq-status-filter')?.value || '';
1113
+ const modelFilter = (document.getElementById('rq-model-filter')?.value || '').toLowerCase();
1114
+ let passes = true;
1115
+ if (proxyFilter && entry.proxyId !== proxyFilter) passes = false;
1116
+ if (statusFilter === 'success' && entry.status !== 'success') passes = false;
1117
+ if (statusFilter === 'failure' && entry.status === 'success') passes = false;
1118
+ if (statusFilter === '429' && entry.status !== '429') passes = false;
1119
+ if (modelFilter && !(entry.model || '').toLowerCase().includes(modelFilter)) passes = false;
1120
+ if (passes) {
1121
+ const tmp = document.createElement('tbody');
1122
+ tmp.innerHTML = renderRequestLogRow(entry);
1123
+ const newRow = tmp.firstElementChild;
1124
+ if (newRow) tbody.insertBefore(newRow, tbody.firstChild);
1125
+ }
1126
+ }
1127
+ }
1128
+ if (currentPage === 'dashboard') renderDashRecentRequests();
1129
+ }
1130
+ } catch {}
1131
+ };
1132
+ ws.onclose = () => {
1133
+ const btn = document.getElementById('rq-ws-btn');
1134
+ if (btn) { btn.textContent = '\u5df2\u65ad\u5f00'; btn.style.color = 'var(--error)'; }
1135
+ setTimeout(connectRequestLogWS, 3000);
1136
+ };
1137
+ ws.onerror = () => {
1138
+ const btn = document.getElementById('rq-ws-btn');
1139
+ if (btn) { btn.textContent = '\u8fde\u63a5\u5931\u8d25'; btn.style.color = 'var(--error)'; }
1140
+ };
1141
+ } catch {
1142
+ // WS not available, show as disabled
1143
+ const btn = document.getElementById('rq-ws-btn');
1144
+ if (btn) { btn.textContent = '\u672a\u8fde\u63a5'; btn.style.color = 'var(--text-faint)'; }
1145
+ }
1146
+ }
1147
+
1148
+ function renderRequestLogs() {
1149
+ const tbody = document.getElementById('rq-tbody');
1150
+ if (!tbody) return;
1151
+
1152
+ const proxyFilter = document.getElementById('rq-proxy-filter')?.value || '';
1153
+ const statusFilter = document.getElementById('rq-status-filter')?.value || '';
1154
+ const modelFilter = (document.getElementById('rq-model-filter')?.value || '').toLowerCase();
1155
+
1156
+ const filtered = requestLogs.filter(r => {
1157
+ if (proxyFilter && r.proxyId !== proxyFilter) return false;
1158
+ if (statusFilter) {
1159
+ if (statusFilter === 'success' && r.status !== 'success') return false;
1160
+ if (statusFilter === 'failure' && r.status === 'success') return false;
1161
+ if (statusFilter === '429' && r.status !== '429') return false;
1162
+ }
1163
+ if (modelFilter && !(r.model || '').toLowerCase().includes(modelFilter)) return false;
1164
+ return true;
1165
+ });
1166
+
1167
+ // Update summary
1168
+ const total = filtered.length;
1169
+ const success = filtered.filter(r => r.status === 'success').length;
1170
+ const avgLatency = total > 0 ? Math.round(filtered.reduce((a, r) => a + (r.latency || 0), 0) / total) : 0;
1171
+ const summary = document.getElementById('rq-summary');
1172
+ if (summary) {
1173
+ summary.innerHTML = `
1174
+ <div class="request-log-summary-item">\u603b\u8ba1: <strong>${total}</strong></div>
1175
+ <div class="request-log-summary-item">\u6210\u529f: <strong style="color:var(--success)">${success}</strong></div>
1176
+ <div class="request-log-summary-item">\u5931\u8d25: <strong style="color:var(--error)">${total - success}</strong></div>
1177
+ <div class="request-log-summary-item">\u5e73\u5747\u5ef6\u8fdf: <strong>${avgLatency}ms</strong></div>
1178
+ `;
1179
+ }
1180
+
1181
+ if (filtered.length === 0) {
1182
+ tbody.innerHTML = '<tr><td colspan="9" class="empty-cell">\u6682\u65e0\u8bf7\u6c42\u8bb0\u5f55</td></tr>';
1183
+ return;
1184
+ }
1185
+
1186
+ tbody.innerHTML = filtered.slice(0, 200).map(r => renderRequestLogRow(r)).join('');
1187
+ }
1188
+
1189
+ function toggleRequestLogDetail(row, entry) {
1190
+ const next = row.nextElementSibling;
1191
+ if (next && next.classList.contains('request-log-detail-row')) {
1192
+ next.remove();
1193
+ return;
1194
+ }
1195
+ const detailRow = document.createElement('tr');
1196
+ detailRow.className = 'request-log-detail-row';
1197
+ const time = entry.timestamp ? new Date(entry.timestamp).toLocaleString('zh-CN', { hour12: false }) : '-';
1198
+ detailRow.innerHTML = `<td colspan="9"><div class="request-log-detail">
1199
+ <div class="request-log-detail-grid">
1200
+ <span class="detail-label">Request ID</span><span><code>${escapeHtml(entry.id || '-')}</code></span>
1201
+ <span class="detail-label">时间</span><span>${time}</span>
1202
+ <span class="detail-label">客户端 IP</span><span>${escapeHtml(entry.clientIP || '-')}</span>
1203
+ <span class="detail-label">入站协议</span><span>${escapeHtml(entry.inboundProtocol || '-')}</span>
1204
+ <span class="detail-label">目标协议</span><span>${escapeHtml(entry.targetProtocol || '-')}</span>
1205
+ <span class="detail-label">代理</span><span>${escapeHtml(entry.proxyName || '-')} <code style="font-size:11px;color:var(--text-dim)">${escapeHtml(entry.proxyId || '')}</code></span>
1206
+ <span class="detail-label">供应商</span><span>${escapeHtml(entry.providerName || '-')}</span>
1207
+ <span class="detail-label">模型</span><span><code>${escapeHtml(entry.model || '-')}</code></span>
1208
+ <span class="detail-label">Key</span><span>${escapeHtml(entry.keyAlias || '-')}</span>
1209
+ <span class="detail-label">流式</span><span>${entry.stream ? '是' : '否'}</span>
1210
+ <span class="detail-label">上游状态码</span><span>${entry.upstreamStatusCode || '-'}</span>
1211
+ <span class="detail-label">延迟</span><span>${entry.latencyMs != null ? entry.latencyMs + 'ms' : '-'}</span>
1212
+ <span class="detail-label">Token</span><span>${entry.promptTokens || 0} 输入 + ${entry.completionTokens || 0} 输出 = ${entry.totalTokens || 0} ${entry.isEstimated ? '(估算)' : ''}</span>
1213
+ ${entry.errorMessage ? `<span class="detail-label">错误信息</span><span class="request-log-detail-error">${escapeHtml(entry.errorMessage)}</span>` : ''}
1214
+ </div>
1215
+ </div></td>`;
1216
+ row.after(detailRow);
1217
+ }
1218
+
1219
+ function renderRequestLogRow(r, compact) {
1220
+ const time = r.timestamp ? new Date(r.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-';
1221
+ const status = r.status === 'success'
1222
+ ? `<span style="color:var(--success);font-weight:600">\u2713</span>`
1223
+ : (r.status === '429'
1224
+ ? `<span style="color:var(--warning);font-weight:600">429</span>`
1225
+ : `<span style="color:var(--error);font-weight:600">\u2717</span>`);
1226
+ const latency = r.latencyMs ? `<span style="color:var(--text-muted)">${r.latencyMs}ms</span>` : '-';
1227
+ const tokens = r.totalTokens ? formatTokens(r.totalTokens) : '-';
1228
+ const keyLabel = r.keyAlias || (r.key ? `\u2026${r.key.slice(-4)}` : '-');
1229
+
1230
+ if (compact) {
1231
+ return `
1232
+ <tr>
1233
+ <td>${time}</td>
1234
+ <td>${escapeHtml(r.proxyName || r.proxyId || '-')}</td>
1235
+ <td><code>${escapeHtml(r.protocol || '-')}</code></td>
1236
+ <td><code>${escapeHtml(r.model || '-')}</code></td>
1237
+ <td>${status}</td>
1238
+ <td class="num">${latency}</td>
1239
+ </tr>
1240
+ `;
1241
+ }
1242
+
1243
+ return `
1244
+ <tr class="clickable" data-entry-id="${escapeHtml(r.id || '')}">
1245
+ <td>${time}</td>
1246
+ <td>${escapeHtml(r.proxyName || r.proxyId || '-')}</td>
1247
+ <td><code>${escapeHtml(r.protocol || '-')}</code></td>
1248
+ <td><code>${escapeHtml(r.model || '-')}</code></td>
1249
+ <td>${status}</td>
1250
+ <td class="num">${tokens}</td>
1251
+ <td class="num">${latency}</td>
1252
+ <td>${escapeHtml(r.providerName || r.provider || '-')}</td>
1253
+ <td><span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">${escapeHtml(keyLabel)}</span></td>
1254
+ </tr>
1255
+ `;
1256
+ }
1257
+
1258
+ function filterRequestLogs() {
1259
+ renderRequestLogs();
1260
+ }
1261
+
1262
+ function exportRequestLogs() {
1263
+ if (requestLogs.length === 0) {
1264
+ showToast('\u65e0\u6570\u636e\u53ef\u5bfc\u51fa', true);
1265
+ return;
1266
+ }
1267
+ const rows = [['\u65f6\u95f4', '\u4ee3\u7406', '\u534f\u8bae', '\u6a21\u578b', '\u72b6\u6001', 'Tokens', '\u5ef6\u8fdf', '\u4f9b\u5e94\u5546', 'Key']];
1268
+ for (const r of requestLogs) {
1269
+ rows.push([r.timestamp || '-', r.proxyName || r.proxyId || '-', r.inboundProtocol || '-', r.model || '-', r.status === 'success' ? '\u6210\u529f' : '\u5931\u8d25', r.totalTokens || 0, r.latencyMs || 0, r.providerName || '-', r.keyAlias || '-']);
1270
+ }
1271
+ const csv = '\ufeff' + rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
1272
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
1273
+ const url = URL.createObjectURL(blob);
1274
+ const a = document.createElement('a');
1275
+ a.href = url;
1276
+ a.download = `request-logs-${new Date().toISOString().slice(0,10)}.csv`;
1277
+ a.click();
1278
+ URL.revokeObjectURL(url);
1279
+ }
1280
+
1281
+ function clearRequestLogs() {
1282
+ requestLogs = [];
1283
+ renderRequestLogs();
1284
+ }
1285
+
1286
+ // ---------- System Logs ----------
1287
+ function renderLogs(lines) {
1288
+ const container = document.getElementById('log-content');
1289
+ if (!container) return;
1290
+ if (!lines || lines.length === 0) {
1291
+ container.innerHTML = '<div class="empty-sm">\u6682\u65e0\u65e5\u5fd7</div>';
1292
+ return;
1293
+ }
1294
+ container.innerHTML = lines.map(line => {
1295
+ const levelMatch = line.match(/\[(ERROR|WARN|INFO)\]/i);
1296
+ let levelClass = '';
1297
+ if (levelMatch) {
1298
+ const lvl = levelMatch[1].toUpperCase();
1299
+ if (lvl === 'ERROR') levelClass = 'log-level-error';
1300
+ else if (lvl === 'WARN') levelClass = 'log-level-warn';
1301
+ else levelClass = 'log-level-info';
1302
+ }
1303
+ const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})/);
1304
+ let display = escapeHtml(line);
1305
+ if (timeMatch) {
1306
+ display = `<span class="log-time">${escapeHtml(timeMatch[1])}</span>` + escapeHtml(line.slice(timeMatch[1].length));
1307
+ }
1308
+ return `<div class="log-line ${levelClass}">${display}</div>`;
1309
+ }).join('');
1310
+ container.scrollTop = container.scrollHeight;
1311
+ }
1312
+
1313
+ // ---------- Config Import/Export ----------
1314
+ async function exportConfig() {
1315
+ try {
1316
+ const res = await fetch('/api/config/export');
1317
+ const data = await res.json();
1318
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1319
+ const url = URL.createObjectURL(blob);
1320
+ const a = document.createElement('a');
1321
+ a.href = url;
1322
+ a.download = `config-backup-${new Date().toISOString().slice(0,10).replace(/-/g,'')}.json`;
1323
+ a.click();
1324
+ URL.revokeObjectURL(url);
1325
+ showToast('\u914d\u7f6e\u5df2\u5bfc\u51fa');
1326
+ } catch (err) {
1327
+ showToast('\u5bfc\u51fa\u5931\u8d25: ' + err.message, true);
1328
+ }
1329
+ }
1330
+
1331
+ function handleImportFile(e) {
1332
+ const file = e.target.files[0];
1333
+ if (!file) return;
1334
+ e.target.value = '';
1335
+ const reader = new FileReader();
1336
+ reader.onload = () => {
1337
+ try {
1338
+ const data = JSON.parse(reader.result);
1339
+ if (!Array.isArray(data.providers) || !Array.isArray(data.proxies)) {
1340
+ showToast('\u914d\u7f6e\u683c\u5f0f\u9519\u8bef', true);
1341
+ return;
1342
+ }
1343
+ importData = data;
1344
+ document.getElementById('import-providers-count').textContent = data.providers.length;
1345
+ document.getElementById('import-proxies-count').textContent = data.proxies.length;
1346
+ showModal('import-modal');
1347
+ } catch (err) {
1348
+ showToast('\u6587\u4ef6\u89e3\u6790\u5931\u8d25: ' + err.message, true);
1349
+ }
1350
+ };
1351
+ reader.readAsText(file);
1352
+ }
1353
+
1354
+ function closeImportModal() {
1355
+ hideModal('import-modal');
1356
+ importData = null;
1357
+ }
1358
+
1359
+ async function confirmImport() {
1360
+ if (!importData) return;
1361
+ const mode = document.querySelector('input[name="import-mode"]:checked')?.value || 'merge';
1362
+ if (mode === 'overwrite') {
1363
+ const ok = await showConfirm('\u786e\u8ba4<strong>\u8986\u76d6</strong>\u73b0\u6709\u914d\u7f6e\uff1f\u6b64\u64cd\u4f5c\u4e0d\u53ef\u64a4\u9500\u3002');
1364
+ if (!ok) return;
1365
+ }
1366
+ try {
1367
+ const res = await fetch('/api/config/import', {
1368
+ method: 'POST',
1369
+ headers: { 'Content-Type': 'application/json' },
1370
+ body: JSON.stringify({ config: importData, mode }),
1371
+ });
1372
+ const result = await res.json();
1373
+ if (!res.ok) {
1374
+ showToast(result.error || '\u5bfc\u5165\u5931\u8d25', true);
1375
+ return;
1376
+ }
1377
+ closeImportModal();
1378
+ await Promise.all([loadProxies(), loadProviders()]);
1379
+ const added = result.added;
1380
+ let msg = `\u5bfc\u5165\u6210\u529f`;
1381
+ if (added) msg += `\uff1a\u65b0\u589e ${added.providers} \u4f9b\u5e94\u5546\u3001${added.proxies} \u4ee3\u7406`;
1382
+ showToast(msg);
1383
+ const restart = await showConfirm(msg + `\u3002<br><br>\u662f\u5426\u7acb\u5373\u91cd\u542f\u6240\u6709\u4ee3\u7406\uff1f`);
1384
+ if (restart) {
1385
+ await restartAllProxies();
1386
+ }
1387
+ } catch (err) {
1388
+ showToast('\u5bfc\u5165\u5931\u8d25: ' + err.message, true);
1389
+ }
1390
+ }
1391
+
1392
+ async function restartAllProxies() {
1393
+ try {
1394
+ const statusRes = await fetch('/api/status');
1395
+ const status = await statusRes.json();
1396
+ const runningIds = (status.running || []).map(r => r.id);
1397
+ for (const id of runningIds) {
1398
+ await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
1399
+ }
1400
+ await loadProxies();
1401
+ for (const p of proxies) {
1402
+ await fetch(`/api/proxies/${p.id}/start`, { method: 'POST' });
1403
+ }
1404
+ await loadProxies();
1405
+ showToast('\u6240\u6709\u4ee3\u7406\u5df2\u91cd\u542f');
1406
+ } catch (err) {
1407
+ showToast('\u91cd\u542f\u5931\u8d25: ' + err.message, true);
1408
+ }
1409
+ }
1410
+
1411
+ // ---------- History Modal ----------
1412
+ async function openHistoryModal() {
1413
+ try {
1414
+ const res = await fetch('/api/config/history');
1415
+ const data = await res.json();
1416
+ const list = document.getElementById('history-list');
1417
+ const snapshots = data.snapshots || [];
1418
+ if (snapshots.length === 0) {
1419
+ list.innerHTML = '<div class="empty-sm">\u6682\u65e0\u5386\u53f2\u8bb0\u5f55</div>';
1420
+ } else {
1421
+ list.innerHTML = snapshots.map(s => `
1422
+ <div class="history-item">
1423
+ <div class="history-meta">
1424
+ <div class="history-name">${escapeHtml(s.file)}</div>
1425
+ <div class="history-reason">${escapeHtml(s.reason)} \u00b7 ${new Date(s.timestamp).toLocaleString('zh-CN')}</div>
1426
+ </div>
1427
+ <div class="history-actions">
1428
+ <button class="btn btn-sm history-rollback-btn" data-file="${escapeHtml(s.file)}">\u56de\u6eda</button>
1429
+ </div>
1430
+ </div>
1431
+ `).join('');
1432
+ list.querySelectorAll('.history-rollback-btn').forEach(btn => {
1433
+ btn.addEventListener('click', () => rollbackConfig(btn.dataset.file));
1434
+ });
1435
+ }
1436
+ showModal('history-modal');
1437
+ } catch (err) {
1438
+ showToast('\u52a0\u8f7d\u5386\u53f2\u5931\u8d25: ' + err.message, true);
1439
+ }
1440
+ }
1441
+
1442
+ function closeHistoryModal() {
1443
+ hideModal('history-modal');
1444
+ }
1445
+
1446
+ async function rollbackConfig(file) {
1447
+ const ok = await showConfirm(`\u786e\u5b9a\u56de\u6eda\u5230 <strong>${escapeHtml(file)}</strong>\uff1f`);
1448
+ if (!ok) return;
1449
+ try {
1450
+ await fetch('/api/config/rollback', {
1451
+ method: 'POST',
1452
+ headers: { 'Content-Type': 'application/json' },
1453
+ body: JSON.stringify({ file }),
1454
+ });
1455
+ closeHistoryModal();
1456
+ await Promise.all([loadProxies(), loadProviders()]);
1457
+ showToast('\u5df2\u56de\u6eda\u5230\u9009\u5b9a\u7248\u672c');
1458
+ } catch (err) {
1459
+ showToast('\u56de\u6eda\u5931\u8d25: ' + err.message, true);
1460
+ }
1461
+ }
1462
+
1463
+ // ---------- Confirm Modal ----------
1464
+ let confirmResolve = null;
1465
+
1466
+ function showConfirm(html, okText) {
1467
+ return new Promise(resolve => {
1468
+ confirmResolve = resolve;
1469
+ document.getElementById('confirm-text').innerHTML = html;
1470
+ const okBtn = document.getElementById('confirm-ok');
1471
+ okBtn.textContent = okText || '\u786e\u8ba4';
1472
+ okBtn.onclick = () => { hideModal('confirm-modal'); resolve(true); };
1473
+ document.getElementById('confirm-cancel').onclick = () => { hideModal('confirm-modal'); resolve(false); };
1474
+ showModal('confirm-modal');
1475
+ });
1476
+ }
1477
+
1478
+ // ---------- Modal Helpers ----------
1479
+ function showModal(id) {
1480
+ const overlay = document.getElementById(id);
1481
+ if (overlay) overlay.classList.add('active');
1482
+ }
1483
+
1484
+ function hideModal(id) {
1485
+ const overlay = document.getElementById(id);
1486
+ if (overlay) overlay.classList.remove('active');
1487
+ }
1488
+
1489
+ // Close modal on overlay click
1490
+ document.querySelectorAll('.modal-overlay').forEach(overlay => {
1491
+ overlay.addEventListener('click', (e) => {
1492
+ if (e.target === overlay) overlay.classList.remove('active');
1493
+ });
1494
+ });
1495
+
1496
+ // Close modal on Escape
1497
+ document.addEventListener('keydown', (e) => {
1498
+ if (e.key === 'Escape') {
1499
+ document.querySelectorAll('.modal-overlay.active').forEach(o => o.classList.remove('active'));
1500
+ }
1501
+ });
1502
+
1503
+ // ---------- Toast ----------
1504
+ let toastTimer = null;
1505
+ function showToast(message, isError) {
1506
+ const toast = document.getElementById('toast');
1507
+ toast.textContent = message;
1508
+ toast.className = 'toast' + (isError ? ' error' : '');
1509
+ toast.style.display = 'block';
1510
+ clearTimeout(toastTimer);
1511
+ toastTimer = setTimeout(() => { toast.style.display = 'none'; }, 2800);
1512
+ }
1513
+
1514
+ // ---------- Utilities ----------
1515
+ function escapeHtml(text) {
1516
+ if (text == null) return '';
1517
+ const div = document.createElement('div');
1518
+ div.textContent = String(text);
1519
+ return div.innerHTML;
1520
+ }
1521
+
1522
+ function formatTokens(n) {
1523
+ if (!n || n === 0) return '0';
1524
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1525
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1526
+ return n.toLocaleString();
1527
+ }
1528
+
1529
+
1530
+ // ---------- Initialization ----------
1531
+ async function init() {
1532
+ // Set default dates for stats
1533
+ const today = new Date().toISOString().slice(0, 10);
1534
+ const startInput = document.getElementById('stats-start');
1535
+ const endInput = document.getElementById('stats-end');
1536
+ if (startInput && !startInput.value) startInput.value = today;
1537
+ if (endInput && !endInput.value) endInput.value = today;
1538
+
1539
+ // Safe event bindings
1540
+ const proxyAuth = document.getElementById('proxy-auth');
1541
+ if (proxyAuth) proxyAuth.addEventListener('change', function() {
1542
+ document.getElementById('proxy-auth-token-group').style.display = this.value === 'true' ? '' : 'none';
1543
+ });
1544
+ const proxyProvider = document.getElementById('proxy-provider');
1545
+ if (proxyProvider) proxyProvider.addEventListener('change', function() {
1546
+ updateProxyModelSelect(this.value);
1547
+ });
1548
+ const providerProtocol = document.getElementById('provider-protocol');
1549
+ if (providerProtocol) providerProtocol.addEventListener('change', function() {
1550
+ document.getElementById('provider-azure-row').style.display = this.value === 'openai' ? 'grid' : 'none';
1551
+ });
1552
+
1553
+ await Promise.all([loadProxies(), loadProviders(), loadKeyHealth()]);
1554
+ loadStats();
1555
+ loadLogs();
1556
+ loadRequestLogHistory();
1557
+ connectRequestLogWS();
1558
+ refreshDashboard();
1559
+
1560
+ // Request log row click → toggle detail
1561
+ const rqTbody = document.getElementById('rq-tbody');
1562
+ if (rqTbody && !rqTbody._delegated) {
1563
+ rqTbody.addEventListener('click', (ev) => {
1564
+ const row = ev.target.closest('tr.clickable');
1565
+ if (!row || row.parentElement !== rqTbody) return;
1566
+ const entryId = row.dataset.entryId;
1567
+ const entry = requestLogs.find(r => r.id === entryId);
1568
+ if (entry) toggleRequestLogDetail(row, entry);
1569
+ });
1570
+ rqTbody._delegated = true;
1571
+ }
1572
+
1573
+ // Auto-refresh
1574
+ setInterval(loadStats, 30000);
1575
+ setInterval(loadKeyHealth, 5 * 60 * 1000);
1576
+ }
1577
+
1578
+ async function loadRequestLogHistory() {
1579
+ try {
1580
+ const res = await fetch('/api/request-logs?limit=200');
1581
+ const data = await res.json();
1582
+ requestLogs = data.entries || [];
1583
+ if (currentPage === 'request-logs') renderRequestLogs();
1584
+ if (currentPage === 'dashboard') renderDashRecentRequests();
1585
+ } catch (err) {
1586
+ console.error('loadRequestLogHistory error:', err);
1587
+ }
1588
+ }
1589
+
1590
+ init();