liuw15 commited on
Commit
0994949
·
1 Parent(s): bf096f1

优化代码结构、前端逻辑、认证提示、前端添加原生axios和系统提示词收集配置的配置

Browse files
config.json CHANGED
@@ -31,6 +31,7 @@
31
  "timeout": 300000,
32
  "retryTimes": 3,
33
  "skipProjectIdFetch": false,
34
- "useNativeAxios": false
 
35
  }
36
  }
 
31
  "timeout": 300000,
32
  "retryTimes": 3,
33
  "skipProjectIdFetch": false,
34
+ "useNativeAxios": false,
35
+ "useContextSystemPrompt": false
36
  }
37
  }
public/app.js DELETED
@@ -1,1362 +0,0 @@
1
- let authToken = localStorage.getItem('authToken');
2
- let oauthPort = null;
3
-
4
- // 字体大小设置
5
- function initFontSize() {
6
- const savedSize = localStorage.getItem('fontSize') || '18';
7
- document.documentElement.style.setProperty('--font-size-base', savedSize + 'px');
8
- updateFontSizeInputs(savedSize);
9
- }
10
-
11
- function changeFontSize(size) {
12
- // 限制范围
13
- size = Math.max(10, Math.min(24, parseInt(size) || 14));
14
- document.documentElement.style.setProperty('--font-size-base', size + 'px');
15
- localStorage.setItem('fontSize', size);
16
- updateFontSizeInputs(size);
17
- }
18
-
19
- function updateFontSizeInputs(size) {
20
- const rangeInput = document.getElementById('fontSizeRange');
21
- const numberInput = document.getElementById('fontSizeInput');
22
- if (rangeInput) rangeInput.value = size;
23
- if (numberInput) numberInput.value = size;
24
- }
25
-
26
- // 页面加载时初始化字体大小
27
- initFontSize();
28
-
29
- // 敏感信息隐藏功能 - 默认隐藏
30
- // localStorage 存储的是字符串 'true' 或 'false'
31
- // 如果没有存储过,默认为隐藏状态
32
- let sensitiveInfoHidden = localStorage.getItem('sensitiveInfoHidden') !== 'false';
33
-
34
- function initSensitiveInfo() {
35
- updateSensitiveInfoDisplay();
36
- updateSensitiveBtn();
37
- }
38
-
39
- function toggleSensitiveInfo() {
40
- sensitiveInfoHidden = !sensitiveInfoHidden;
41
- localStorage.setItem('sensitiveInfoHidden', sensitiveInfoHidden);
42
- updateSensitiveInfoDisplay();
43
- updateSensitiveBtn();
44
- }
45
-
46
- function updateSensitiveBtn() {
47
- const btn = document.getElementById('toggleSensitiveBtn');
48
- if (btn) {
49
- if (sensitiveInfoHidden) {
50
- btn.innerHTML = '🙈 隐藏';
51
- btn.title = '点击显示敏感信息';
52
- btn.classList.remove('btn-info');
53
- btn.classList.add('btn-secondary');
54
- } else {
55
- btn.innerHTML = '👁️ 显示';
56
- btn.title = '点击隐藏敏感信息';
57
- btn.classList.remove('btn-secondary');
58
- btn.classList.add('btn-info');
59
- }
60
- }
61
- }
62
-
63
- function updateSensitiveInfoDisplay() {
64
- document.querySelectorAll('.sensitive-info').forEach(el => {
65
- if (sensitiveInfoHidden) {
66
- el.dataset.original = el.textContent;
67
- el.textContent = '••••••';
68
- el.classList.add('blurred');
69
- } else if (el.dataset.original) {
70
- el.textContent = el.dataset.original;
71
- el.classList.remove('blurred');
72
- }
73
- });
74
- }
75
-
76
- // 页面加载时初始化敏感信息状态
77
- initSensitiveInfo();
78
- const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
79
- const SCOPES = [
80
- 'https://www.googleapis.com/auth/cloud-platform',
81
- 'https://www.googleapis.com/auth/userinfo.email',
82
- 'https://www.googleapis.com/auth/userinfo.profile',
83
- 'https://www.googleapis.com/auth/cclog',
84
- 'https://www.googleapis.com/auth/experimentsandconfigs'
85
- ].join(' ');
86
-
87
- // 封装fetch,自动处理401
88
- const authFetch = async (url, options = {}) => {
89
- const response = await fetch(url, options);
90
- if (response.status === 401) {
91
- silentLogout();
92
- showToast('登录已过期,请重新登录', 'warning');
93
- throw new Error('Unauthorized');
94
- }
95
- return response;
96
- };
97
-
98
- function showToast(message, type = 'info', title = '') {
99
- const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' };
100
- const titles = { success: '成功', error: '错误', warning: '警告', info: '提示' };
101
- const toast = document.createElement('div');
102
- toast.className = `toast ${type}`;
103
- toast.innerHTML = `
104
- <div class="toast-icon">${icons[type]}</div>
105
- <div class="toast-content">
106
- <div class="toast-title">${title || titles[type]}</div>
107
- <div class="toast-message">${message}</div>
108
- </div>
109
- `;
110
- document.body.appendChild(toast);
111
- setTimeout(() => {
112
- toast.style.animation = 'slideOut 0.3s ease';
113
- setTimeout(() => toast.remove(), 300);
114
- }, 3000);
115
- }
116
-
117
- function showConfirm(message, title = '确认操作') {
118
- return new Promise((resolve) => {
119
- const modal = document.createElement('div');
120
- modal.className = 'modal';
121
- modal.innerHTML = `
122
- <div class="modal-content">
123
- <div class="modal-title">${title}</div>
124
- <div class="modal-message">${message}</div>
125
- <div class="modal-actions">
126
- <button class="btn btn-secondary" onclick="this.closest('.modal').remove(); window.modalResolve(false)">取消</button>
127
- <button class="btn btn-danger" onclick="this.closest('.modal').remove(); window.modalResolve(true)">确定</button>
128
- </div>
129
- </div>
130
- `;
131
- document.body.appendChild(modal);
132
- modal.onclick = (e) => { if (e.target === modal) { modal.remove(); resolve(false); } };
133
- window.modalResolve = resolve;
134
- });
135
- }
136
-
137
- function showLoading(text = '处理中...') {
138
- const overlay = document.createElement('div');
139
- overlay.className = 'loading-overlay';
140
- overlay.id = 'loadingOverlay';
141
- overlay.innerHTML = `<div class="spinner"></div><div class="loading-text">${text}</div>`;
142
- document.body.appendChild(overlay);
143
- }
144
-
145
- function hideLoading() {
146
- const overlay = document.getElementById('loadingOverlay');
147
- if (overlay) overlay.remove();
148
- }
149
-
150
- if (authToken) {
151
- showMainContent();
152
- loadTokens();
153
- loadConfig();
154
- }
155
-
156
- document.getElementById('login').addEventListener('submit', async (e) => {
157
- e.preventDefault();
158
- const btn = e.target.querySelector('button[type="submit"]');
159
- if (btn.disabled) return;
160
-
161
- const username = document.getElementById('username').value;
162
- const password = document.getElementById('password').value;
163
-
164
- btn.disabled = true;
165
- btn.classList.add('loading');
166
- const originalText = btn.textContent;
167
- btn.textContent = '登录中';
168
-
169
- try {
170
- const response = await fetch('/admin/login', {
171
- method: 'POST',
172
- headers: { 'Content-Type': 'application/json' },
173
- body: JSON.stringify({ username, password })
174
- });
175
-
176
- const data = await response.json();
177
- if (data.success) {
178
- authToken = data.token;
179
- localStorage.setItem('authToken', authToken);
180
- showToast('登录成功', 'success');
181
- showMainContent();
182
- loadTokens();
183
- loadConfig();
184
- } else {
185
- showToast(data.message || '用户名或密码错误', 'error');
186
- }
187
- } catch (error) {
188
- showToast('登录失败: ' + error.message, 'error');
189
- } finally {
190
- btn.disabled = false;
191
- btn.classList.remove('loading');
192
- btn.textContent = originalText;
193
- }
194
- });
195
-
196
- function showOAuthModal() {
197
- showToast('点击后请在新窗口完成授权', 'info');
198
- const modal = document.createElement('div');
199
- modal.className = 'modal form-modal';
200
- modal.innerHTML = `
201
- <div class="modal-content">
202
- <div class="modal-title">🔐 OAuth授权登录</div>
203
- <div class="oauth-steps">
204
- <p><strong>📝 授权流程:</strong></p>
205
- <p>1️⃣ 点击下方按钮打开Google授权页面</p>
206
- <p>2️⃣ 完成授权后,复制浏览器地址栏的完整URL</p>
207
- <p>3️⃣ 粘贴URL到下方输入框并提交</p>
208
- </div>
209
- <div style="display: flex; gap: 8px; margin-bottom: 12px;">
210
- <button type="button" onclick="openOAuthWindow()" class="btn btn-success" style="flex: 1;">🔐 打开授权页面</button>
211
- <button type="button" onclick="copyOAuthUrl()" class="btn btn-info" style="flex: 1;">📋 复制授权链接</button>
212
- </div>
213
- <input type="text" id="modalCallbackUrl" placeholder="粘贴完整的回调URL (http://localhost:xxxxx/oauth-callback?code=...)">
214
- <div class="modal-actions">
215
- <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
216
- <button class="btn btn-success" onclick="processOAuthCallbackModal()">✅ 提交</button>
217
- </div>
218
- </div>
219
- `;
220
- document.body.appendChild(modal);
221
- modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
222
- }
223
-
224
- function createTokenFormBody({
225
- title,
226
- showAccess = true,
227
- showRefresh = true,
228
- showExpires = true
229
- } = {}) {
230
- const parts = [];
231
- if (showAccess) {
232
- parts.push('<input type="text" id="modalAccessToken" placeholder="Access Token (必填)">');
233
- }
234
- if (showRefresh) {
235
- parts.push('<input type="text" id="modalRefreshToken" placeholder="Refresh Token (必填)">');
236
- }
237
- if (showExpires) {
238
- parts.push('<input type="number" id="modalExpiresIn" placeholder="过期时间(秒)" value="3599">');
239
- }
240
- return `
241
- <div class="modal-content">
242
- <div class="modal-title">${title}</div>
243
- <div class="form-row">${parts.join('')}</div>
244
- <p style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 12px;">💡 过期时间默认3599秒(约1小时)</p>
245
- <div class="modal-actions">
246
- <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
247
- <button class="btn btn-success" onclick="addTokenFromModal()">✅ 添加</button>
248
- </div>
249
- </div>
250
- `;
251
- }
252
-
253
- function showManualModal() {
254
- const modal = document.createElement('div');
255
- modal.className = 'modal form-modal';
256
- modal.innerHTML = createTokenFormBody({ title: '✏️ 手动填入Token' });
257
- document.body.appendChild(modal);
258
- modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
259
- }
260
-
261
- function getOAuthUrl() {
262
- if (!oauthPort) oauthPort = Math.floor(Math.random() * 10000) + 50000;
263
- const redirectUri = `http://localhost:${oauthPort}/oauth-callback`;
264
- return `https://accounts.google.com/o/oauth2/v2/auth?` +
265
- `access_type=offline&client_id=${CLIENT_ID}&prompt=consent&` +
266
- `redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&` +
267
- `scope=${encodeURIComponent(SCOPES)}&state=${Date.now()}`;
268
- }
269
-
270
- function openOAuthWindow() {
271
- window.open(getOAuthUrl(), '_blank');
272
- }
273
-
274
- function copyOAuthUrl() {
275
- const url = getOAuthUrl();
276
- navigator.clipboard.writeText(url).then(() => {
277
- showToast('授权链接已复制', 'success');
278
- }).catch(() => {
279
- showToast('复制失败', 'error');
280
- });
281
- }
282
-
283
- async function processOAuthCallbackModal() {
284
- const modal = document.querySelector('.form-modal');
285
- const callbackUrl = document.getElementById('modalCallbackUrl').value.trim();
286
- if (!callbackUrl) {
287
- showToast('请输入回调URL', 'warning');
288
- return;
289
- }
290
-
291
- showLoading('正在处理授权...');
292
-
293
- try {
294
- const url = new URL(callbackUrl);
295
- const code = url.searchParams.get('code');
296
- const port = new URL(url.origin).port || (url.protocol === 'https:' ? 443 : 80);
297
-
298
- if (!code) {
299
- hideLoading();
300
- showToast('URL中未找到授权码', 'error');
301
- return;
302
- }
303
-
304
- const response = await authFetch('/admin/oauth/exchange', {
305
- method: 'POST',
306
- headers: {
307
- 'Content-Type': 'application/json',
308
- 'Authorization': `Bearer ${authToken}`
309
- },
310
- body: JSON.stringify({ code, port })
311
- });
312
-
313
- const result = await response.json();
314
- if (result.success) {
315
- const account = result.data;
316
- const addResponse = await authFetch('/admin/tokens', {
317
- method: 'POST',
318
- headers: {
319
- 'Content-Type': 'application/json',
320
- 'Authorization': `Bearer ${authToken}`
321
- },
322
- body: JSON.stringify(account)
323
- });
324
-
325
- const addResult = await addResponse.json();
326
- hideLoading();
327
- if (addResult.success) {
328
- modal.remove();
329
- showToast('Token添加成功', 'success');
330
- loadTokens();
331
- } else {
332
- showToast('添加失败: ' + addResult.message, 'error');
333
- }
334
- } else {
335
- hideLoading();
336
- showToast('交换失败: ' + result.message, 'error');
337
- }
338
- } catch (error) {
339
- hideLoading();
340
- showToast('处理失败: ' + error.message, 'error');
341
- }
342
- }
343
-
344
- async function addTokenFromModal() {
345
- const modal = document.querySelector('.form-modal');
346
- const accessToken = document.getElementById('modalAccessToken').value.trim();
347
- const refreshToken = document.getElementById('modalRefreshToken').value.trim();
348
- const expiresIn = parseInt(document.getElementById('modalExpiresIn').value);
349
-
350
- if (!accessToken || !refreshToken) {
351
- showToast('请填写完整的Token信息', 'warning');
352
- return;
353
- }
354
-
355
- showLoading('正在添加Token...');
356
- try {
357
- const response = await authFetch('/admin/tokens', {
358
- method: 'POST',
359
- headers: {
360
- 'Content-Type': 'application/json',
361
- 'Authorization': `Bearer ${authToken}`
362
- },
363
- body: JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn })
364
- });
365
-
366
- const data = await response.json();
367
- hideLoading();
368
- if (data.success) {
369
- modal.remove();
370
- showToast('Token添加成功', 'success');
371
- loadTokens();
372
- } else {
373
- showToast(data.message || '添加失败', 'error');
374
- }
375
- } catch (error) {
376
- hideLoading();
377
- showToast('添加失败: ' + error.message, 'error');
378
- }
379
- }
380
-
381
- function showMainContent() {
382
- document.getElementById('loginForm').classList.add('hidden');
383
- document.getElementById('mainContent').classList.remove('hidden');
384
- }
385
-
386
- function switchTab(tab) {
387
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
388
- event.target.classList.add('active');
389
-
390
- document.getElementById('tokensPage').classList.add('hidden');
391
- document.getElementById('settingsPage').classList.add('hidden');
392
-
393
- if (tab === 'tokens') {
394
- document.getElementById('tokensPage').classList.remove('hidden');
395
- } else if (tab === 'settings') {
396
- document.getElementById('settingsPage').classList.remove('hidden');
397
- loadConfig();
398
- }
399
- }
400
-
401
- function silentLogout() {
402
- localStorage.removeItem('authToken');
403
- authToken = null;
404
- document.getElementById('loginForm').classList.remove('hidden');
405
- document.getElementById('mainContent').classList.add('hidden');
406
- }
407
-
408
- async function logout() {
409
- const confirmed = await showConfirm('确定要退出登录吗?', '退出确认');
410
- if (!confirmed) return;
411
-
412
- silentLogout();
413
- showToast('已退出登录', 'info');
414
- }
415
-
416
- async function loadTokens() {
417
- try {
418
- const response = await authFetch('/admin/tokens', {
419
- headers: { 'Authorization': `Bearer ${authToken}` }
420
- });
421
-
422
- const data = await response.json();
423
- if (data.success) {
424
- renderTokens(data.data);
425
- } else {
426
- showToast('加载失败: ' + (data.message || '未知错误'), 'error');
427
- }
428
- } catch (error) {
429
- showToast('加载Token失败: ' + error.message, 'error');
430
- }
431
- }
432
-
433
- function renderTokens(tokens) {
434
- // 缓存tokens用于额度弹窗
435
- cachedTokens = tokens;
436
-
437
- document.getElementById('totalTokens').textContent = tokens.length;
438
- document.getElementById('enabledTokens').textContent = tokens.filter(t => t.enable).length;
439
- document.getElementById('disabledTokens').textContent = tokens.filter(t => !t.enable).length;
440
-
441
- const tokenList = document.getElementById('tokenList');
442
- if (tokens.length === 0) {
443
- tokenList.innerHTML = `
444
- <div class="empty-state">
445
- <div class="empty-state-icon">📦</div>
446
- <div class="empty-state-text">暂无Token</div>
447
- <div class="empty-state-hint">点击上方OAuth按钮添加Token</div>
448
- </div>
449
- `;
450
- return;
451
- }
452
-
453
- tokenList.innerHTML = tokens.map(token => {
454
- const expireTime = new Date(token.timestamp + token.expires_in * 1000);
455
- const isExpired = expireTime < new Date();
456
- const expireStr = expireTime.toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
457
- const cardId = token.refresh_token.substring(0, 8);
458
-
459
- return `
460
- <div class="token-card ${!token.enable ? 'disabled' : ''} ${isExpired ? 'expired' : ''}">
461
- <div class="token-header">
462
- <span class="status ${token.enable ? 'enabled' : 'disabled'}">
463
- ${token.enable ? '✅ 启用' : '❌ 禁用'}
464
- </span>
465
- <div class="token-header-right">
466
- <button class="btn-icon" onclick="showTokenDetail('${token.refresh_token}')" title="编辑全部">✏️</button>
467
- <span class="token-id">#${token.refresh_token.substring(0, 6)}</span>
468
- </div>
469
- </div>
470
- <div class="token-info">
471
- <div class="info-row">
472
- <span class="info-label">🎫</span>
473
- <span class="info-value sensitive-info" title="${token.access_token_suffix}">${token.access_token_suffix}</span>
474
- </div>
475
- <div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'projectId', '${(token.projectId || '').replace(/'/g, "\\'")}')" title="点击编辑">
476
- <span class="info-label">📦</span>
477
- <span class="info-value sensitive-info">${token.projectId || '点击设置'}</span>
478
- <span class="info-edit-icon">✏️</span>
479
- </div>
480
- <div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'email', '${(token.email || '').replace(/'/g, "\\'")}')" title="点击编辑">
481
- <span class="info-label">📧</span>
482
- <span class="info-value sensitive-info">${token.email || '点击设置'}</span>
483
- <span class="info-edit-icon">✏️</span>
484
- </div>
485
- <div class="info-row ${isExpired ? 'expired-text' : ''}">
486
- <span class="info-label">⏰</span>
487
- <span class="info-value">${expireStr}${isExpired ? ' (已过期)' : ''}</span>
488
- </div>
489
- </div>
490
- <!-- 内嵌额度显示 -->
491
- <div class="token-quota-inline" id="quota-inline-${cardId}">
492
- <div class="quota-inline-header" onclick="toggleQuotaExpand('${cardId}', '${token.refresh_token}')">
493
- <span class="quota-inline-summary" id="quota-summary-${cardId}">📊 加载中...</span>
494
- <span class="quota-inline-toggle" id="quota-toggle-${cardId}">▼</span>
495
- </div>
496
- <div class="quota-inline-detail hidden" id="quota-detail-${cardId}"></div>
497
- </div>
498
- <div class="token-actions">
499
- <button class="btn btn-info btn-xs" onclick="showQuotaModal('${token.refresh_token}')" title="查看额度">📊 详情</button>
500
- <button class="btn ${token.enable ? 'btn-warning' : 'btn-success'} btn-xs" onclick="toggleToken('${token.refresh_token}', ${!token.enable})" title="${token.enable ? '禁用' : '启用'}">
501
- ${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
502
- </button>
503
- <button class="btn btn-danger btn-xs" onclick="deleteToken('${token.refresh_token}')" title="删除">���️ 删除</button>
504
- </div>
505
- </div>
506
- `}).join('');
507
-
508
- // 自动加载所有token的额度摘要
509
- tokens.forEach(token => {
510
- loadTokenQuotaSummary(token.refresh_token);
511
- });
512
-
513
- // 应用敏感信息隐藏状态
514
- updateSensitiveInfoDisplay();
515
- }
516
-
517
- // 加载token额度摘要(只显示最低额度的模型)
518
- async function loadTokenQuotaSummary(refreshToken) {
519
- const cardId = refreshToken.substring(0, 8);
520
- const summaryEl = document.getElementById(`quota-summary-${cardId}`);
521
- if (!summaryEl) return;
522
-
523
- // 先检查缓存
524
- const cached = quotaCache.get(refreshToken);
525
- if (cached) {
526
- renderQuotaSummary(summaryEl, cached);
527
- return;
528
- }
529
-
530
- try {
531
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
532
- headers: { 'Authorization': `Bearer ${authToken}` }
533
- });
534
- const data = await response.json();
535
-
536
- if (data.success && data.data && data.data.models) {
537
- // 缓存数据
538
- quotaCache.set(refreshToken, data.data);
539
- renderQuotaSummary(summaryEl, data.data);
540
- } else {
541
- const errMsg = data.message || '未知错误';
542
- summaryEl.innerHTML = `<span class="quota-summary-error">📊 ${errMsg}</span>`;
543
- }
544
- } catch (error) {
545
- if (error.message !== 'Unauthorized') {
546
- console.error('加载额度摘要失败:', error);
547
- summaryEl.innerHTML = `<span class="quota-summary-error">📊 加载失败</span>`;
548
- }
549
- }
550
- }
551
-
552
- // 渲染额度摘要
553
- function renderQuotaSummary(summaryEl, quotaData) {
554
- const models = quotaData.models;
555
- const modelEntries = Object.entries(models);
556
-
557
- if (modelEntries.length === 0) {
558
- summaryEl.textContent = '📊 暂无额度';
559
- return;
560
- }
561
-
562
- // 找到额度最低的模型
563
- let minModel = modelEntries[0][0];
564
- let minQuota = modelEntries[0][1];
565
- modelEntries.forEach(([modelId, quota]) => {
566
- if (quota.remaining < minQuota.remaining) {
567
- minQuota = quota;
568
- minModel = modelId;
569
- }
570
- });
571
-
572
- const percentage = minQuota.remaining * 100;
573
- const percentageText = `${percentage.toFixed(2)}%`;
574
- const shortName = minModel.replace('models/', '').replace('publishers/google/', '').split('/').pop();
575
- const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
576
-
577
- // 简洁的一行显示
578
- summaryEl.innerHTML = `
579
- <span class="quota-summary-icon">📊</span>
580
- <span class="quota-summary-model" title="${minModel}">${shortName}</span>
581
- <span class="quota-summary-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
582
- <span class="quota-summary-pct">${percentageText}</span>
583
- `;
584
- }
585
-
586
- // 展开/收起额度详情
587
- async function toggleQuotaExpand(cardId, refreshToken) {
588
- const detailEl = document.getElementById(`quota-detail-${cardId}`);
589
- const toggleEl = document.getElementById(`quota-toggle-${cardId}`);
590
- if (!detailEl || !toggleEl) return;
591
-
592
- const isHidden = detailEl.classList.contains('hidden');
593
-
594
- if (isHidden) {
595
- // 展开
596
- detailEl.classList.remove('hidden');
597
- toggleEl.textContent = '▲';
598
-
599
- // 如果还没加载过详情,加载它
600
- if (!detailEl.dataset.loaded) {
601
- detailEl.innerHTML = '<div class="quota-loading-small">加载中...</div>';
602
- await loadQuotaDetail(cardId, refreshToken);
603
- detailEl.dataset.loaded = 'true';
604
- }
605
- } else {
606
- // 收起
607
- detailEl.classList.add('hidden');
608
- toggleEl.textContent = '▼';
609
- }
610
- }
611
-
612
- // 加载额度详情
613
- async function loadQuotaDetail(cardId, refreshToken) {
614
- const detailEl = document.getElementById(`quota-detail-${cardId}`);
615
- if (!detailEl) return;
616
-
617
- try {
618
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
619
- headers: { 'Authorization': `Bearer ${authToken}` }
620
- });
621
- const data = await response.json();
622
-
623
- if (data.success && data.data && data.data.models) {
624
- const models = data.data.models;
625
- const modelEntries = Object.entries(models);
626
-
627
- if (modelEntries.length === 0) {
628
- detailEl.innerHTML = '<div class="quota-empty-small">暂无额度信息</div>';
629
- return;
630
- }
631
-
632
- // 按模型类型分组
633
- const grouped = { claude: [], gemini: [], other: [] };
634
- modelEntries.forEach(([modelId, quota]) => {
635
- const item = { modelId, quota };
636
- if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
637
- else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
638
- else grouped.other.push(item);
639
- });
640
-
641
- let html = '<div class="quota-detail-grid">';
642
-
643
- const renderGroup = (items, icon) => {
644
- if (items.length === 0) return '';
645
- let groupHtml = '';
646
- items.forEach(({ modelId, quota }) => {
647
- const percentage = quota.remaining * 100;
648
- const percentageText = `${percentage.toFixed(2)}%`;
649
- const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
650
- const shortName = modelId.replace('models/', '').replace('publishers/google/', '').split('/').pop();
651
- // 紧凑的一行显示
652
- groupHtml += `
653
- <div class="quota-detail-row" title="${modelId} - 重置: ${quota.resetTime}">
654
- <span class="quota-detail-icon">${icon}</span>
655
- <span class="quota-detail-name">${shortName}</span>
656
- <span class="quota-detail-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
657
- <span class="quota-detail-pct">${percentageText}</span>
658
- </div>
659
- `;
660
- });
661
- return groupHtml;
662
- };
663
-
664
- html += renderGroup(grouped.claude, '🤖');
665
- html += renderGroup(grouped.gemini, '💎');
666
- html += renderGroup(grouped.other, '🔧');
667
- html += '</div>';
668
-
669
- // 添加刷新按钮
670
- html += `<button class="btn btn-info btn-xs quota-refresh-btn" onclick="refreshInlineQuota('${cardId}', '${refreshToken}')">🔄 刷新额度</button>`;
671
-
672
- detailEl.innerHTML = html;
673
- } else {
674
- const errMsg = data.message || '未知错误';
675
- detailEl.innerHTML = `<div class="quota-error-small">加载失败: ${errMsg}</div>`;
676
- }
677
- } catch (error) {
678
- if (error.message !== 'Unauthorized') {
679
- detailEl.innerHTML = `<div class="quota-error-small">网络错误</div>`;
680
- }
681
- }
682
- }
683
-
684
- // 刷新内嵌额度
685
- async function refreshInlineQuota(cardId, refreshToken) {
686
- const detailEl = document.getElementById(`quota-detail-${cardId}`);
687
- const summaryEl = document.getElementById(`quota-summary-${cardId}`);
688
-
689
- if (detailEl) {
690
- detailEl.innerHTML = '<div class="quota-loading-small">刷新中...</div>';
691
- }
692
- if (summaryEl) {
693
- summaryEl.textContent = '📊 刷新中...';
694
- }
695
-
696
- // 清除缓存
697
- quotaCache.clear(refreshToken);
698
-
699
- // 强制刷新
700
- try {
701
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas?refresh=true`, {
702
- headers: { 'Authorization': `Bearer ${authToken}` }
703
- });
704
- const data = await response.json();
705
- if (data.success && data.data) {
706
- quotaCache.set(refreshToken, data.data);
707
- }
708
- } catch (e) {}
709
-
710
- // 重新加载摘要和详情
711
- await loadTokenQuotaSummary(refreshToken);
712
- await loadQuotaDetail(cardId, refreshToken);
713
- }
714
-
715
- // 内联编辑字段
716
- function editField(event, refreshToken, field, currentValue) {
717
- event.stopPropagation();
718
- const row = event.currentTarget;
719
- const valueSpan = row.querySelector('.info-value');
720
-
721
- // 如果已经在编辑状态,不重复创建
722
- if (row.querySelector('input')) return;
723
-
724
- const fieldLabels = {
725
- projectId: 'Project ID',
726
- email: '邮箱'
727
- };
728
-
729
- // 创建输入框
730
- const input = document.createElement('input');
731
- input.type = field === 'email' ? 'email' : 'text';
732
- input.value = currentValue;
733
- input.className = 'inline-edit-input';
734
- input.placeholder = `输入${fieldLabels[field]}`;
735
-
736
- // 保存原始内容
737
- const originalContent = valueSpan.textContent;
738
- valueSpan.style.display = 'none';
739
- row.insertBefore(input, valueSpan.nextSibling);
740
- input.focus();
741
- input.select();
742
-
743
- // 保存函数
744
- const save = async () => {
745
- const newValue = input.value.trim();
746
- input.disabled = true;
747
-
748
- try {
749
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
750
- method: 'PUT',
751
- headers: {
752
- 'Content-Type': 'application/json',
753
- 'Authorization': `Bearer ${authToken}`
754
- },
755
- body: JSON.stringify({ [field]: newValue })
756
- });
757
-
758
- const data = await response.json();
759
- if (data.success) {
760
- showToast('已保存', 'success');
761
- loadTokens();
762
- } else {
763
- showToast(data.message || '保存失败', 'error');
764
- cancel();
765
- }
766
- } catch (error) {
767
- showToast('保存失败', 'error');
768
- cancel();
769
- }
770
- };
771
-
772
- // 取消函数
773
- const cancel = () => {
774
- input.remove();
775
- valueSpan.style.display = '';
776
- };
777
-
778
- // 事件监听
779
- input.addEventListener('blur', () => {
780
- setTimeout(() => {
781
- if (document.activeElement !== input) {
782
- if (input.value.trim() !== currentValue) {
783
- save();
784
- } else {
785
- cancel();
786
- }
787
- }
788
- }, 100);
789
- });
790
-
791
- input.addEventListener('keydown', (e) => {
792
- if (e.key === 'Enter') {
793
- e.preventDefault();
794
- save();
795
- } else if (e.key === 'Escape') {
796
- cancel();
797
- }
798
- });
799
- }
800
-
801
- // 显示Token详情编辑弹窗
802
- function showTokenDetail(refreshToken) {
803
- const token = cachedTokens.find(t => t.refresh_token === refreshToken);
804
- if (!token) {
805
- showToast('Token不存在', 'error');
806
- return;
807
- }
808
-
809
- const modal = document.createElement('div');
810
- modal.className = 'modal form-modal';
811
- modal.innerHTML = `
812
- <div class="modal-content">
813
- <div class="modal-title">📝 Token详情</div>
814
- <div class="form-group compact">
815
- <label>🎫 Access Token (只读)</label>
816
- <div class="token-display">${token.access_token || ''}</div>
817
- </div>
818
- <div class="form-group compact">
819
- <label>🔄 Refresh Token (只读)</label>
820
- <div class="token-display">${token.refresh_token}</div>
821
- </div>
822
- <div class="form-group compact">
823
- <label>📦 Project ID</label>
824
- <input type="text" id="editProjectId" value="${token.projectId || ''}" placeholder="项目ID">
825
- </div>
826
- <div class="form-group compact">
827
- <label>📧 邮箱</label>
828
- <input type="email" id="editEmail" value="${token.email || ''}" placeholder="账号邮箱">
829
- </div>
830
- <div class="form-group compact">
831
- <label>⏰ 过期时间</label>
832
- <input type="text" value="${new Date(token.timestamp + token.expires_in * 1000).toLocaleString('zh-CN')}" readonly style="background: var(--bg); cursor: not-allowed;">
833
- </div>
834
- <div class="modal-actions">
835
- <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
836
- <button class="btn btn-success" onclick="saveTokenDetail('${refreshToken}')">💾 保存</button>
837
- </div>
838
- </div>
839
- `;
840
- document.body.appendChild(modal);
841
- modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
842
- }
843
-
844
- // 保存Token详情
845
- async function saveTokenDetail(refreshToken) {
846
- const projectId = document.getElementById('editProjectId').value.trim();
847
- const email = document.getElementById('editEmail').value.trim();
848
-
849
- showLoading('保存中...');
850
- try {
851
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
852
- method: 'PUT',
853
- headers: {
854
- 'Content-Type': 'application/json',
855
- 'Authorization': `Bearer ${authToken}`
856
- },
857
- body: JSON.stringify({ projectId, email })
858
- });
859
-
860
- const data = await response.json();
861
- hideLoading();
862
- if (data.success) {
863
- document.querySelector('.form-modal').remove();
864
- showToast('保存成功', 'success');
865
- loadTokens();
866
- } else {
867
- showToast(data.message || '保存失败', 'error');
868
- }
869
- } catch (error) {
870
- hideLoading();
871
- showToast('保存失败: ' + error.message, 'error');
872
- }
873
- }
874
-
875
- async function toggleToken(refreshToken, enable) {
876
- const action = enable ? '启用' : '禁用';
877
- const confirmed = await showConfirm(`确定要${action}这个Token吗?`, `${action}确认`);
878
- if (!confirmed) return;
879
-
880
- showLoading(`正在${action}...`);
881
- try {
882
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
883
- method: 'PUT',
884
- headers: {
885
- 'Content-Type': 'application/json',
886
- 'Authorization': `Bearer ${authToken}`
887
- },
888
- body: JSON.stringify({ enable })
889
- });
890
-
891
- const data = await response.json();
892
- hideLoading();
893
- if (data.success) {
894
- showToast(`已${action}`, 'success');
895
- loadTokens();
896
- } else {
897
- showToast(data.message || '操作失败', 'error');
898
- }
899
- } catch (error) {
900
- hideLoading();
901
- showToast('操作失败: ' + error.message, 'error');
902
- }
903
- }
904
-
905
- async function deleteToken(refreshToken) {
906
- const confirmed = await showConfirm('删除后无法恢复,确定删除?', '⚠️ 删除确认');
907
- if (!confirmed) return;
908
-
909
- showLoading('正在删除...');
910
- try {
911
- const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
912
- method: 'DELETE',
913
- headers: { 'Authorization': `Bearer ${authToken}` }
914
- });
915
-
916
- const data = await response.json();
917
- hideLoading();
918
- if (data.success) {
919
- showToast('已删除', 'success');
920
- loadTokens();
921
- } else {
922
- showToast(data.message || '删除失败', 'error');
923
- }
924
- } catch (error) {
925
- hideLoading();
926
- showToast('删除失败: ' + error.message, 'error');
927
- }
928
- }
929
-
930
- // 存储token数据用于额度弹窗显示邮箱
931
- let cachedTokens = [];
932
- // 当前选中的token(用于额度弹窗)
933
- let currentQuotaToken = null;
934
-
935
- // 额度数据缓存 - 避免频繁请求
936
- const quotaCache = {
937
- data: {}, // { refreshToken: { data, timestamp } }
938
- ttl: 5 * 60 * 1000, // 缓存5分钟
939
-
940
- get(refreshToken) {
941
- const cached = this.data[refreshToken];
942
- if (!cached) return null;
943
- if (Date.now() - cached.timestamp > this.ttl) {
944
- delete this.data[refreshToken];
945
- return null;
946
- }
947
- return cached.data;
948
- },
949
-
950
- set(refreshToken, data) {
951
- this.data[refreshToken] = {
952
- data,
953
- timestamp: Date.now()
954
- };
955
- },
956
-
957
- clear(refreshToken) {
958
- if (refreshToken) {
959
- delete this.data[refreshToken];
960
- } else {
961
- this.data = {};
962
- }
963
- }
964
- };
965
-
966
- async function showQuotaModal(refreshToken) {
967
- currentQuotaToken = refreshToken;
968
-
969
- // 找到当前token的索引
970
- const activeIndex = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
971
-
972
- // 生成邮箱标签 - 使用索引来确保只有一个active
973
- const emailTabs = cachedTokens.map((t, index) => {
974
- const email = t.email || '未知';
975
- const shortEmail = email.length > 20 ? email.substring(0, 17) + '...' : email;
976
- const isActive = index === activeIndex;
977
- return `<button type="button" class="quota-tab${isActive ? ' active' : ''}" data-index="${index}" onclick="switchQuotaAccountByIndex(${index})" title="${email}">${shortEmail}</button>`;
978
- }).join('');
979
-
980
- const modal = document.createElement('div');
981
- modal.className = 'modal';
982
- modal.id = 'quotaModal';
983
- modal.innerHTML = `
984
- <div class="modal-content modal-xl">
985
- <div class="quota-modal-header">
986
- <div class="modal-title">📊 模型额度</div>
987
- <div class="quota-update-time" id="quotaUpdateTime"></div>
988
- </div>
989
- <div class="quota-tabs" id="quotaEmailList">
990
- ${emailTabs}
991
- </div>
992
- <div id="quotaContent" class="quota-container">
993
- <div class="quota-loading">加载中...</div>
994
- </div>
995
- <div class="modal-actions">
996
- <button class="btn btn-info btn-sm" id="quotaRefreshBtn" onclick="refreshQuotaData()">🔄 刷新</button>
997
- <button class="btn btn-secondary btn-sm" onclick="this.closest('.modal').remove()">关闭</button>
998
- </div>
999
- </div>
1000
- `;
1001
- document.body.appendChild(modal);
1002
- modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
1003
-
1004
- await loadQuotaData(refreshToken);
1005
-
1006
- // 添加鼠标滚轮横向滚动支持
1007
- const tabsContainer = document.getElementById('quotaEmailList');
1008
- if (tabsContainer) {
1009
- tabsContainer.addEventListener('wheel', (e) => {
1010
- if (e.deltaY !== 0) {
1011
- e.preventDefault();
1012
- tabsContainer.scrollLeft += e.deltaY;
1013
- }
1014
- }, { passive: false });
1015
- }
1016
- }
1017
-
1018
- // 切换账号(通过索引)
1019
- async function switchQuotaAccountByIndex(index) {
1020
- if (index < 0 || index >= cachedTokens.length) return;
1021
-
1022
- const token = cachedTokens[index];
1023
- currentQuotaToken = token.refresh_token;
1024
-
1025
- // 更新标签的激活状态
1026
- document.querySelectorAll('.quota-tab').forEach((tab, i) => {
1027
- if (i === index) {
1028
- tab.classList.add('active');
1029
- } else {
1030
- tab.classList.remove('active');
1031
- }
1032
- });
1033
-
1034
- // 加载新账号的额度
1035
- await loadQuotaData(token.refresh_token);
1036
- }
1037
-
1038
- // 保留旧函数以兼容
1039
- async function switchQuotaAccount(refreshToken) {
1040
- const index = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
1041
- if (index >= 0) {
1042
- await switchQuotaAccountByIndex(index);
1043
- }
1044
- }
1045
-
1046
- async function loadQuotaData(refreshToken, forceRefresh = false) {
1047
- const quotaContent = document.getElementById('quotaContent');
1048
- if (!quotaContent) return;
1049
-
1050
- const refreshBtn = document.getElementById('quotaRefreshBtn');
1051
- if (refreshBtn) {
1052
- refreshBtn.disabled = true;
1053
- refreshBtn.textContent = '⏳ 加载中...';
1054
- }
1055
-
1056
- // 如果不是强制刷新,先检查缓存
1057
- if (!forceRefresh) {
1058
- const cached = quotaCache.get(refreshToken);
1059
- if (cached) {
1060
- renderQuotaModal(quotaContent, cached);
1061
- if (refreshBtn) {
1062
- refreshBtn.disabled = false;
1063
- refreshBtn.textContent = '🔄 刷新';
1064
- }
1065
- return;
1066
- }
1067
- } else {
1068
- // 强制刷新时清除缓存
1069
- quotaCache.clear(refreshToken);
1070
- }
1071
-
1072
- quotaContent.innerHTML = '<div class="quota-loading">加载中...</div>';
1073
-
1074
- try {
1075
- const url = `/admin/tokens/${encodeURIComponent(refreshToken)}/quotas${forceRefresh ? '?refresh=true' : ''}`;
1076
- const response = await fetch(url, {
1077
- headers: { 'Authorization': `Bearer ${authToken}` }
1078
- });
1079
-
1080
- const data = await response.json();
1081
-
1082
- if (data.success) {
1083
- // 缓存数据
1084
- quotaCache.set(refreshToken, data.data);
1085
- renderQuotaModal(quotaContent, data.data);
1086
- } else {
1087
- quotaContent.innerHTML = `<div class="quota-error">加载失败: ${data.message}</div>`;
1088
- }
1089
- } catch (error) {
1090
- if (quotaContent) {
1091
- quotaContent.innerHTML = `<div class="quota-error">加载失败: ${error.message}</div>`;
1092
- }
1093
- } finally {
1094
- if (refreshBtn) {
1095
- refreshBtn.disabled = false;
1096
- refreshBtn.textContent = '🔄 刷新';
1097
- }
1098
- }
1099
- }
1100
-
1101
- async function refreshQuotaData() {
1102
- if (currentQuotaToken) {
1103
- await loadQuotaData(currentQuotaToken, true);
1104
- }
1105
- }
1106
-
1107
- // 渲染额度弹窗内容
1108
- function renderQuotaModal(quotaContent, quotaData) {
1109
- const models = quotaData.models;
1110
-
1111
- // 更新时间显示
1112
- const updateTimeEl = document.getElementById('quotaUpdateTime');
1113
- if (updateTimeEl && quotaData.lastUpdated) {
1114
- const lastUpdated = new Date(quotaData.lastUpdated).toLocaleString('zh-CN', {
1115
- month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
1116
- });
1117
- updateTimeEl.textContent = `更新于 ${lastUpdated}`;
1118
- }
1119
-
1120
- if (Object.keys(models).length === 0) {
1121
- quotaContent.innerHTML = '<div class="quota-empty">暂无额度信息</div>';
1122
- return;
1123
- }
1124
-
1125
- // 按模型类型分组
1126
- const grouped = { claude: [], gemini: [], other: [] };
1127
- Object.entries(models).forEach(([modelId, quota]) => {
1128
- const item = { modelId, quota };
1129
- if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
1130
- else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
1131
- else grouped.other.push(item);
1132
- });
1133
-
1134
- let html = '';
1135
-
1136
- const renderGroup = (items, title) => {
1137
- if (items.length === 0) return '';
1138
- let groupHtml = `<div class="quota-group-title">${title}</div><div class="quota-grid">`;
1139
- items.forEach(({ modelId, quota }) => {
1140
- const percentage = quota.remaining * 100;
1141
- const percentageText = `${percentage.toFixed(2)}%`;
1142
- const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
1143
- const shortName = modelId.replace('models/', '').replace('publishers/google/', '');
1144
- groupHtml += `
1145
- <div class="quota-item">
1146
- <div class="quota-model-name" title="${modelId}">${shortName}</div>
1147
- <div class="quota-bar-container">
1148
- <div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
1149
- </div>
1150
- <div class="quota-info-row">
1151
- <span class="quota-reset">重置: ${quota.resetTime}</span>
1152
- <span class="quota-percentage">${percentageText}</span>
1153
- </div>
1154
- </div>
1155
- `;
1156
- });
1157
- groupHtml += '</div>';
1158
- return groupHtml;
1159
- };
1160
-
1161
- html += renderGroup(grouped.claude, '🤖 Claude');
1162
- html += renderGroup(grouped.gemini, '💎 Gemini');
1163
- html += renderGroup(grouped.other, '🔧 其他');
1164
-
1165
- quotaContent.innerHTML = html;
1166
- }
1167
-
1168
- // 切换请求次数输入框的显示
1169
- function toggleRequestCountInput() {
1170
- const strategy = document.getElementById('rotationStrategy').value;
1171
- const requestCountGroup = document.getElementById('requestCountGroup');
1172
- if (requestCountGroup) {
1173
- requestCountGroup.style.display = strategy === 'request_count' ? 'block' : 'none';
1174
- }
1175
- }
1176
-
1177
- // 加载轮询策略状态
1178
- async function loadRotationStatus() {
1179
- try {
1180
- const response = await authFetch('/admin/rotation', {
1181
- headers: { 'Authorization': `Bearer ${authToken}` }
1182
- });
1183
- const data = await response.json();
1184
- if (data.success) {
1185
- const { strategy, requestCount, currentIndex, tokenCounts } = data.data;
1186
- const strategyNames = {
1187
- 'round_robin': '均衡负载',
1188
- 'quota_exhausted': '额度耗尽切换',
1189
- 'request_count': '自定义次数'
1190
- };
1191
- const statusEl = document.getElementById('currentRotationInfo');
1192
- if (statusEl) {
1193
- let statusText = `${strategyNames[strategy] || strategy}`;
1194
- if (strategy === 'request_count') {
1195
- statusText += ` (每${requestCount}次)`;
1196
- }
1197
- statusText += ` | 当前索引: ${currentIndex}`;
1198
- statusEl.textContent = statusText;
1199
- }
1200
- }
1201
- } catch (error) {
1202
- console.error('加载轮询状态失败:', error);
1203
- }
1204
- }
1205
-
1206
- async function loadConfig() {
1207
- try {
1208
- const response = await authFetch('/admin/config', {
1209
- headers: { 'Authorization': `Bearer ${authToken}` }
1210
- });
1211
- const data = await response.json();
1212
- if (data.success) {
1213
- const form = document.getElementById('configForm');
1214
- const { env, json } = data.data;
1215
-
1216
- // 更新服务器信息显示
1217
- const serverInfo = document.getElementById('serverInfo');
1218
- if (serverInfo && json.server) {
1219
- serverInfo.textContent = `${json.server.host || '0.0.0.0'}:${json.server.port || 8045}`;
1220
- }
1221
-
1222
- // 加载 .env 配置
1223
- Object.entries(env).forEach(([key, value]) => {
1224
- const input = form.elements[key];
1225
- if (input) input.value = value || '';
1226
- });
1227
-
1228
- // 加载 config.json 配置
1229
- if (json.server) {
1230
- if (form.elements['PORT']) form.elements['PORT'].value = json.server.port || '';
1231
- if (form.elements['HOST']) form.elements['HOST'].value = json.server.host || '';
1232
- if (form.elements['MAX_REQUEST_SIZE']) form.elements['MAX_REQUEST_SIZE'].value = json.server.maxRequestSize || '';
1233
- if (form.elements['HEARTBEAT_INTERVAL']) form.elements['HEARTBEAT_INTERVAL'].value = json.server.heartbeatInterval || '';
1234
- if (form.elements['MEMORY_THRESHOLD']) form.elements['MEMORY_THRESHOLD'].value = json.server.memoryThreshold || '';
1235
- }
1236
- if (json.defaults) {
1237
- if (form.elements['DEFAULT_TEMPERATURE']) form.elements['DEFAULT_TEMPERATURE'].value = json.defaults.temperature ?? '';
1238
- if (form.elements['DEFAULT_TOP_P']) form.elements['DEFAULT_TOP_P'].value = json.defaults.topP ?? '';
1239
- if (form.elements['DEFAULT_TOP_K']) form.elements['DEFAULT_TOP_K'].value = json.defaults.topK ?? '';
1240
- if (form.elements['DEFAULT_MAX_TOKENS']) form.elements['DEFAULT_MAX_TOKENS'].value = json.defaults.maxTokens ?? '';
1241
- if (form.elements['DEFAULT_THINKING_BUDGET']) form.elements['DEFAULT_THINKING_BUDGET'].value = json.defaults.thinkingBudget ?? '';
1242
- }
1243
- if (json.other) {
1244
- if (form.elements['TIMEOUT']) form.elements['TIMEOUT'].value = json.other.timeout ?? '';
1245
- if (form.elements['RETRY_TIMES']) form.elements['RETRY_TIMES'].value = json.other.retryTimes ?? '';
1246
- if (form.elements['SKIP_PROJECT_ID_FETCH']) form.elements['SKIP_PROJECT_ID_FETCH'].value = json.other.skipProjectIdFetch ? 'true' : 'false';
1247
- }
1248
- // 加载轮询策略配置
1249
- if (json.rotation) {
1250
- if (form.elements['ROTATION_STRATEGY']) {
1251
- form.elements['ROTATION_STRATEGY'].value = json.rotation.strategy || 'round_robin';
1252
- }
1253
- if (form.elements['ROTATION_REQUEST_COUNT']) {
1254
- form.elements['ROTATION_REQUEST_COUNT'].value = json.rotation.requestCount || 10;
1255
- }
1256
- toggleRequestCountInput();
1257
- }
1258
-
1259
- // 加载轮询状态
1260
- loadRotationStatus();
1261
- }
1262
- } catch (error) {
1263
- showToast('加载配置失败: ' + error.message, 'error');
1264
- }
1265
- }
1266
-
1267
- document.getElementById('configForm').addEventListener('submit', async (e) => {
1268
- e.preventDefault();
1269
- const formData = new FormData(e.target);
1270
- const allConfig = Object.fromEntries(formData);
1271
-
1272
- // 分离敏感和非敏感配置
1273
- const sensitiveKeys = ['API_KEY', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'JWT_SECRET', 'PROXY', 'SYSTEM_INSTRUCTION', 'IMAGE_BASE_URL'];
1274
- const envConfig = {};
1275
- const jsonConfig = {
1276
- server: {},
1277
- api: {},
1278
- defaults: {},
1279
- other: {},
1280
- rotation: {}
1281
- };
1282
-
1283
- Object.entries(allConfig).forEach(([key, value]) => {
1284
- if (sensitiveKeys.includes(key)) {
1285
- envConfig[key] = value;
1286
- } else {
1287
- // 映射到 config.json 结构
1288
- if (key === 'PORT') jsonConfig.server.port = parseInt(value) || undefined;
1289
- else if (key === 'HOST') jsonConfig.server.host = value || undefined;
1290
- else if (key === 'MAX_REQUEST_SIZE') jsonConfig.server.maxRequestSize = value || undefined;
1291
- else if (key === 'HEARTBEAT_INTERVAL') jsonConfig.server.heartbeatInterval = parseInt(value) || undefined;
1292
- else if (key === 'MEMORY_THRESHOLD') jsonConfig.server.memoryThreshold = parseInt(value) || undefined;
1293
- else if (key === 'DEFAULT_TEMPERATURE') jsonConfig.defaults.temperature = parseFloat(value) || undefined;
1294
- else if (key === 'DEFAULT_TOP_P') jsonConfig.defaults.topP = parseFloat(value) || undefined;
1295
- else if (key === 'DEFAULT_TOP_K') jsonConfig.defaults.topK = parseInt(value) || undefined;
1296
- else if (key === 'DEFAULT_MAX_TOKENS') jsonConfig.defaults.maxTokens = parseInt(value) || undefined;
1297
- else if (key === 'DEFAULT_THINKING_BUDGET') {
1298
- const num = parseInt(value);
1299
- jsonConfig.defaults.thinkingBudget = Number.isNaN(num) ? undefined : num;
1300
- }
1301
- else if (key === 'TIMEOUT') jsonConfig.other.timeout = parseInt(value) || undefined;
1302
- else if (key === 'RETRY_TIMES') {
1303
- const num = parseInt(value);
1304
- jsonConfig.other.retryTimes = Number.isNaN(num) ? undefined : num;
1305
- }
1306
- else if (key === 'SKIP_PROJECT_ID_FETCH') jsonConfig.other.skipProjectIdFetch = value === 'true';
1307
- else if (key === 'ROTATION_STRATEGY') jsonConfig.rotation.strategy = value || undefined;
1308
- else if (key === 'ROTATION_REQUEST_COUNT') jsonConfig.rotation.requestCount = parseInt(value) || undefined;
1309
- else envConfig[key] = value;
1310
- }
1311
- });
1312
-
1313
- // 清理undefined值
1314
- Object.keys(jsonConfig).forEach(section => {
1315
- Object.keys(jsonConfig[section]).forEach(key => {
1316
- if (jsonConfig[section][key] === undefined) {
1317
- delete jsonConfig[section][key];
1318
- }
1319
- });
1320
- if (Object.keys(jsonConfig[section]).length === 0) {
1321
- delete jsonConfig[section];
1322
- }
1323
- });
1324
-
1325
- showLoading('正在保存配置...');
1326
- try {
1327
- // 先保存通用配置
1328
- const response = await authFetch('/admin/config', {
1329
- method: 'PUT',
1330
- headers: {
1331
- 'Content-Type': 'application/json',
1332
- 'Authorization': `Bearer ${authToken}`
1333
- },
1334
- body: JSON.stringify({ env: envConfig, json: jsonConfig })
1335
- });
1336
-
1337
- const data = await response.json();
1338
-
1339
- // 如果有轮询配置,单独更新轮询策略(触发热更新)
1340
- if (jsonConfig.rotation && Object.keys(jsonConfig.rotation).length > 0) {
1341
- await authFetch('/admin/rotation', {
1342
- method: 'PUT',
1343
- headers: {
1344
- 'Content-Type': 'application/json',
1345
- 'Authorization': `Bearer ${authToken}`
1346
- },
1347
- body: JSON.stringify(jsonConfig.rotation)
1348
- });
1349
- }
1350
-
1351
- hideLoading();
1352
- if (data.success) {
1353
- showToast('配置已保存', 'success');
1354
- loadConfig(); // 重新加载以更新显示
1355
- } else {
1356
- showToast(data.message || '保存失败', 'error');
1357
- }
1358
- } catch (error) {
1359
- hideLoading();
1360
- showToast('保存失败: ' + error.message, 'error');
1361
- }
1362
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/index.html CHANGED
@@ -138,7 +138,7 @@
138
  </div>
139
  </div>
140
  <div class="form-group compact highlight">
141
- <label>思考预算 <span class="help-tip" title="思考模型的思考token预算,影响推理深度">?</span></label>
142
  <input type="number" name="DEFAULT_THINKING_BUDGET" placeholder="16000">
143
  </div>
144
  <div class="form-group compact">
@@ -152,7 +152,7 @@
152
  <h4>🔄 轮询与性能</h4>
153
  <div class="form-row-inline">
154
  <div class="form-group compact">
155
- <label>策略模式 <span class="help-tip" title="均衡负载:每次请求切换Token&#10;额度耗尽:用完额度才切换&#10;自定义次数:指定次数后切换">?</span></label>
156
  <select name="ROTATION_STRATEGY" id="rotationStrategy" onchange="toggleRequestCountInput()">
157
  <option value="round_robin">均衡负载</option>
158
  <option value="quota_exhausted">额度耗尽切换</option>
@@ -160,7 +160,7 @@
160
  </select>
161
  </div>
162
  <div class="form-group compact" id="requestCountGroup">
163
- <label>每Token请求次数 <span class="help-tip" title="自定义次数模式下,每个Token处理多少次请求后切换">?</span></label>
164
  <input type="number" name="ROTATION_REQUEST_COUNT" min="1" placeholder="10">
165
  </div>
166
  </div>
@@ -187,11 +187,27 @@
187
  </div>
188
  <div class="form-row-inline">
189
  <div class="form-group compact">
190
- <label>心跳间隔(ms) <span class="help-tip" title="SSE心跳间隔,防止CF超时断连">?</span></label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  <input type="number" name="HEARTBEAT_INTERVAL" placeholder="15000">
192
  </div>
193
  <div class="form-group compact">
194
- <label>内存阈值(MB) <span class="help-tip" title="超过此值触发GC清理">?</span></label>
195
  <input type="number" name="MEMORY_THRESHOLD" placeholder="100">
196
  </div>
197
  </div>
@@ -214,6 +230,13 @@
214
  </div>
215
  </div>
216
 
217
- <script src="app.js"></script>
 
 
 
 
 
 
 
218
  </body>
219
  </html>
 
138
  </div>
139
  </div>
140
  <div class="form-group compact highlight">
141
+ <label>思考预算 <span class="help-tip" data-tooltip="思考模型的思考token预算,影响推理深度">?</span></label>
142
  <input type="number" name="DEFAULT_THINKING_BUDGET" placeholder="16000">
143
  </div>
144
  <div class="form-group compact">
 
152
  <h4>🔄 轮询与性能</h4>
153
  <div class="form-row-inline">
154
  <div class="form-group compact">
155
+ <label>策略模式 <span class="help-tip" data-tooltip="均衡负载:每次请求切换Token&#10;额度耗尽:用完额度才切换&#10;自定义次数:指定次数后切换">?</span></label>
156
  <select name="ROTATION_STRATEGY" id="rotationStrategy" onchange="toggleRequestCountInput()">
157
  <option value="round_robin">均衡负载</option>
158
  <option value="quota_exhausted">额度耗尽切换</option>
 
160
  </select>
161
  </div>
162
  <div class="form-group compact" id="requestCountGroup">
163
+ <label>每Token请求次数 <span class="help-tip" data-tooltip="自定义次数模式下,每个Token处理多少次请求后切换">?</span></label>
164
  <input type="number" name="ROTATION_REQUEST_COUNT" min="1" placeholder="10">
165
  </div>
166
  </div>
 
187
  </div>
188
  <div class="form-row-inline">
189
  <div class="form-group compact">
190
+ <label>原生Axios <span class="help-tip" data-tooltip="使用原生axios而非TLS指纹请求器">?</span></label>
191
+ <select name="USE_NATIVE_AXIOS">
192
+ <option value="true">是</option>
193
+ <option value="false">否</option>
194
+ </select>
195
+ </div>
196
+ <div class="form-group compact">
197
+ <label>上下文System <span class="help-tip" data-tooltip="合并开头连续的system消息到SystemInstruction">?</span></label>
198
+ <select name="USE_CONTEXT_SYSTEM_PROMPT">
199
+ <option value="false">否</option>
200
+ <option value="true">是</option>
201
+ </select>
202
+ </div>
203
+ </div>
204
+ <div class="form-row-inline">
205
+ <div class="form-group compact">
206
+ <label>心跳间隔(ms) <span class="help-tip" data-tooltip="SSE心跳间隔,防止CF超时断连">?</span></label>
207
  <input type="number" name="HEARTBEAT_INTERVAL" placeholder="15000">
208
  </div>
209
  <div class="form-group compact">
210
+ <label>内存阈值(MB) <span class="help-tip" data-tooltip="超过此值触发GC清理">?</span></label>
211
  <input type="number" name="MEMORY_THRESHOLD" placeholder="100">
212
  </div>
213
  </div>
 
230
  </div>
231
  </div>
232
 
233
+ <!-- 按依赖顺序加载模块 -->
234
+ <script src="js/utils.js"></script>
235
+ <script src="js/ui.js"></script>
236
+ <script src="js/auth.js"></script>
237
+ <script src="js/quota.js"></script>
238
+ <script src="js/tokens.js"></script>
239
+ <script src="js/config.js"></script>
240
+ <script src="js/main.js"></script>
241
  </body>
242
  </html>
public/js/README.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 前端模块说明
2
+
3
+ 原 `app.js` (1300+ 行) 已拆分为以下模块:
4
+
5
+ ## 模块结构
6
+
7
+ ```
8
+ js/
9
+ ├── utils.js - 工具函数(字体大小、敏感信息隐藏)
10
+ ├── ui.js - UI组件(Toast、Modal、Loading、Tab切换)
11
+ ├── auth.js - 认证相关(登录、登出、OAuth授权)
12
+ ├── tokens.js - Token管理(增删改查、启用禁用、内联编辑)
13
+ ├── quota.js - 额度管理(查看、刷新、缓存、内嵌显示)
14
+ ├── config.js - 配置管理(加载、保存、轮询策略)
15
+ └── main.js - 主入口(初始化、事件绑定)
16
+ ```
17
+
18
+ ## 加载顺序
19
+
20
+ 模块按依赖关系加载(在 `index.html` 中):
21
+
22
+ 1. **utils.js** - 基础工具函数
23
+ 2. **ui.js** - UI组件(依赖 utils)
24
+ 3. **auth.js** - 认证模块(依赖 ui)
25
+ 4. **quota.js** - 额度模块(依赖 auth)
26
+ 5. **tokens.js** - Token模块(依赖 auth、quota、ui)
27
+ 6. **config.js** - 配置模块(依赖 auth、ui)
28
+ 7. **main.js** - 主入口(依赖所有模块)
29
+
30
+ ## 模块职责
31
+
32
+ ### utils.js
33
+ - 字体大小设置和持久化
34
+ - 敏感信息显示/隐藏切换
35
+ - localStorage 管理
36
+
37
+ ### ui.js
38
+ - Toast 提示框
39
+ - Confirm 确认框
40
+ - Loading 加载遮罩
41
+ - Tab 页面切换
42
+
43
+ ### auth.js
44
+ - 用户登录/登出
45
+ - OAuth 授权流程
46
+ - authFetch 封装(自动处理401)
47
+ - Token 认证状态管理
48
+
49
+ ### tokens.js
50
+ - Token 列表加载和渲染
51
+ - Token 增删改查操作
52
+ - 内联字段编辑(projectId、email)
53
+ - Token 详情弹窗
54
+
55
+ ### quota.js
56
+ - 额度数据缓存(5分钟TTL)
57
+ - 内嵌额度摘要显示
58
+ - 额度详情展开/收起
59
+ - 额度弹窗(多账号切换)
60
+ - 强制刷新额度
61
+
62
+ ### config.js
63
+ - 配置加载(.env + config.json)
64
+ - 配置保存(分离敏感/非敏感)
65
+ - 轮询策略管理
66
+ - 轮询状态显示
67
+
68
+ ### main.js
69
+ - 页面初始化
70
+ - 登录表单事件绑定
71
+ - 配置表单事件绑定
72
+ - 自动登录检测
73
+
74
+ ## 全局变量
75
+
76
+ 跨模块共享的全局变量:
77
+
78
+ - `authToken` - 认证令牌(auth.js)
79
+ - `cachedTokens` - Token列表缓存(tokens.js)
80
+ - `currentQuotaToken` - 当前查看的额度Token(quota.js)
81
+ - `quotaCache` - 额度数据缓存对象(quota.js)
82
+ - `sensitiveInfoHidden` - 敏感信息隐藏状态(utils.js)
83
+
84
+ ## 优势
85
+
86
+ 1. **可维护性** - 每个模块职责单一,易于定位和修改
87
+ 2. **可读性** - 文件大小合理(200-400行),代码结构清晰
88
+ 3. **可扩展性** - 新增功能只需修改对应模块
89
+ 4. **可测试性** - 模块独立,便于单元测试
90
+ 5. **协作友好** - 多人开发时减少冲突
91
+
92
+ ## 注意事项
93
+
94
+ 1. 模块间通过全局变量和函数通信
95
+ 2. 保持加载顺序,避免依赖问题
96
+ 3. 修改时注意跨模块调用的函数
public/js/auth.js ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 认证相关:登录、登出、OAuth
2
+
3
+ let authToken = localStorage.getItem('authToken');
4
+ let oauthPort = null;
5
+
6
+ const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
7
+ const SCOPES = [
8
+ 'https://www.googleapis.com/auth/cloud-platform',
9
+ 'https://www.googleapis.com/auth/userinfo.email',
10
+ 'https://www.googleapis.com/auth/userinfo.profile',
11
+ 'https://www.googleapis.com/auth/cclog',
12
+ 'https://www.googleapis.com/auth/experimentsandconfigs'
13
+ ].join(' ');
14
+
15
+ // 封装fetch,自动处理401
16
+ const authFetch = async (url, options = {}) => {
17
+ const response = await fetch(url, options);
18
+ if (response.status === 401) {
19
+ silentLogout();
20
+ showToast('登录已过期,请重新登录', 'warning');
21
+ throw new Error('Unauthorized');
22
+ }
23
+ return response;
24
+ };
25
+
26
+ function showMainContent() {
27
+ document.getElementById('loginForm').classList.add('hidden');
28
+ document.getElementById('mainContent').classList.remove('hidden');
29
+ }
30
+
31
+ function silentLogout() {
32
+ localStorage.removeItem('authToken');
33
+ authToken = null;
34
+ document.getElementById('loginForm').classList.remove('hidden');
35
+ document.getElementById('mainContent').classList.add('hidden');
36
+ }
37
+
38
+ async function logout() {
39
+ const confirmed = await showConfirm('确定要退出登录吗?', '退出确认');
40
+ if (!confirmed) return;
41
+
42
+ silentLogout();
43
+ showToast('已退出登录', 'info');
44
+ }
45
+
46
+ function getOAuthUrl() {
47
+ if (!oauthPort) oauthPort = Math.floor(Math.random() * 10000) + 50000;
48
+ const redirectUri = `http://localhost:${oauthPort}/oauth-callback`;
49
+ return `https://accounts.google.com/o/oauth2/v2/auth?` +
50
+ `access_type=offline&client_id=${CLIENT_ID}&prompt=consent&` +
51
+ `redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&` +
52
+ `scope=${encodeURIComponent(SCOPES)}&state=${Date.now()}`;
53
+ }
54
+
55
+ function openOAuthWindow() {
56
+ window.open(getOAuthUrl(), '_blank');
57
+ }
58
+
59
+ function copyOAuthUrl() {
60
+ const url = getOAuthUrl();
61
+ navigator.clipboard.writeText(url).then(() => {
62
+ showToast('授权链接已复制', 'success');
63
+ }).catch(() => {
64
+ showToast('复制失败', 'error');
65
+ });
66
+ }
67
+
68
+ function showOAuthModal() {
69
+ showToast('点击后请在新窗口完成授权', 'info');
70
+ const modal = document.createElement('div');
71
+ modal.className = 'modal form-modal';
72
+ modal.innerHTML = `
73
+ <div class="modal-content">
74
+ <div class="modal-title">🔐 OAuth授权登录</div>
75
+ <div class="oauth-steps">
76
+ <p><strong>📝 授权流程:</strong></p>
77
+ <p>1️⃣ 点击下方按钮打开Google授权页面</p>
78
+ <p>2️⃣ 完成授权后,复制浏览器地址栏的完整URL</p>
79
+ <p>3️⃣ 粘贴URL到下方输入框并提交</p>
80
+ </div>
81
+ <div style="display: flex; gap: 8px; margin-bottom: 12px;">
82
+ <button type="button" onclick="openOAuthWindow()" class="btn btn-success" style="flex: 1;">🔐 打开授权页面</button>
83
+ <button type="button" onclick="copyOAuthUrl()" class="btn btn-info" style="flex: 1;">📋 复制授权链接</button>
84
+ </div>
85
+ <input type="text" id="modalCallbackUrl" placeholder="粘贴完整的回调URL (http://localhost:xxxxx/oauth-callback?code=...)">
86
+ <div class="modal-actions">
87
+ <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
88
+ <button class="btn btn-success" onclick="processOAuthCallbackModal()">✅ 提交</button>
89
+ </div>
90
+ </div>
91
+ `;
92
+ document.body.appendChild(modal);
93
+ modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
94
+ }
95
+
96
+ async function processOAuthCallbackModal() {
97
+ const modal = document.querySelector('.form-modal');
98
+ const callbackUrl = document.getElementById('modalCallbackUrl').value.trim();
99
+ if (!callbackUrl) {
100
+ showToast('请输入回调URL', 'warning');
101
+ return;
102
+ }
103
+
104
+ showLoading('正在处理授权...');
105
+
106
+ try {
107
+ const url = new URL(callbackUrl);
108
+ const code = url.searchParams.get('code');
109
+ const port = new URL(url.origin).port || (url.protocol === 'https:' ? 443 : 80);
110
+
111
+ if (!code) {
112
+ hideLoading();
113
+ showToast('URL中未找到授权码', 'error');
114
+ return;
115
+ }
116
+
117
+ const response = await authFetch('/admin/oauth/exchange', {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ 'Authorization': `Bearer ${authToken}`
122
+ },
123
+ body: JSON.stringify({ code, port })
124
+ });
125
+
126
+ const result = await response.json();
127
+ if (result.success) {
128
+ const account = result.data;
129
+ const addResponse = await authFetch('/admin/tokens', {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': 'application/json',
133
+ 'Authorization': `Bearer ${authToken}`
134
+ },
135
+ body: JSON.stringify(account)
136
+ });
137
+
138
+ const addResult = await addResponse.json();
139
+ hideLoading();
140
+ if (addResult.success) {
141
+ modal.remove();
142
+ const message = result.fallbackMode
143
+ ? 'Token添加成功(该账号无资格,已自动使用随机ProjectId)'
144
+ : 'Token添加成功';
145
+ showToast(message, result.fallbackMode ? 'warning' : 'success');
146
+ loadTokens();
147
+ } else {
148
+ showToast('添加失败: ' + addResult.message, 'error');
149
+ }
150
+ } else {
151
+ hideLoading();
152
+ showToast('交换失败: ' + result.message, 'error');
153
+ }
154
+ } catch (error) {
155
+ hideLoading();
156
+ showToast('处理失败: ' + error.message, 'error');
157
+ }
158
+ }
public/js/config.js ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 配置管理:加载、保存
2
+
3
+ function toggleRequestCountInput() {
4
+ const strategy = document.getElementById('rotationStrategy').value;
5
+ const requestCountGroup = document.getElementById('requestCountGroup');
6
+ if (requestCountGroup) {
7
+ requestCountGroup.style.display = strategy === 'request_count' ? 'block' : 'none';
8
+ }
9
+ }
10
+
11
+ async function loadRotationStatus() {
12
+ try {
13
+ const response = await authFetch('/admin/rotation', {
14
+ headers: { 'Authorization': `Bearer ${authToken}` }
15
+ });
16
+ const data = await response.json();
17
+ if (data.success) {
18
+ const { strategy, requestCount, currentIndex } = data.data;
19
+ const strategyNames = {
20
+ 'round_robin': '均衡负载',
21
+ 'quota_exhausted': '额度耗尽切换',
22
+ 'request_count': '自定义次数'
23
+ };
24
+ const statusEl = document.getElementById('currentRotationInfo');
25
+ if (statusEl) {
26
+ let statusText = `${strategyNames[strategy] || strategy}`;
27
+ if (strategy === 'request_count') {
28
+ statusText += ` (每${requestCount}次)`;
29
+ }
30
+ statusText += ` | 当前索引: ${currentIndex}`;
31
+ statusEl.textContent = statusText;
32
+ }
33
+ }
34
+ } catch (error) {
35
+ console.error('加载轮询状态失败:', error);
36
+ }
37
+ }
38
+
39
+ async function loadConfig() {
40
+ try {
41
+ const response = await authFetch('/admin/config', {
42
+ headers: { 'Authorization': `Bearer ${authToken}` }
43
+ });
44
+ const data = await response.json();
45
+ if (data.success) {
46
+ const form = document.getElementById('configForm');
47
+ const { env, json } = data.data;
48
+
49
+ const serverInfo = document.getElementById('serverInfo');
50
+ if (serverInfo && json.server) {
51
+ serverInfo.textContent = `${json.server.host || '0.0.0.0'}:${json.server.port || 8045}`;
52
+ }
53
+
54
+ Object.entries(env).forEach(([key, value]) => {
55
+ const input = form.elements[key];
56
+ if (input) input.value = value || '';
57
+ });
58
+
59
+ if (json.server) {
60
+ if (form.elements['PORT']) form.elements['PORT'].value = json.server.port || '';
61
+ if (form.elements['HOST']) form.elements['HOST'].value = json.server.host || '';
62
+ if (form.elements['MAX_REQUEST_SIZE']) form.elements['MAX_REQUEST_SIZE'].value = json.server.maxRequestSize || '';
63
+ if (form.elements['HEARTBEAT_INTERVAL']) form.elements['HEARTBEAT_INTERVAL'].value = json.server.heartbeatInterval || '';
64
+ if (form.elements['MEMORY_THRESHOLD']) form.elements['MEMORY_THRESHOLD'].value = json.server.memoryThreshold || '';
65
+ }
66
+ if (json.defaults) {
67
+ if (form.elements['DEFAULT_TEMPERATURE']) form.elements['DEFAULT_TEMPERATURE'].value = json.defaults.temperature ?? '';
68
+ if (form.elements['DEFAULT_TOP_P']) form.elements['DEFAULT_TOP_P'].value = json.defaults.topP ?? '';
69
+ if (form.elements['DEFAULT_TOP_K']) form.elements['DEFAULT_TOP_K'].value = json.defaults.topK ?? '';
70
+ if (form.elements['DEFAULT_MAX_TOKENS']) form.elements['DEFAULT_MAX_TOKENS'].value = json.defaults.maxTokens ?? '';
71
+ if (form.elements['DEFAULT_THINKING_BUDGET']) form.elements['DEFAULT_THINKING_BUDGET'].value = json.defaults.thinkingBudget ?? '';
72
+ }
73
+ if (json.other) {
74
+ if (form.elements['TIMEOUT']) form.elements['TIMEOUT'].value = json.other.timeout ?? '';
75
+ if (form.elements['RETRY_TIMES']) form.elements['RETRY_TIMES'].value = json.other.retryTimes ?? '';
76
+ if (form.elements['SKIP_PROJECT_ID_FETCH']) form.elements['SKIP_PROJECT_ID_FETCH'].value = json.other.skipProjectIdFetch ? 'true' : 'false';
77
+ if (form.elements['USE_NATIVE_AXIOS']) form.elements['USE_NATIVE_AXIOS'].value = json.other.useNativeAxios === false ? 'false' : 'true';
78
+ if (form.elements['USE_CONTEXT_SYSTEM_PROMPT']) form.elements['USE_CONTEXT_SYSTEM_PROMPT'].value = json.other.useContextSystemPrompt ? 'true' : 'false';
79
+ }
80
+ if (json.rotation) {
81
+ if (form.elements['ROTATION_STRATEGY']) {
82
+ form.elements['ROTATION_STRATEGY'].value = json.rotation.strategy || 'round_robin';
83
+ }
84
+ if (form.elements['ROTATION_REQUEST_COUNT']) {
85
+ form.elements['ROTATION_REQUEST_COUNT'].value = json.rotation.requestCount || 10;
86
+ }
87
+ toggleRequestCountInput();
88
+ }
89
+
90
+ loadRotationStatus();
91
+ }
92
+ } catch (error) {
93
+ showToast('加载配置失败: ' + error.message, 'error');
94
+ }
95
+ }
96
+
97
+ async function saveConfig(e) {
98
+ e.preventDefault();
99
+ const formData = new FormData(e.target);
100
+ const allConfig = Object.fromEntries(formData);
101
+
102
+ const sensitiveKeys = ['API_KEY', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'JWT_SECRET', 'PROXY', 'SYSTEM_INSTRUCTION', 'IMAGE_BASE_URL'];
103
+ const envConfig = {};
104
+ const jsonConfig = {
105
+ server: {},
106
+ api: {},
107
+ defaults: {},
108
+ other: {},
109
+ rotation: {}
110
+ };
111
+
112
+ Object.entries(allConfig).forEach(([key, value]) => {
113
+ if (sensitiveKeys.includes(key)) {
114
+ envConfig[key] = value;
115
+ } else {
116
+ if (key === 'PORT') jsonConfig.server.port = parseInt(value) || undefined;
117
+ else if (key === 'HOST') jsonConfig.server.host = value || undefined;
118
+ else if (key === 'MAX_REQUEST_SIZE') jsonConfig.server.maxRequestSize = value || undefined;
119
+ else if (key === 'HEARTBEAT_INTERVAL') jsonConfig.server.heartbeatInterval = parseInt(value) || undefined;
120
+ else if (key === 'MEMORY_THRESHOLD') jsonConfig.server.memoryThreshold = parseInt(value) || undefined;
121
+ else if (key === 'DEFAULT_TEMPERATURE') jsonConfig.defaults.temperature = parseFloat(value) || undefined;
122
+ else if (key === 'DEFAULT_TOP_P') jsonConfig.defaults.topP = parseFloat(value) || undefined;
123
+ else if (key === 'DEFAULT_TOP_K') jsonConfig.defaults.topK = parseInt(value) || undefined;
124
+ else if (key === 'DEFAULT_MAX_TOKENS') jsonConfig.defaults.maxTokens = parseInt(value) || undefined;
125
+ else if (key === 'DEFAULT_THINKING_BUDGET') {
126
+ const num = parseInt(value);
127
+ jsonConfig.defaults.thinkingBudget = Number.isNaN(num) ? undefined : num;
128
+ }
129
+ else if (key === 'TIMEOUT') jsonConfig.other.timeout = parseInt(value) || undefined;
130
+ else if (key === 'RETRY_TIMES') {
131
+ const num = parseInt(value);
132
+ jsonConfig.other.retryTimes = Number.isNaN(num) ? undefined : num;
133
+ }
134
+ else if (key === 'SKIP_PROJECT_ID_FETCH') jsonConfig.other.skipProjectIdFetch = value === 'true';
135
+ else if (key === 'USE_NATIVE_AXIOS') jsonConfig.other.useNativeAxios = value !== 'false';
136
+ else if (key === 'USE_CONTEXT_SYSTEM_PROMPT') jsonConfig.other.useContextSystemPrompt = value === 'true';
137
+ else if (key === 'ROTATION_STRATEGY') jsonConfig.rotation.strategy = value || undefined;
138
+ else if (key === 'ROTATION_REQUEST_COUNT') jsonConfig.rotation.requestCount = parseInt(value) || undefined;
139
+ else envConfig[key] = value;
140
+ }
141
+ });
142
+
143
+ Object.keys(jsonConfig).forEach(section => {
144
+ Object.keys(jsonConfig[section]).forEach(key => {
145
+ if (jsonConfig[section][key] === undefined) {
146
+ delete jsonConfig[section][key];
147
+ }
148
+ });
149
+ if (Object.keys(jsonConfig[section]).length === 0) {
150
+ delete jsonConfig[section];
151
+ }
152
+ });
153
+
154
+ showLoading('正在保存配置...');
155
+ try {
156
+ const response = await authFetch('/admin/config', {
157
+ method: 'PUT',
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ 'Authorization': `Bearer ${authToken}`
161
+ },
162
+ body: JSON.stringify({ env: envConfig, json: jsonConfig })
163
+ });
164
+
165
+ const data = await response.json();
166
+
167
+ if (jsonConfig.rotation && Object.keys(jsonConfig.rotation).length > 0) {
168
+ await authFetch('/admin/rotation', {
169
+ method: 'PUT',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ 'Authorization': `Bearer ${authToken}`
173
+ },
174
+ body: JSON.stringify(jsonConfig.rotation)
175
+ });
176
+ }
177
+
178
+ hideLoading();
179
+ if (data.success) {
180
+ showToast('配置已保存', 'success');
181
+ loadConfig();
182
+ } else {
183
+ showToast(data.message || '保存失败', 'error');
184
+ }
185
+ } catch (error) {
186
+ hideLoading();
187
+ showToast('保存失败: ' + error.message, 'error');
188
+ }
189
+ }
public/js/main.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 主入口:初始化和事件绑定
2
+
3
+ // 页面加载时初始化
4
+ initFontSize();
5
+ initSensitiveInfo();
6
+
7
+ // 如果已登录,显示主内容
8
+ if (authToken) {
9
+ showMainContent();
10
+ loadTokens();
11
+ loadConfig();
12
+ }
13
+
14
+ // 登录表单提交
15
+ document.getElementById('login').addEventListener('submit', async (e) => {
16
+ e.preventDefault();
17
+ const btn = e.target.querySelector('button[type="submit"]');
18
+ if (btn.disabled) return;
19
+
20
+ const username = document.getElementById('username').value;
21
+ const password = document.getElementById('password').value;
22
+
23
+ btn.disabled = true;
24
+ btn.classList.add('loading');
25
+ const originalText = btn.textContent;
26
+ btn.textContent = '登录中';
27
+
28
+ try {
29
+ const response = await fetch('/admin/login', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ username, password })
33
+ });
34
+
35
+ const data = await response.json();
36
+ if (data.success) {
37
+ authToken = data.token;
38
+ localStorage.setItem('authToken', authToken);
39
+ showToast('登录成功', 'success');
40
+ showMainContent();
41
+ loadTokens();
42
+ loadConfig();
43
+ } else {
44
+ showToast(data.message || '用户名或密码错误', 'error');
45
+ }
46
+ } catch (error) {
47
+ showToast('登录失败: ' + error.message, 'error');
48
+ } finally {
49
+ btn.disabled = false;
50
+ btn.classList.remove('loading');
51
+ btn.textContent = originalText;
52
+ }
53
+ });
54
+
55
+ // 配置表单提交
56
+ document.getElementById('configForm').addEventListener('submit', saveConfig);
public/js/quota.js ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 额度管理:查看、刷新、缓存
2
+
3
+ let currentQuotaToken = null;
4
+
5
+ const quotaCache = {
6
+ data: {},
7
+ ttl: 5 * 60 * 1000,
8
+
9
+ get(refreshToken) {
10
+ const cached = this.data[refreshToken];
11
+ if (!cached) return null;
12
+ if (Date.now() - cached.timestamp > this.ttl) {
13
+ delete this.data[refreshToken];
14
+ return null;
15
+ }
16
+ return cached.data;
17
+ },
18
+
19
+ set(refreshToken, data) {
20
+ this.data[refreshToken] = { data, timestamp: Date.now() };
21
+ },
22
+
23
+ clear(refreshToken) {
24
+ if (refreshToken) {
25
+ delete this.data[refreshToken];
26
+ } else {
27
+ this.data = {};
28
+ }
29
+ }
30
+ };
31
+
32
+ async function loadTokenQuotaSummary(refreshToken) {
33
+ const cardId = refreshToken.substring(0, 8);
34
+ const summaryEl = document.getElementById(`quota-summary-${cardId}`);
35
+ if (!summaryEl) return;
36
+
37
+ const cached = quotaCache.get(refreshToken);
38
+ if (cached) {
39
+ renderQuotaSummary(summaryEl, cached);
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
45
+ headers: { 'Authorization': `Bearer ${authToken}` }
46
+ });
47
+ const data = await response.json();
48
+
49
+ if (data.success && data.data && data.data.models) {
50
+ quotaCache.set(refreshToken, data.data);
51
+ renderQuotaSummary(summaryEl, data.data);
52
+ } else {
53
+ const errMsg = data.message || '未知错误';
54
+ summaryEl.innerHTML = `<span class="quota-summary-error">📊 ${errMsg}</span>`;
55
+ }
56
+ } catch (error) {
57
+ if (error.message !== 'Unauthorized') {
58
+ console.error('加载额度摘要失败:', error);
59
+ summaryEl.innerHTML = `<span class="quota-summary-error">📊 加载失败</span>`;
60
+ }
61
+ }
62
+ }
63
+
64
+ function renderQuotaSummary(summaryEl, quotaData) {
65
+ const models = quotaData.models;
66
+ const modelEntries = Object.entries(models);
67
+
68
+ if (modelEntries.length === 0) {
69
+ summaryEl.textContent = '📊 暂无额度';
70
+ return;
71
+ }
72
+
73
+ let minModel = modelEntries[0][0];
74
+ let minQuota = modelEntries[0][1];
75
+ modelEntries.forEach(([modelId, quota]) => {
76
+ if (quota.remaining < minQuota.remaining) {
77
+ minQuota = quota;
78
+ minModel = modelId;
79
+ }
80
+ });
81
+
82
+ const percentage = minQuota.remaining * 100;
83
+ const percentageText = `${percentage.toFixed(2)}%`;
84
+ const shortName = minModel.replace('models/', '').replace('publishers/google/', '').split('/').pop();
85
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
86
+
87
+ summaryEl.innerHTML = `
88
+ <span class="quota-summary-icon">📊</span>
89
+ <span class="quota-summary-model" title="${minModel}">${shortName}</span>
90
+ <span class="quota-summary-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
91
+ <span class="quota-summary-pct">${percentageText}</span>
92
+ `;
93
+ }
94
+
95
+ async function toggleQuotaExpand(cardId, refreshToken) {
96
+ const detailEl = document.getElementById(`quota-detail-${cardId}`);
97
+ const toggleEl = document.getElementById(`quota-toggle-${cardId}`);
98
+ if (!detailEl || !toggleEl) return;
99
+
100
+ const isHidden = detailEl.classList.contains('hidden');
101
+
102
+ if (isHidden) {
103
+ detailEl.classList.remove('hidden');
104
+ toggleEl.textContent = '▲';
105
+
106
+ if (!detailEl.dataset.loaded) {
107
+ detailEl.innerHTML = '<div class="quota-loading-small">加载中...</div>';
108
+ await loadQuotaDetail(cardId, refreshToken);
109
+ detailEl.dataset.loaded = 'true';
110
+ }
111
+ } else {
112
+ detailEl.classList.add('hidden');
113
+ toggleEl.textContent = '▼';
114
+ }
115
+ }
116
+
117
+ async function loadQuotaDetail(cardId, refreshToken) {
118
+ const detailEl = document.getElementById(`quota-detail-${cardId}`);
119
+ if (!detailEl) return;
120
+
121
+ try {
122
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas`, {
123
+ headers: { 'Authorization': `Bearer ${authToken}` }
124
+ });
125
+ const data = await response.json();
126
+
127
+ if (data.success && data.data && data.data.models) {
128
+ const models = data.data.models;
129
+ const modelEntries = Object.entries(models);
130
+
131
+ if (modelEntries.length === 0) {
132
+ detailEl.innerHTML = '<div class="quota-empty-small">暂无额度信息</div>';
133
+ return;
134
+ }
135
+
136
+ const grouped = { claude: [], gemini: [], other: [] };
137
+ modelEntries.forEach(([modelId, quota]) => {
138
+ const item = { modelId, quota };
139
+ if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
140
+ else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
141
+ else grouped.other.push(item);
142
+ });
143
+
144
+ let html = '<div class="quota-detail-grid">';
145
+
146
+ const renderGroup = (items, icon) => {
147
+ if (items.length === 0) return '';
148
+ let groupHtml = '';
149
+ items.forEach(({ modelId, quota }) => {
150
+ const percentage = quota.remaining * 100;
151
+ const percentageText = `${percentage.toFixed(2)}%`;
152
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
153
+ const shortName = modelId.replace('models/', '').replace('publishers/google/', '').split('/').pop();
154
+ groupHtml += `
155
+ <div class="quota-detail-row" title="${modelId} - 重置: ${quota.resetTime}">
156
+ <span class="quota-detail-icon">${icon}</span>
157
+ <span class="quota-detail-name">${shortName}</span>
158
+ <span class="quota-detail-bar"><span style="width:${percentage}%;background:${barColor}"></span></span>
159
+ <span class="quota-detail-pct">${percentageText}</span>
160
+ </div>
161
+ `;
162
+ });
163
+ return groupHtml;
164
+ };
165
+
166
+ html += renderGroup(grouped.claude, '🤖');
167
+ html += renderGroup(grouped.gemini, '💎');
168
+ html += renderGroup(grouped.other, '🔧');
169
+ html += '</div>';
170
+ html += `<button class="btn btn-info btn-xs quota-refresh-btn" onclick="refreshInlineQuota('${cardId}', '${refreshToken}')">🔄 刷新额度</button>`;
171
+
172
+ detailEl.innerHTML = html;
173
+ } else {
174
+ const errMsg = data.message || '未知错误';
175
+ detailEl.innerHTML = `<div class="quota-error-small">加载失败: ${errMsg}</div>`;
176
+ }
177
+ } catch (error) {
178
+ if (error.message !== 'Unauthorized') {
179
+ detailEl.innerHTML = `<div class="quota-error-small">网络错误</div>`;
180
+ }
181
+ }
182
+ }
183
+
184
+ async function refreshInlineQuota(cardId, refreshToken) {
185
+ const detailEl = document.getElementById(`quota-detail-${cardId}`);
186
+ const summaryEl = document.getElementById(`quota-summary-${cardId}`);
187
+
188
+ if (detailEl) detailEl.innerHTML = '<div class="quota-loading-small">刷新中...</div>';
189
+ if (summaryEl) summaryEl.textContent = '📊 刷新中...';
190
+
191
+ quotaCache.clear(refreshToken);
192
+
193
+ try {
194
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}/quotas?refresh=true`, {
195
+ headers: { 'Authorization': `Bearer ${authToken}` }
196
+ });
197
+ const data = await response.json();
198
+ if (data.success && data.data) {
199
+ quotaCache.set(refreshToken, data.data);
200
+ }
201
+ } catch (e) {}
202
+
203
+ await loadTokenQuotaSummary(refreshToken);
204
+ await loadQuotaDetail(cardId, refreshToken);
205
+ }
206
+
207
+ async function showQuotaModal(refreshToken) {
208
+ currentQuotaToken = refreshToken;
209
+
210
+ const activeIndex = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
211
+
212
+ const emailTabs = cachedTokens.map((t, index) => {
213
+ const email = t.email || '未知';
214
+ const shortEmail = email.length > 20 ? email.substring(0, 17) + '...' : email;
215
+ const isActive = index === activeIndex;
216
+ return `<button type="button" class="quota-tab${isActive ? ' active' : ''}" data-index="${index}" onclick="switchQuotaAccountByIndex(${index})" title="${email}">${shortEmail}</button>`;
217
+ }).join('');
218
+
219
+ const modal = document.createElement('div');
220
+ modal.className = 'modal';
221
+ modal.id = 'quotaModal';
222
+ modal.innerHTML = `
223
+ <div class="modal-content modal-xl">
224
+ <div class="quota-modal-header">
225
+ <div class="modal-title">📊 模型额度</div>
226
+ <div class="quota-update-time" id="quotaUpdateTime"></div>
227
+ </div>
228
+ <div class="quota-tabs" id="quotaEmailList">
229
+ ${emailTabs}
230
+ </div>
231
+ <div id="quotaContent" class="quota-container">
232
+ <div class="quota-loading">加载中...</div>
233
+ </div>
234
+ <div class="modal-actions">
235
+ <button class="btn btn-info btn-sm" id="quotaRefreshBtn" onclick="refreshQuotaData()">🔄 刷新</button>
236
+ <button class="btn btn-secondary btn-sm" onclick="this.closest('.modal').remove()">关闭</button>
237
+ </div>
238
+ </div>
239
+ `;
240
+ document.body.appendChild(modal);
241
+ modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
242
+
243
+ await loadQuotaData(refreshToken);
244
+
245
+ const tabsContainer = document.getElementById('quotaEmailList');
246
+ if (tabsContainer) {
247
+ tabsContainer.addEventListener('wheel', (e) => {
248
+ if (e.deltaY !== 0) {
249
+ e.preventDefault();
250
+ tabsContainer.scrollLeft += e.deltaY;
251
+ }
252
+ }, { passive: false });
253
+ }
254
+ }
255
+
256
+ async function switchQuotaAccountByIndex(index) {
257
+ if (index < 0 || index >= cachedTokens.length) return;
258
+
259
+ const token = cachedTokens[index];
260
+ currentQuotaToken = token.refresh_token;
261
+
262
+ document.querySelectorAll('.quota-tab').forEach((tab, i) => {
263
+ if (i === index) {
264
+ tab.classList.add('active');
265
+ } else {
266
+ tab.classList.remove('active');
267
+ }
268
+ });
269
+
270
+ await loadQuotaData(token.refresh_token);
271
+ }
272
+
273
+ async function switchQuotaAccount(refreshToken) {
274
+ const index = cachedTokens.findIndex(t => t.refresh_token === refreshToken);
275
+ if (index >= 0) {
276
+ await switchQuotaAccountByIndex(index);
277
+ }
278
+ }
279
+
280
+ async function loadQuotaData(refreshToken, forceRefresh = false) {
281
+ const quotaContent = document.getElementById('quotaContent');
282
+ if (!quotaContent) return;
283
+
284
+ const refreshBtn = document.getElementById('quotaRefreshBtn');
285
+ if (refreshBtn) {
286
+ refreshBtn.disabled = true;
287
+ refreshBtn.textContent = '⏳ 加载中...';
288
+ }
289
+
290
+ if (!forceRefresh) {
291
+ const cached = quotaCache.get(refreshToken);
292
+ if (cached) {
293
+ renderQuotaModal(quotaContent, cached);
294
+ if (refreshBtn) {
295
+ refreshBtn.disabled = false;
296
+ refreshBtn.textContent = '🔄 刷新';
297
+ }
298
+ return;
299
+ }
300
+ } else {
301
+ quotaCache.clear(refreshToken);
302
+ }
303
+
304
+ quotaContent.innerHTML = '<div class="quota-loading">加载中...</div>';
305
+
306
+ try {
307
+ const url = `/admin/tokens/${encodeURIComponent(refreshToken)}/quotas${forceRefresh ? '?refresh=true' : ''}`;
308
+ const response = await fetch(url, {
309
+ headers: { 'Authorization': `Bearer ${authToken}` }
310
+ });
311
+
312
+ const data = await response.json();
313
+
314
+ if (data.success) {
315
+ quotaCache.set(refreshToken, data.data);
316
+ renderQuotaModal(quotaContent, data.data);
317
+ } else {
318
+ quotaContent.innerHTML = `<div class="quota-error">加载失败: ${data.message}</div>`;
319
+ }
320
+ } catch (error) {
321
+ if (quotaContent) {
322
+ quotaContent.innerHTML = `<div class="quota-error">加载失败: ${error.message}</div>`;
323
+ }
324
+ } finally {
325
+ if (refreshBtn) {
326
+ refreshBtn.disabled = false;
327
+ refreshBtn.textContent = '🔄 刷新';
328
+ }
329
+ }
330
+ }
331
+
332
+ async function refreshQuotaData() {
333
+ if (currentQuotaToken) {
334
+ await loadQuotaData(currentQuotaToken, true);
335
+ }
336
+ }
337
+
338
+ function renderQuotaModal(quotaContent, quotaData) {
339
+ const models = quotaData.models;
340
+
341
+ const updateTimeEl = document.getElementById('quotaUpdateTime');
342
+ if (updateTimeEl && quotaData.lastUpdated) {
343
+ const lastUpdated = new Date(quotaData.lastUpdated).toLocaleString('zh-CN', {
344
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
345
+ });
346
+ updateTimeEl.textContent = `更新于 ${lastUpdated}`;
347
+ }
348
+
349
+ if (Object.keys(models).length === 0) {
350
+ quotaContent.innerHTML = '<div class="quota-empty">暂无额度信息</div>';
351
+ return;
352
+ }
353
+
354
+ const grouped = { claude: [], gemini: [], other: [] };
355
+ Object.entries(models).forEach(([modelId, quota]) => {
356
+ const item = { modelId, quota };
357
+ if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
358
+ else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
359
+ else grouped.other.push(item);
360
+ });
361
+
362
+ let html = '';
363
+
364
+ const renderGroup = (items, title) => {
365
+ if (items.length === 0) return '';
366
+ let groupHtml = `<div class="quota-group-title">${title}</div><div class="quota-grid">`;
367
+ items.forEach(({ modelId, quota }) => {
368
+ const percentage = quota.remaining * 100;
369
+ const percentageText = `${percentage.toFixed(2)}%`;
370
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
371
+ const shortName = modelId.replace('models/', '').replace('publishers/google/', '');
372
+ groupHtml += `
373
+ <div class="quota-item">
374
+ <div class="quota-model-name" title="${modelId}">${shortName}</div>
375
+ <div class="quota-bar-container">
376
+ <div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
377
+ </div>
378
+ <div class="quota-info-row">
379
+ <span class="quota-reset">重置: ${quota.resetTime}</span>
380
+ <span class="quota-percentage">${percentageText}</span>
381
+ </div>
382
+ </div>
383
+ `;
384
+ });
385
+ groupHtml += '</div>';
386
+ return groupHtml;
387
+ };
388
+
389
+ html += renderGroup(grouped.claude, '🤖 Claude');
390
+ html += renderGroup(grouped.gemini, '💎 Gemini');
391
+ html += renderGroup(grouped.other, '🔧 其他');
392
+
393
+ quotaContent.innerHTML = html;
394
+ }
public/js/tokens.js ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Token管理:增删改查、启用禁用
2
+
3
+ let cachedTokens = [];
4
+
5
+ async function loadTokens() {
6
+ try {
7
+ const response = await authFetch('/admin/tokens', {
8
+ headers: { 'Authorization': `Bearer ${authToken}` }
9
+ });
10
+
11
+ const data = await response.json();
12
+ if (data.success) {
13
+ renderTokens(data.data);
14
+ } else {
15
+ showToast('加载失败: ' + (data.message || '未知错误'), 'error');
16
+ }
17
+ } catch (error) {
18
+ showToast('加载Token失败: ' + error.message, 'error');
19
+ }
20
+ }
21
+
22
+ function renderTokens(tokens) {
23
+ cachedTokens = tokens;
24
+
25
+ document.getElementById('totalTokens').textContent = tokens.length;
26
+ document.getElementById('enabledTokens').textContent = tokens.filter(t => t.enable).length;
27
+ document.getElementById('disabledTokens').textContent = tokens.filter(t => !t.enable).length;
28
+
29
+ const tokenList = document.getElementById('tokenList');
30
+ if (tokens.length === 0) {
31
+ tokenList.innerHTML = `
32
+ <div class="empty-state">
33
+ <div class="empty-state-icon">📦</div>
34
+ <div class="empty-state-text">暂无Token</div>
35
+ <div class="empty-state-hint">点击上方OAuth按钮添加Token</div>
36
+ </div>
37
+ `;
38
+ return;
39
+ }
40
+
41
+ tokenList.innerHTML = tokens.map(token => {
42
+ const expireTime = new Date(token.timestamp + token.expires_in * 1000);
43
+ const isExpired = expireTime < new Date();
44
+ const expireStr = expireTime.toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
45
+ const cardId = token.refresh_token.substring(0, 8);
46
+
47
+ return `
48
+ <div class="token-card ${!token.enable ? 'disabled' : ''} ${isExpired ? 'expired' : ''}">
49
+ <div class="token-header">
50
+ <span class="status ${token.enable ? 'enabled' : 'disabled'}">
51
+ ${token.enable ? '✅ 启用' : '❌ 禁用'}
52
+ </span>
53
+ <div class="token-header-right">
54
+ <button class="btn-icon" onclick="showTokenDetail('${token.refresh_token}')" title="编辑全部">✏️</button>
55
+ <span class="token-id">#${token.refresh_token.substring(0, 6)}</span>
56
+ </div>
57
+ </div>
58
+ <div class="token-info">
59
+ <div class="info-row">
60
+ <span class="info-label">🎫</span>
61
+ <span class="info-value sensitive-info" title="${token.access_token_suffix}">${token.access_token_suffix}</span>
62
+ </div>
63
+ <div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'projectId', '${(token.projectId || '').replace(/'/g, "\\'")}')" title="点击编辑">
64
+ <span class="info-label">📦</span>
65
+ <span class="info-value sensitive-info">${token.projectId || '点击设置'}</span>
66
+ <span class="info-edit-icon">✏️</span>
67
+ </div>
68
+ <div class="info-row editable" onclick="editField(event, '${token.refresh_token}', 'email', '${(token.email || '').replace(/'/g, "\\'")}')" title="点击编辑">
69
+ <span class="info-label">📧</span>
70
+ <span class="info-value sensitive-info">${token.email || '点击设置'}</span>
71
+ <span class="info-edit-icon">✏️</span>
72
+ </div>
73
+ <div class="info-row ${isExpired ? 'expired-text' : ''}">
74
+ <span class="info-label">⏰</span>
75
+ <span class="info-value">${expireStr}${isExpired ? ' (已过期)' : ''}</span>
76
+ </div>
77
+ </div>
78
+ <div class="token-quota-inline" id="quota-inline-${cardId}">
79
+ <div class="quota-inline-header" onclick="toggleQuotaExpand('${cardId}', '${token.refresh_token}')">
80
+ <span class="quota-inline-summary" id="quota-summary-${cardId}">📊 加载中...</span>
81
+ <span class="quota-inline-toggle" id="quota-toggle-${cardId}">▼</span>
82
+ </div>
83
+ <div class="quota-inline-detail hidden" id="quota-detail-${cardId}"></div>
84
+ </div>
85
+ <div class="token-actions">
86
+ <button class="btn btn-info btn-xs" onclick="showQuotaModal('${token.refresh_token}')" title="查看额度">📊 详情</button>
87
+ <button class="btn ${token.enable ? 'btn-warning' : 'btn-success'} btn-xs" onclick="toggleToken('${token.refresh_token}', ${!token.enable})" title="${token.enable ? '禁用' : '启用'}">
88
+ ${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
89
+ </button>
90
+ <button class="btn btn-danger btn-xs" onclick="deleteToken('${token.refresh_token}')" title="删除">🗑️ 删除</button>
91
+ </div>
92
+ </div>
93
+ `}).join('');
94
+
95
+ tokens.forEach(token => {
96
+ loadTokenQuotaSummary(token.refresh_token);
97
+ });
98
+
99
+ updateSensitiveInfoDisplay();
100
+ }
101
+
102
+ function showManualModal() {
103
+ const modal = document.createElement('div');
104
+ modal.className = 'modal form-modal';
105
+ modal.innerHTML = `
106
+ <div class="modal-content">
107
+ <div class="modal-title">✏️ 手动填入Token</div>
108
+ <div class="form-row">
109
+ <input type="text" id="modalAccessToken" placeholder="Access Token (必填)">
110
+ <input type="text" id="modalRefreshToken" placeholder="Refresh Token (必填)">
111
+ <input type="number" id="modalExpiresIn" placeholder="过期时间(秒)" value="3599">
112
+ </div>
113
+ <p style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 12px;">💡 过期时间默认3599秒(约1小时)</p>
114
+ <div class="modal-actions">
115
+ <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
116
+ <button class="btn btn-success" onclick="addTokenFromModal()">✅ 添加</button>
117
+ </div>
118
+ </div>
119
+ `;
120
+ document.body.appendChild(modal);
121
+ modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
122
+ }
123
+
124
+ async function addTokenFromModal() {
125
+ const modal = document.querySelector('.form-modal');
126
+ const accessToken = document.getElementById('modalAccessToken').value.trim();
127
+ const refreshToken = document.getElementById('modalRefreshToken').value.trim();
128
+ const expiresIn = parseInt(document.getElementById('modalExpiresIn').value);
129
+
130
+ if (!accessToken || !refreshToken) {
131
+ showToast('请填写完整的Token信息', 'warning');
132
+ return;
133
+ }
134
+
135
+ showLoading('正在添加Token...');
136
+ try {
137
+ const response = await authFetch('/admin/tokens', {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ 'Authorization': `Bearer ${authToken}`
142
+ },
143
+ body: JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn })
144
+ });
145
+
146
+ const data = await response.json();
147
+ hideLoading();
148
+ if (data.success) {
149
+ modal.remove();
150
+ showToast('Token添加成功', 'success');
151
+ loadTokens();
152
+ } else {
153
+ showToast(data.message || '添加失败', 'error');
154
+ }
155
+ } catch (error) {
156
+ hideLoading();
157
+ showToast('添加失败: ' + error.message, 'error');
158
+ }
159
+ }
160
+
161
+ function editField(event, refreshToken, field, currentValue) {
162
+ event.stopPropagation();
163
+ const row = event.currentTarget;
164
+ const valueSpan = row.querySelector('.info-value');
165
+
166
+ if (row.querySelector('input')) return;
167
+
168
+ const fieldLabels = { projectId: 'Project ID', email: '邮箱' };
169
+
170
+ const input = document.createElement('input');
171
+ input.type = field === 'email' ? 'email' : 'text';
172
+ input.value = currentValue;
173
+ input.className = 'inline-edit-input';
174
+ input.placeholder = `输入${fieldLabels[field]}`;
175
+
176
+ valueSpan.style.display = 'none';
177
+ row.insertBefore(input, valueSpan.nextSibling);
178
+ input.focus();
179
+ input.select();
180
+
181
+ const save = async () => {
182
+ const newValue = input.value.trim();
183
+ input.disabled = true;
184
+
185
+ try {
186
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
187
+ method: 'PUT',
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ 'Authorization': `Bearer ${authToken}`
191
+ },
192
+ body: JSON.stringify({ [field]: newValue })
193
+ });
194
+
195
+ const data = await response.json();
196
+ if (data.success) {
197
+ showToast('已保存', 'success');
198
+ loadTokens();
199
+ } else {
200
+ showToast(data.message || '保存失败', 'error');
201
+ cancel();
202
+ }
203
+ } catch (error) {
204
+ showToast('保存失败', 'error');
205
+ cancel();
206
+ }
207
+ };
208
+
209
+ const cancel = () => {
210
+ input.remove();
211
+ valueSpan.style.display = '';
212
+ };
213
+
214
+ input.addEventListener('blur', () => {
215
+ setTimeout(() => {
216
+ if (document.activeElement !== input) {
217
+ if (input.value.trim() !== currentValue) {
218
+ save();
219
+ } else {
220
+ cancel();
221
+ }
222
+ }
223
+ }, 100);
224
+ });
225
+
226
+ input.addEventListener('keydown', (e) => {
227
+ if (e.key === 'Enter') {
228
+ e.preventDefault();
229
+ save();
230
+ } else if (e.key === 'Escape') {
231
+ cancel();
232
+ }
233
+ });
234
+ }
235
+
236
+ function showTokenDetail(refreshToken) {
237
+ const token = cachedTokens.find(t => t.refresh_token === refreshToken);
238
+ if (!token) {
239
+ showToast('Token不存在', 'error');
240
+ return;
241
+ }
242
+
243
+ const modal = document.createElement('div');
244
+ modal.className = 'modal form-modal';
245
+ modal.innerHTML = `
246
+ <div class="modal-content">
247
+ <div class="modal-title">📝 Token详情</div>
248
+ <div class="form-group compact">
249
+ <label>🎫 Access Token (只读)</label>
250
+ <div class="token-display">${token.access_token || ''}</div>
251
+ </div>
252
+ <div class="form-group compact">
253
+ <label>🔄 Refresh Token (只读)</label>
254
+ <div class="token-display">${token.refresh_token}</div>
255
+ </div>
256
+ <div class="form-group compact">
257
+ <label>📦 Project ID</label>
258
+ <input type="text" id="editProjectId" value="${token.projectId || ''}" placeholder="项目ID">
259
+ </div>
260
+ <div class="form-group compact">
261
+ <label>📧 邮箱</label>
262
+ <input type="email" id="editEmail" value="${token.email || ''}" placeholder="账号邮箱">
263
+ </div>
264
+ <div class="form-group compact">
265
+ <label>⏰ 过期时间</label>
266
+ <input type="text" value="${new Date(token.timestamp + token.expires_in * 1000).toLocaleString('zh-CN')}" readonly style="background: var(--bg); cursor: not-allowed;">
267
+ </div>
268
+ <div class="modal-actions">
269
+ <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">取消</button>
270
+ <button class="btn btn-success" onclick="saveTokenDetail('${refreshToken}')">💾 保存</button>
271
+ </div>
272
+ </div>
273
+ `;
274
+ document.body.appendChild(modal);
275
+ modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
276
+ }
277
+
278
+ async function saveTokenDetail(refreshToken) {
279
+ const projectId = document.getElementById('editProjectId').value.trim();
280
+ const email = document.getElementById('editEmail').value.trim();
281
+
282
+ showLoading('保存中...');
283
+ try {
284
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
285
+ method: 'PUT',
286
+ headers: {
287
+ 'Content-Type': 'application/json',
288
+ 'Authorization': `Bearer ${authToken}`
289
+ },
290
+ body: JSON.stringify({ projectId, email })
291
+ });
292
+
293
+ const data = await response.json();
294
+ hideLoading();
295
+ if (data.success) {
296
+ document.querySelector('.form-modal').remove();
297
+ showToast('保存成功', 'success');
298
+ loadTokens();
299
+ } else {
300
+ showToast(data.message || '保存失败', 'error');
301
+ }
302
+ } catch (error) {
303
+ hideLoading();
304
+ showToast('保存失败: ' + error.message, 'error');
305
+ }
306
+ }
307
+
308
+ async function toggleToken(refreshToken, enable) {
309
+ const action = enable ? '启用' : '禁用';
310
+ const confirmed = await showConfirm(`确定要${action}这个Token吗?`, `${action}确认`);
311
+ if (!confirmed) return;
312
+
313
+ showLoading(`正在${action}...`);
314
+ try {
315
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
316
+ method: 'PUT',
317
+ headers: {
318
+ 'Content-Type': 'application/json',
319
+ 'Authorization': `Bearer ${authToken}`
320
+ },
321
+ body: JSON.stringify({ enable })
322
+ });
323
+
324
+ const data = await response.json();
325
+ hideLoading();
326
+ if (data.success) {
327
+ showToast(`已${action}`, 'success');
328
+ loadTokens();
329
+ } else {
330
+ showToast(data.message || '操作失败', 'error');
331
+ }
332
+ } catch (error) {
333
+ hideLoading();
334
+ showToast('操作失败: ' + error.message, 'error');
335
+ }
336
+ }
337
+
338
+ async function deleteToken(refreshToken) {
339
+ const confirmed = await showConfirm('删除后无法恢复,确定删除?', '⚠️ 删除确认');
340
+ if (!confirmed) return;
341
+
342
+ showLoading('正在删除...');
343
+ try {
344
+ const response = await authFetch(`/admin/tokens/${encodeURIComponent(refreshToken)}`, {
345
+ method: 'DELETE',
346
+ headers: { 'Authorization': `Bearer ${authToken}` }
347
+ });
348
+
349
+ const data = await response.json();
350
+ hideLoading();
351
+ if (data.success) {
352
+ showToast('已删除', 'success');
353
+ loadTokens();
354
+ } else {
355
+ showToast(data.message || '删除失败', 'error');
356
+ }
357
+ } catch (error) {
358
+ hideLoading();
359
+ showToast('删除失败: ' + error.message, 'error');
360
+ }
361
+ }
public/js/ui.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // UI组件:Toast、Modal、Loading
2
+
3
+ function showToast(message, type = 'info', title = '') {
4
+ const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' };
5
+ const titles = { success: '成功', error: '错误', warning: '警告', info: '提示' };
6
+ const toast = document.createElement('div');
7
+ toast.className = `toast ${type}`;
8
+ toast.innerHTML = `
9
+ <div class="toast-icon">${icons[type]}</div>
10
+ <div class="toast-content">
11
+ <div class="toast-title">${title || titles[type]}</div>
12
+ <div class="toast-message">${message}</div>
13
+ </div>
14
+ `;
15
+ document.body.appendChild(toast);
16
+ setTimeout(() => {
17
+ toast.style.animation = 'slideOut 0.3s ease';
18
+ setTimeout(() => toast.remove(), 300);
19
+ }, 3000);
20
+ }
21
+
22
+ function showConfirm(message, title = '确认操作') {
23
+ return new Promise((resolve) => {
24
+ const modal = document.createElement('div');
25
+ modal.className = 'modal';
26
+ modal.innerHTML = `
27
+ <div class="modal-content">
28
+ <div class="modal-title">${title}</div>
29
+ <div class="modal-message">${message}</div>
30
+ <div class="modal-actions">
31
+ <button class="btn btn-secondary" onclick="this.closest('.modal').remove(); window.modalResolve(false)">取消</button>
32
+ <button class="btn btn-danger" onclick="this.closest('.modal').remove(); window.modalResolve(true)">确定</button>
33
+ </div>
34
+ </div>
35
+ `;
36
+ document.body.appendChild(modal);
37
+ modal.onclick = (e) => { if (e.target === modal) { modal.remove(); resolve(false); } };
38
+ window.modalResolve = resolve;
39
+ });
40
+ }
41
+
42
+ function showLoading(text = '处理中...') {
43
+ const overlay = document.createElement('div');
44
+ overlay.className = 'loading-overlay';
45
+ overlay.id = 'loadingOverlay';
46
+ overlay.innerHTML = `<div class="spinner"></div><div class="loading-text">${text}</div>`;
47
+ document.body.appendChild(overlay);
48
+ }
49
+
50
+ function hideLoading() {
51
+ const overlay = document.getElementById('loadingOverlay');
52
+ if (overlay) overlay.remove();
53
+ }
54
+
55
+ function switchTab(tab) {
56
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
57
+ event.target.classList.add('active');
58
+
59
+ document.getElementById('tokensPage').classList.add('hidden');
60
+ document.getElementById('settingsPage').classList.add('hidden');
61
+
62
+ if (tab === 'tokens') {
63
+ document.getElementById('tokensPage').classList.remove('hidden');
64
+ } else if (tab === 'settings') {
65
+ document.getElementById('settingsPage').classList.remove('hidden');
66
+ loadConfig();
67
+ }
68
+ }
public/js/utils.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 字体大小设置
2
+ function initFontSize() {
3
+ const savedSize = localStorage.getItem('fontSize') || '18';
4
+ document.documentElement.style.setProperty('--font-size-base', savedSize + 'px');
5
+ updateFontSizeInputs(savedSize);
6
+ }
7
+
8
+ function changeFontSize(size) {
9
+ size = Math.max(10, Math.min(24, parseInt(size) || 14));
10
+ document.documentElement.style.setProperty('--font-size-base', size + 'px');
11
+ localStorage.setItem('fontSize', size);
12
+ updateFontSizeInputs(size);
13
+ }
14
+
15
+ function updateFontSizeInputs(size) {
16
+ const rangeInput = document.getElementById('fontSizeRange');
17
+ const numberInput = document.getElementById('fontSizeInput');
18
+ if (rangeInput) rangeInput.value = size;
19
+ if (numberInput) numberInput.value = size;
20
+ }
21
+
22
+ // 敏感信息隐藏功能
23
+ let sensitiveInfoHidden = localStorage.getItem('sensitiveInfoHidden') !== 'false';
24
+
25
+ function initSensitiveInfo() {
26
+ updateSensitiveInfoDisplay();
27
+ updateSensitiveBtn();
28
+ }
29
+
30
+ function toggleSensitiveInfo() {
31
+ sensitiveInfoHidden = !sensitiveInfoHidden;
32
+ localStorage.setItem('sensitiveInfoHidden', sensitiveInfoHidden);
33
+ updateSensitiveInfoDisplay();
34
+ updateSensitiveBtn();
35
+ }
36
+
37
+ function updateSensitiveBtn() {
38
+ const btn = document.getElementById('toggleSensitiveBtn');
39
+ if (btn) {
40
+ if (sensitiveInfoHidden) {
41
+ btn.innerHTML = '🙈 隐藏';
42
+ btn.title = '点击显示敏感信息';
43
+ btn.classList.remove('btn-info');
44
+ btn.classList.add('btn-secondary');
45
+ } else {
46
+ btn.innerHTML = '👁️ 显示';
47
+ btn.title = '点击隐藏敏感信息';
48
+ btn.classList.remove('btn-secondary');
49
+ btn.classList.add('btn-info');
50
+ }
51
+ }
52
+ }
53
+
54
+ function updateSensitiveInfoDisplay() {
55
+ document.querySelectorAll('.sensitive-info').forEach(el => {
56
+ if (sensitiveInfoHidden) {
57
+ el.dataset.original = el.textContent;
58
+ el.textContent = '••••••';
59
+ el.classList.add('blurred');
60
+ } else if (el.dataset.original) {
61
+ el.textContent = el.dataset.original;
62
+ el.classList.remove('blurred');
63
+ }
64
+ });
65
+ }
public/style.css CHANGED
@@ -161,7 +161,7 @@ label {
161
  input, select, textarea {
162
  width: 100%;
163
  min-height: 40px;
164
- padding: 0.5rem 0.75rem;
165
  border: 1.5px solid var(--border);
166
  border-radius: 0.5rem;
167
  font-size: 0.875rem;
@@ -357,6 +357,7 @@ button.loading::after {
357
  display: grid;
358
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
359
  gap: 0.75rem;
 
360
  }
361
  .token-card {
362
  background: rgba(255, 255, 255, 0.6);
@@ -464,7 +465,7 @@ button.loading::after {
464
  .inline-edit-input {
465
  flex: 1;
466
  min-height: 24px;
467
- padding: 0.125rem 0.375rem;
468
  font-size: 0.75rem;
469
  border: 1px solid var(--primary);
470
  border-radius: 0.25rem;
@@ -544,6 +545,7 @@ button.loading::after {
544
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
545
  gap: 1rem;
546
  margin-bottom: 1rem;
 
547
  }
548
  .config-section {
549
  background: rgba(255, 255, 255, 0.6);
@@ -577,13 +579,13 @@ button.loading::after {
577
  .form-group.compact input,
578
  .form-group.compact select {
579
  min-height: 36px;
580
- padding: 0.375rem 0.5rem;
581
  font-size: 0.8rem;
582
  }
583
  .form-group.compact textarea {
584
  min-height: 60px;
585
  max-height: 300px;
586
- padding: 0.375rem 0.5rem;
587
  font-size: 0.8rem;
588
  resize: vertical;
589
  height: auto;
@@ -783,6 +785,56 @@ button.loading::after {
783
  font-weight: 600;
784
  cursor: help;
785
  margin-left: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  }
787
 
788
  /* 额度弹窗头部 */
 
161
  input, select, textarea {
162
  width: 100%;
163
  min-height: 40px;
164
+ padding: 0.5rem 0.75rem 0.5rem 0.5rem !important;
165
  border: 1.5px solid var(--border);
166
  border-radius: 0.5rem;
167
  font-size: 0.875rem;
 
357
  display: grid;
358
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
359
  gap: 0.75rem;
360
+ align-items: start;
361
  }
362
  .token-card {
363
  background: rgba(255, 255, 255, 0.6);
 
465
  .inline-edit-input {
466
  flex: 1;
467
  min-height: 24px;
468
+ padding: 0.125rem 0.375rem 0.125rem 0.5rem;
469
  font-size: 0.75rem;
470
  border: 1px solid var(--primary);
471
  border-radius: 0.25rem;
 
545
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
546
  gap: 1rem;
547
  margin-bottom: 1rem;
548
+ align-items: start;
549
  }
550
  .config-section {
551
  background: rgba(255, 255, 255, 0.6);
 
579
  .form-group.compact input,
580
  .form-group.compact select {
581
  min-height: 36px;
582
+ padding: 0.375rem 0.5rem 0.375rem 0.75rem;
583
  font-size: 0.8rem;
584
  }
585
  .form-group.compact textarea {
586
  min-height: 60px;
587
  max-height: 300px;
588
+ padding: 0.375rem 0.5rem 0.375rem 0.75rem;
589
  font-size: 0.8rem;
590
  resize: vertical;
591
  height: auto;
 
785
  font-weight: 600;
786
  cursor: help;
787
  margin-left: 4px;
788
+ position: relative;
789
+ }
790
+ .help-tip::before {
791
+ content: attr(data-tooltip);
792
+ position: absolute;
793
+ bottom: calc(100% + 8px);
794
+ left: 50%;
795
+ transform: translateX(-50%);
796
+ background: rgba(0, 0, 0, 0.9);
797
+ color: white;
798
+ padding: 0.5rem 0.75rem;
799
+ border-radius: 0.375rem;
800
+ font-size: 0.75rem;
801
+ font-weight: 400;
802
+ white-space: pre-line;
803
+ min-width: 200px;
804
+ max-width: 300px;
805
+ text-align: left;
806
+ line-height: 1.4;
807
+ opacity: 0;
808
+ pointer-events: none;
809
+ transition: opacity 0.2s;
810
+ z-index: 1000;
811
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
812
+ }
813
+ .help-tip::after {
814
+ content: '';
815
+ position: absolute;
816
+ bottom: calc(100% + 2px);
817
+ left: 50%;
818
+ transform: translateX(-50%);
819
+ border: 6px solid transparent;
820
+ border-top-color: rgba(0, 0, 0, 0.9);
821
+ opacity: 0;
822
+ pointer-events: none;
823
+ transition: opacity 0.2s;
824
+ z-index: 1000;
825
+ }
826
+ .help-tip:hover::before,
827
+ .help-tip:hover::after {
828
+ opacity: 1;
829
+ }
830
+ @media (prefers-color-scheme: dark) {
831
+ .help-tip::before {
832
+ background: rgba(255, 255, 255, 0.95);
833
+ color: var(--text);
834
+ }
835
+ .help-tip::after {
836
+ border-top-color: rgba(255, 255, 255, 0.95);
837
+ }
838
  }
839
 
840
  /* 额度弹窗头部 */
scripts/oauth-server.js CHANGED
@@ -1,72 +1,14 @@
1
  import http from 'http';
2
  import { URL } from 'url';
3
- import crypto from 'crypto';
4
- import fs from 'fs';
5
  import path from 'path';
6
  import { fileURLToPath } from 'url';
7
- import axios from 'axios';
8
  import log from '../src/utils/logger.js';
9
- import config from '../src/config/config.js';
10
- import { generateProjectId } from '../src/utils/idGenerator.js';
11
  import tokenManager from '../src/auth/token_manager.js';
12
- import { OAUTH_CONFIG, OAUTH_SCOPES } from '../src/constants/oauth.js';
13
- import { buildAxiosRequestConfig } from '../src/utils/httpClient.js';
14
 
15
  const __filename = fileURLToPath(import.meta.url);
16
  const __dirname = path.dirname(__filename);
17
- // 账号文件路径保持不变,仅用于日志展示,具体读写交给 TokenManager 处理
18
  const ACCOUNTS_FILE = path.join(__dirname, '..', 'data', 'accounts.json');
19
- const STATE = crypto.randomUUID();
20
-
21
- const SCOPES = OAUTH_SCOPES;
22
-
23
- function generateAuthUrl(port) {
24
- const params = new URLSearchParams({
25
- access_type: 'offline',
26
- client_id: OAUTH_CONFIG.CLIENT_ID,
27
- prompt: 'consent',
28
- redirect_uri: `http://localhost:${port}/oauth-callback`,
29
- response_type: 'code',
30
- scope: SCOPES.join(' '),
31
- state: STATE
32
- });
33
- return `${OAUTH_CONFIG.AUTH_URL}?${params.toString()}`;
34
- }
35
-
36
- async function exchangeCodeForToken(code, port) {
37
- const postData = new URLSearchParams({
38
- code,
39
- client_id: OAUTH_CONFIG.CLIENT_ID,
40
- client_secret: OAUTH_CONFIG.CLIENT_SECRET,
41
- redirect_uri: `http://localhost:${port}/oauth-callback`,
42
- grant_type: 'authorization_code'
43
- });
44
-
45
- const response = await axios(buildAxiosRequestConfig({
46
- method: 'POST',
47
- url: OAUTH_CONFIG.TOKEN_URL,
48
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
49
- data: postData.toString(),
50
- timeout: config.timeout
51
- }));
52
-
53
- return response.data;
54
- }
55
-
56
- async function fetchUserEmail(accessToken) {
57
- const response = await axios(buildAxiosRequestConfig({
58
- method: 'GET',
59
- url: 'https://www.googleapis.com/oauth2/v2/userinfo',
60
- headers: {
61
- 'Host': 'www.googleapis.com',
62
- 'User-Agent': 'Go-http-client/1.1',
63
- 'Authorization': `Bearer ${accessToken}`,
64
- 'Accept-Encoding': 'gzip'
65
- },
66
- timeout: config.timeout
67
- }));
68
- return response.data?.email;
69
- }
70
 
71
  const server = http.createServer((req, res) => {
72
  const port = server.address().port;
@@ -78,68 +20,25 @@ const server = http.createServer((req, res) => {
78
 
79
  if (code) {
80
  log.info('收到授权码,正在交换 Token...');
81
- exchangeCodeForToken(code, port).then(async (tokenData) => {
82
- const account = {
83
- access_token: tokenData.access_token,
84
- refresh_token: tokenData.refresh_token,
85
- expires_in: tokenData.expires_in,
86
- timestamp: Date.now()
87
- };
88
-
89
- try {
90
- const email = await fetchUserEmail(account.access_token);
91
- if (email) {
92
- account.email = email;
93
- log.info('获取到用户邮箱: ' + email);
94
- }
95
- } catch (err) {
96
- log.warn('获取用户邮箱失败:', err.message);
97
- }
98
-
99
- if (config.skipProjectIdFetch) {
100
- account.projectId = generateProjectId();
101
- account.enable = true;
102
- log.info('已跳过API验证,使用随机生成的projectId: ' + account.projectId);
103
- } else {
104
- log.info('正在验证账号资格...');
105
- try {
106
- const projectId = await tokenManager.fetchProjectId({ access_token: account.access_token });
107
- if (projectId === undefined) {
108
- log.warn('该账号无资格使用(无法获取projectId),已跳过保存');
109
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
110
- res.end('<h1>账号无资格</h1><p>该账号无法获取projectId,未保存。</p>');
111
- setTimeout(() => server.close(), 1000);
112
- return;
113
- }
114
- account.projectId = projectId;
115
- account.enable = true;
116
- log.info('账号验证通过');
117
- } catch (err) {
118
- log.error('验证账号资格失败:', err.message);
119
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
120
- res.end('<h1>验证失败</h1><p>无法验证账号资格,请查看控制台。</p>');
121
- setTimeout(() => server.close(), 1000);
122
- return;
123
- }
124
- }
125
-
126
  const result = tokenManager.addToken(account);
127
  if (result.success) {
128
  log.info(`Token 已保存到 ${ACCOUNTS_FILE}`);
 
 
 
129
  } else {
130
  log.error('保存 Token 失败:', result.message);
131
  }
132
 
 
133
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
134
- res.end('<h1>授权成功!</h1><p>Token 已保存,可以关闭此页面。</p>');
135
-
136
  setTimeout(() => server.close(), 1000);
137
  }).catch(err => {
138
- log.error('Token 交换失败:', err.message);
139
-
140
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
141
- res.end('<h1>Token 获取失败</h1><p>查看控制台错误信息</p>');
142
-
143
  setTimeout(() => server.close(), 1000);
144
  });
145
  } else {
@@ -156,7 +55,7 @@ const server = http.createServer((req, res) => {
156
 
157
  server.listen(0, () => {
158
  const port = server.address().port;
159
- const authUrl = generateAuthUrl(port);
160
  log.info(`服务器运行在 http://localhost:${port}`);
161
  log.info('请在浏览器中打开以下链接进行登录:');
162
  console.log(`\n${authUrl}\n`);
 
1
  import http from 'http';
2
  import { URL } from 'url';
 
 
3
  import path from 'path';
4
  import { fileURLToPath } from 'url';
 
5
  import log from '../src/utils/logger.js';
 
 
6
  import tokenManager from '../src/auth/token_manager.js';
7
+ import oauthManager from '../src/auth/oauth_manager.js';
 
8
 
9
  const __filename = fileURLToPath(import.meta.url);
10
  const __dirname = path.dirname(__filename);
 
11
  const ACCOUNTS_FILE = path.join(__dirname, '..', 'data', 'accounts.json');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  const server = http.createServer((req, res) => {
14
  const port = server.address().port;
 
20
 
21
  if (code) {
22
  log.info('收到授权码,正在交换 Token...');
23
+ oauthManager.authenticate(code, port).then(account => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  const result = tokenManager.addToken(account);
25
  if (result.success) {
26
  log.info(`Token 已保存到 ${ACCOUNTS_FILE}`);
27
+ if (!account.hasQuota) {
28
+ log.warn('该账号无资格,已自动使用随机ProjectId');
29
+ }
30
  } else {
31
  log.error('保存 Token 失败:', result.message);
32
  }
33
 
34
+ const statusMsg = account.hasQuota ? '' : '<p style="color: orange;">⚠️ ���账号无资格,已自动使用随机ProjectId</p>';
35
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
36
+ res.end(`<h1>授权成功!</h1><p>Token 已保存,可以关闭此页面。</p>${statusMsg}`);
 
37
  setTimeout(() => server.close(), 1000);
38
  }).catch(err => {
39
+ log.error('认证失败:', err.message);
 
40
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
41
+ res.end('<h1>认证失败</h1><p>查看控制台错误信息</p>');
 
42
  setTimeout(() => server.close(), 1000);
43
  });
44
  } else {
 
55
 
56
  server.listen(0, () => {
57
  const port = server.address().port;
58
+ const authUrl = oauthManager.generateAuthUrl(port);
59
  log.info(`服务器运行在 http://localhost:${port}`);
60
  log.info('请在浏览器中打开以下链接进行登录:');
61
  console.log(`\n${authUrl}\n`);
src/auth/oauth_manager.js ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import crypto from 'crypto';
3
+ import log from '../utils/logger.js';
4
+ import config from '../config/config.js';
5
+ import { generateProjectId } from '../utils/idGenerator.js';
6
+ import tokenManager from './token_manager.js';
7
+ import { OAUTH_CONFIG, OAUTH_SCOPES } from '../constants/oauth.js';
8
+ import { buildAxiosRequestConfig } from '../utils/httpClient.js';
9
+
10
+ class OAuthManager {
11
+ constructor() {
12
+ this.state = crypto.randomUUID();
13
+ }
14
+
15
+ /**
16
+ * 生成授权URL
17
+ */
18
+ generateAuthUrl(port) {
19
+ const params = new URLSearchParams({
20
+ access_type: 'offline',
21
+ client_id: OAUTH_CONFIG.CLIENT_ID,
22
+ prompt: 'consent',
23
+ redirect_uri: `http://localhost:${port}/oauth-callback`,
24
+ response_type: 'code',
25
+ scope: OAUTH_SCOPES.join(' '),
26
+ state: this.state
27
+ });
28
+ return `${OAUTH_CONFIG.AUTH_URL}?${params.toString()}`;
29
+ }
30
+
31
+ /**
32
+ * 交换授权码获取Token
33
+ */
34
+ async exchangeCodeForToken(code, port) {
35
+ const postData = new URLSearchParams({
36
+ code,
37
+ client_id: OAUTH_CONFIG.CLIENT_ID,
38
+ client_secret: OAUTH_CONFIG.CLIENT_SECRET,
39
+ redirect_uri: `http://localhost:${port}/oauth-callback`,
40
+ grant_type: 'authorization_code'
41
+ });
42
+
43
+ const response = await axios(buildAxiosRequestConfig({
44
+ method: 'POST',
45
+ url: OAUTH_CONFIG.TOKEN_URL,
46
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
47
+ data: postData.toString(),
48
+ timeout: config.timeout
49
+ }));
50
+
51
+ return response.data;
52
+ }
53
+
54
+ /**
55
+ * 获取用户邮箱
56
+ */
57
+ async fetchUserEmail(accessToken) {
58
+ try {
59
+ const response = await axios(buildAxiosRequestConfig({
60
+ method: 'GET',
61
+ url: 'https://www.googleapis.com/oauth2/v2/userinfo',
62
+ headers: {
63
+ 'Host': 'www.googleapis.com',
64
+ 'User-Agent': 'Go-http-client/1.1',
65
+ 'Authorization': `Bearer ${accessToken}`,
66
+ 'Accept-Encoding': 'gzip'
67
+ },
68
+ timeout: config.timeout
69
+ }));
70
+ return response.data?.email;
71
+ } catch (err) {
72
+ log.warn('获取用户邮箱失败:', err.message);
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 资格校验:尝试获取projectId,失败则自动回退到随机projectId
79
+ */
80
+ async validateAndGetProjectId(accessToken) {
81
+ // 如果配置跳过API验证,直接返回随机projectId
82
+ if (config.skipProjectIdFetch) {
83
+ const projectId = generateProjectId();
84
+ log.info('已跳过API验证,使用随机生成的projectId: ' + projectId);
85
+ return { projectId, hasQuota: false };
86
+ }
87
+
88
+ // 尝试从API获取projectId
89
+ try {
90
+ log.info('正在验证账号资格...');
91
+ const projectId = await tokenManager.fetchProjectId({ access_token: accessToken });
92
+
93
+ if (projectId === undefined) {
94
+ // 无资格,自动回退到随机projectId
95
+ const randomProjectId = generateProjectId();
96
+ log.warn('该账号无资格使用,已自动退回无资格模式,使用随机projectId: ' + randomProjectId);
97
+ return { projectId: randomProjectId, hasQuota: false };
98
+ }
99
+
100
+ log.info('账号验证通过,projectId: ' + projectId);
101
+ return { projectId, hasQuota: true };
102
+ } catch (err) {
103
+ // 获取失败时也退回到随机projectId
104
+ const randomProjectId = generateProjectId();
105
+ log.warn('验证账号资格失败: ' + err.message + ',已自动退回无资格模式');
106
+ log.info('使用随机生成的projectId: ' + randomProjectId);
107
+ return { projectId: randomProjectId, hasQuota: false };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 完整的OAuth认证流程:交换Token -> 获取邮箱 -> 资格校验
113
+ */
114
+ async authenticate(code, port) {
115
+ // 1. 交换授权码获取Token
116
+ const tokenData = await this.exchangeCodeForToken(code, port);
117
+
118
+ if (!tokenData.access_token) {
119
+ throw new Error('Token交换失败:未获取到access_token');
120
+ }
121
+
122
+ const account = {
123
+ access_token: tokenData.access_token,
124
+ refresh_token: tokenData.refresh_token,
125
+ expires_in: tokenData.expires_in,
126
+ timestamp: Date.now()
127
+ };
128
+
129
+ // 2. 获取用户邮箱
130
+ const email = await this.fetchUserEmail(account.access_token);
131
+ if (email) {
132
+ account.email = email;
133
+ log.info('获取到用户邮箱: ' + email);
134
+ }
135
+
136
+ // 3. 资格校验并获取projectId
137
+ const { projectId, hasQuota } = await this.validateAndGetProjectId(account.access_token);
138
+ account.projectId = projectId;
139
+ account.hasQuota = hasQuota;
140
+ account.enable = true;
141
+
142
+ return account;
143
+ }
144
+ }
145
+
146
+ export default new OAuthManager();
src/config/config.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'fs';
3
  import path from 'path';
4
  import { fileURLToPath } from 'url';
5
  import log from '../utils/logger.js';
 
6
 
7
  const __filename = fileURLToPath(import.meta.url);
8
  const __dirname = path.dirname(__filename);
@@ -97,53 +98,60 @@ export function getProxyConfig() {
97
  return systemProxy || null;
98
  }
99
 
100
- const config = {
101
- server: {
102
- port: jsonConfig.server?.port || 8045,
103
- host: jsonConfig.server?.host || '0.0.0.0',
104
- heartbeatInterval: jsonConfig.server?.heartbeatInterval || 15000, // 心跳间隔(ms),防止CF超时
105
- memoryThreshold: jsonConfig.server?.memoryThreshold || 500 // 内存阈值(MB),超过触发GC
106
- },
107
- cache: {
108
- modelListTTL: jsonConfig.cache?.modelListTTL || 60 * 60 * 1000 // 模型列表缓存时间(ms),默认60分钟
109
- },
110
- rotation: {
111
- strategy: jsonConfig.rotation?.strategy || 'round_robin', // 轮询策略: round_robin, quota_exhausted, request_count
112
- requestCount: jsonConfig.rotation?.requestCount || 10 // request_count策略下每个token的请求次数
113
- },
114
- imageBaseUrl: process.env.IMAGE_BASE_URL || null,
115
- maxImages: jsonConfig.other?.maxImages || 10,
116
- api: {
117
- url: jsonConfig.api?.url || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse',
118
- modelsUrl: jsonConfig.api?.modelsUrl || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
119
- noStreamUrl: jsonConfig.api?.noStreamUrl || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent',
120
- host: jsonConfig.api?.host || 'daily-cloudcode-pa.sandbox.googleapis.com',
121
- userAgent: jsonConfig.api?.userAgent || 'antigravity/1.11.3 windows/amd64'
122
- },
123
- defaults: {
124
- temperature: jsonConfig.defaults?.temperature || 1,
125
- top_p: jsonConfig.defaults?.topP || 0.85,
126
- top_k: jsonConfig.defaults?.topK || 50,
127
- max_tokens: jsonConfig.defaults?.maxTokens || 32000,
128
- thinking_budget: jsonConfig.defaults?.thinkingBudget ?? 1024
129
- },
130
- security: {
131
- maxRequestSize: jsonConfig.server?.maxRequestSize || '50mb',
132
- apiKey: process.env.API_KEY || null
133
- },
134
- admin: {
135
- username: process.env.ADMIN_USERNAME || 'admin',
136
- password: process.env.ADMIN_PASSWORD || 'admin123',
137
- jwtSecret: process.env.JWT_SECRET || 'your-jwt-secret-key-change-this-in-production'
138
- },
139
- useNativeAxios: jsonConfig.other?.useNativeAxios !== false,
140
- timeout: jsonConfig.other?.timeout || 300000,
141
- // 默认 429 重试次数(统一配置,0 表示不重试,默认 3 次)
142
- retryTimes: Number.isFinite(jsonConfig.other?.retryTimes) ? jsonConfig.other.retryTimes : 3,
143
- proxy: getProxyConfig(),
144
- systemInstruction: process.env.SYSTEM_INSTRUCTION || '',
145
- skipProjectIdFetch: jsonConfig.other?.skipProjectIdFetch === true
146
- };
 
 
 
 
 
 
 
147
 
148
  log.info('✓ 配置加载成功');
149
 
@@ -157,5 +165,7 @@ export function getConfigJson() {
157
  }
158
 
159
  export function saveConfigJson(data) {
160
- fs.writeFileSync(configJsonPath, JSON.stringify(data, null, 2), 'utf8');
161
- }
 
 
 
3
  import path from 'path';
4
  import { fileURLToPath } from 'url';
5
  import log from '../utils/logger.js';
6
+ import { deepMerge } from '../utils/deepMerge.js';
7
 
8
  const __filename = fileURLToPath(import.meta.url);
9
  const __dirname = path.dirname(__filename);
 
98
  return systemProxy || null;
99
  }
100
 
101
+ /**
102
+ * 从 JSON 和环境变量构建配置对象
103
+ */
104
+ export function buildConfig(jsonConfig) {
105
+ return {
106
+ server: {
107
+ port: jsonConfig.server?.port || 8045,
108
+ host: jsonConfig.server?.host || '0.0.0.0',
109
+ heartbeatInterval: jsonConfig.server?.heartbeatInterval || 15000,
110
+ memoryThreshold: jsonConfig.server?.memoryThreshold || 500
111
+ },
112
+ cache: {
113
+ modelListTTL: jsonConfig.cache?.modelListTTL || 60 * 60 * 1000
114
+ },
115
+ rotation: {
116
+ strategy: jsonConfig.rotation?.strategy || 'round_robin',
117
+ requestCount: jsonConfig.rotation?.requestCount || 10
118
+ },
119
+ imageBaseUrl: process.env.IMAGE_BASE_URL || null,
120
+ maxImages: jsonConfig.other?.maxImages || 10,
121
+ api: {
122
+ url: jsonConfig.api?.url || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse',
123
+ modelsUrl: jsonConfig.api?.modelsUrl || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
124
+ noStreamUrl: jsonConfig.api?.noStreamUrl || 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent',
125
+ host: jsonConfig.api?.host || 'daily-cloudcode-pa.sandbox.googleapis.com',
126
+ userAgent: jsonConfig.api?.userAgent || 'antigravity/1.11.3 windows/amd64'
127
+ },
128
+ defaults: {
129
+ temperature: jsonConfig.defaults?.temperature || 1,
130
+ top_p: jsonConfig.defaults?.topP || 0.85,
131
+ top_k: jsonConfig.defaults?.topK || 50,
132
+ max_tokens: jsonConfig.defaults?.maxTokens || 32000,
133
+ thinking_budget: jsonConfig.defaults?.thinkingBudget ?? 1024
134
+ },
135
+ security: {
136
+ maxRequestSize: jsonConfig.server?.maxRequestSize || '50mb',
137
+ apiKey: process.env.API_KEY || null
138
+ },
139
+ admin: {
140
+ username: process.env.ADMIN_USERNAME || 'admin',
141
+ password: process.env.ADMIN_PASSWORD || 'admin123',
142
+ jwtSecret: process.env.JWT_SECRET || 'your-jwt-secret-key-change-this-in-production'
143
+ },
144
+ useNativeAxios: jsonConfig.other?.useNativeAxios !== false,
145
+ timeout: jsonConfig.other?.timeout || 300000,
146
+ retryTimes: Number.isFinite(jsonConfig.other?.retryTimes) ? jsonConfig.other.retryTimes : 3,
147
+ proxy: getProxyConfig(),
148
+ systemInstruction: process.env.SYSTEM_INSTRUCTION || '',
149
+ skipProjectIdFetch: jsonConfig.other?.skipProjectIdFetch === true,
150
+ useContextSystemPrompt: jsonConfig.other?.useContextSystemPrompt === true
151
+ };
152
+ }
153
+
154
+ const config = buildConfig(jsonConfig);
155
 
156
  log.info('✓ 配置加载成功');
157
 
 
165
  }
166
 
167
  export function saveConfigJson(data) {
168
+ const existing = getConfigJson();
169
+ const merged = deepMerge(existing, data);
170
+ fs.writeFileSync(configJsonPath, JSON.stringify(merged, null, 2), 'utf8');
171
+ }
src/routes/admin.js CHANGED
@@ -3,12 +3,11 @@ import fs from 'fs';
3
  import { generateToken, authMiddleware } from '../auth/jwt.js';
4
  import tokenManager from '../auth/token_manager.js';
5
  import quotaManager from '../auth/quota_manager.js';
 
6
  import config, { getConfigJson, saveConfigJson } from '../config/config.js';
7
  import logger from '../utils/logger.js';
8
- import { generateProjectId } from '../utils/idGenerator.js';
9
  import { parseEnvFile, updateEnvFile } from '../utils/envParser.js';
10
  import { reloadConfig } from '../utils/configReloader.js';
11
- import { OAUTH_CONFIG } from '../constants/oauth.js';
12
  import { deepMerge } from '../utils/deepMerge.js';
13
  import { getModelsWithQuotas } from '../api/client.js';
14
  import path from 'path';
@@ -110,77 +109,13 @@ router.post('/oauth/exchange', authMiddleware, async (req, res) => {
110
  }
111
 
112
  try {
113
- const postData = new URLSearchParams({
114
- code,
115
- client_id: OAUTH_CONFIG.CLIENT_ID,
116
- client_secret: OAUTH_CONFIG.CLIENT_SECRET,
117
- redirect_uri: `http://localhost:${port}/oauth-callback`,
118
- grant_type: 'authorization_code'
119
- });
120
-
121
- const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
122
- method: 'POST',
123
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
124
- body: postData.toString()
125
- });
126
-
127
- const tokenData = await response.json();
128
-
129
- if (!tokenData.access_token) {
130
- return res.status(400).json({ success: false, message: 'Token交换失败' });
131
- }
132
-
133
- const account = {
134
- access_token: tokenData.access_token,
135
- refresh_token: tokenData.refresh_token,
136
- expires_in: tokenData.expires_in,
137
- timestamp: Date.now(),
138
- enable: true
139
- };
140
-
141
- try {
142
- const emailResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
143
- headers: {
144
- 'Host': 'www.googleapis.com',
145
- 'User-Agent': 'Go-http-client/1.1',
146
- 'Authorization': `Bearer ${account.access_token}`,
147
- 'Accept-Encoding': 'gzip'
148
- }
149
- });
150
- const userInfo = await emailResponse.json();
151
- if (userInfo.email) {
152
- account.email = userInfo.email;
153
- logger.info('获取到用户邮箱: ' + userInfo.email);
154
- }
155
- } catch (err) {
156
- logger.warn('获取用户邮箱失败:', err.message);
157
- }
158
-
159
- // 始终尝试获取 projectId 进行资格校验
160
- // 如果无资格,自动退回到无资格模式使用随机 projectId
161
- try {
162
- const projectId = await tokenManager.fetchProjectId(account);
163
- if (projectId === undefined) {
164
- // 无资格,自动退回到无资格模式
165
- account.projectId = generateProjectId();
166
- account.hasQuota = false;
167
- logger.warn('该账号无资格使用,已自动退回无资格模式,使用随机projectId: ' + account.projectId);
168
- } else {
169
- account.projectId = projectId;
170
- account.hasQuota = true;
171
- logger.info('账号验证通过,projectId: ' + projectId);
172
- }
173
- } catch (error) {
174
- // 获取失败时也退回到无资格模式
175
- logger.warn('验证账号资格失败: ' + error.message + ',已自动退回无资格模式');
176
- account.projectId = generateProjectId();
177
- account.hasQuota = false;
178
- logger.info('使用随机生成的projectId: ' + account.projectId);
179
- }
180
-
181
- res.json({ success: true, data: account });
182
  } catch (error) {
183
- logger.error('Token交换失败:', error.message);
184
  res.status(500).json({ success: false, message: error.message });
185
  }
186
  });
@@ -202,15 +137,8 @@ router.put('/config', authMiddleware, (req, res) => {
202
  try {
203
  const { env: envUpdates, json: jsonUpdates } = req.body;
204
 
205
- if (envUpdates) {
206
- updateEnvFile(envPath, envUpdates);
207
- }
208
-
209
- if (jsonUpdates) {
210
- const currentConfig = getConfigJson();
211
- const mergedConfig = deepMerge(currentConfig, jsonUpdates);
212
- saveConfigJson(mergedConfig);
213
- }
214
 
215
  dotenv.config({ override: true });
216
  reloadConfig();
@@ -251,13 +179,16 @@ router.put('/rotation', authMiddleware, (req, res) => {
251
  // 更新内存中的配置
252
  tokenManager.updateRotationConfig(strategy, requestCount);
253
 
254
- // 同时保存到config.json
255
  const currentConfig = getConfigJson();
256
  if (!currentConfig.rotation) currentConfig.rotation = {};
257
  if (strategy) currentConfig.rotation.strategy = strategy;
258
  if (requestCount) currentConfig.rotation.requestCount = requestCount;
259
  saveConfigJson(currentConfig);
260
 
 
 
 
261
  logger.info(`轮询策略已更新: ${strategy || '未变'}, 请求次数: ${requestCount || '未变'}`);
262
  res.json({ success: true, message: '轮询策略已更新', data: tokenManager.getRotationConfig() });
263
  } catch (error) {
 
3
  import { generateToken, authMiddleware } from '../auth/jwt.js';
4
  import tokenManager from '../auth/token_manager.js';
5
  import quotaManager from '../auth/quota_manager.js';
6
+ import oauthManager from '../auth/oauth_manager.js';
7
  import config, { getConfigJson, saveConfigJson } from '../config/config.js';
8
  import logger from '../utils/logger.js';
 
9
  import { parseEnvFile, updateEnvFile } from '../utils/envParser.js';
10
  import { reloadConfig } from '../utils/configReloader.js';
 
11
  import { deepMerge } from '../utils/deepMerge.js';
12
  import { getModelsWithQuotas } from '../api/client.js';
13
  import path from 'path';
 
109
  }
110
 
111
  try {
112
+ const account = await oauthManager.authenticate(code, port);
113
+ const message = account.hasQuota
114
+ ? 'Token添加成功'
115
+ : 'Token添加成功(该账号无资格,已自动使用随机ProjectId)';
116
+ res.json({ success: true, data: account, message, fallbackMode: !account.hasQuota });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  } catch (error) {
118
+ logger.error('认证失败:', error.message);
119
  res.status(500).json({ success: false, message: error.message });
120
  }
121
  });
 
137
  try {
138
  const { env: envUpdates, json: jsonUpdates } = req.body;
139
 
140
+ if (envUpdates) updateEnvFile(envPath, envUpdates);
141
+ if (jsonUpdates) saveConfigJson(deepMerge(getConfigJson(), jsonUpdates));
 
 
 
 
 
 
 
142
 
143
  dotenv.config({ override: true });
144
  reloadConfig();
 
179
  // 更新内存中的配置
180
  tokenManager.updateRotationConfig(strategy, requestCount);
181
 
182
+ // 保存到config.json
183
  const currentConfig = getConfigJson();
184
  if (!currentConfig.rotation) currentConfig.rotation = {};
185
  if (strategy) currentConfig.rotation.strategy = strategy;
186
  if (requestCount) currentConfig.rotation.requestCount = requestCount;
187
  saveConfigJson(currentConfig);
188
 
189
+ // 重载配置到内存
190
+ reloadConfig();
191
+
192
  logger.info(`轮询策略已更新: ${strategy || '未变'}, 请求次数: ${requestCount || '未变'}`);
193
  res.json({ success: true, message: '轮询策略已更新', data: tokenManager.getRotationConfig() });
194
  } catch (error) {
src/utils/configReloader.js CHANGED
@@ -1,68 +1,9 @@
1
- import config, { getConfigJson, getProxyConfig } from '../config/config.js';
2
-
3
- /**
4
- * 配置字段映射表:config对象路径 -> config.json路径 / 环境变量
5
- */
6
- const CONFIG_MAPPING = [
7
- { target: 'server.port', source: 'server.port', default: 8045 },
8
- { target: 'server.host', source: 'server.host', default: '0.0.0.0' },
9
- { target: 'defaults.temperature', source: 'defaults.temperature', default: 1 },
10
- { target: 'defaults.top_p', source: 'defaults.topP', default: 0.85 },
11
- { target: 'defaults.top_k', source: 'defaults.topK', default: 50 },
12
- { target: 'defaults.max_tokens', source: 'defaults.maxTokens', default: 32000 },
13
- { target: 'defaults.thinking_budget', source: 'defaults.thinkingBudget', default: 1024 },
14
- { target: 'timeout', source: 'other.timeout', default: 300000 },
15
- { target: 'skipProjectIdFetch', source: 'other.skipProjectIdFetch', default: false, transform: v => v === true },
16
- { target: 'maxImages', source: 'other.maxImages', default: 10 },
17
- { target: 'useNativeAxios', source: 'other.useNativeAxios', default: true, transform: v => v !== false },
18
- { target: 'api.url', source: 'api.url', default: 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse' },
19
- { target: 'api.modelsUrl', source: 'api.modelsUrl', default: 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels' },
20
- { target: 'api.noStreamUrl', source: 'api.noStreamUrl', default: 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent' },
21
- { target: 'api.host', source: 'api.host', default: 'daily-cloudcode-pa.sandbox.googleapis.com' },
22
- { target: 'api.userAgent', source: 'api.userAgent', default: 'antigravity/1.11.3 windows/amd64' }
23
- ];
24
-
25
- const ENV_MAPPING = [
26
- { target: 'security.apiKey', env: 'API_KEY', default: null },
27
- { target: 'systemInstruction', env: 'SYSTEM_INSTRUCTION', default: '' }
28
- ];
29
-
30
- /**
31
- * 从嵌套路径获取值
32
- */
33
- function getNestedValue(obj, path) {
34
- return path.split('.').reduce((acc, key) => acc?.[key], obj);
35
- }
36
-
37
- /**
38
- * 设置嵌套路径的值
39
- */
40
- function setNestedValue(obj, path, value) {
41
- const keys = path.split('.');
42
- const lastKey = keys.pop();
43
- const target = keys.reduce((acc, key) => acc[key], obj);
44
- target[lastKey] = value;
45
- }
46
 
47
  /**
48
  * 重新加载配置到 config 对象
49
  */
50
  export function reloadConfig() {
51
- const jsonConfig = getConfigJson();
52
-
53
- // 更新 JSON 配置
54
- CONFIG_MAPPING.forEach(({ target, source, default: defaultValue, transform }) => {
55
- let value = getNestedValue(jsonConfig, source) ?? defaultValue;
56
- if (transform) value = transform(value);
57
- setNestedValue(config, target, value);
58
- });
59
-
60
- // 更新环境变量配置
61
- ENV_MAPPING.forEach(({ target, env, default: defaultValue }) => {
62
- const value = process.env[env] || defaultValue;
63
- setNestedValue(config, target, value);
64
- });
65
-
66
- // 单独处理代理配置(支持系统代理环境变量)
67
- config.proxy = getProxyConfig();
68
  }
 
1
+ import config, { getConfigJson, buildConfig } from '../config/config.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  /**
4
  * 重新加载配置到 config 对象
5
  */
6
  export function reloadConfig() {
7
+ const newConfig = buildConfig(getConfigJson());
8
+ Object.assign(config, newConfig);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
src/utils/utils.js CHANGED
@@ -205,11 +205,8 @@ function handleToolCall(message, antigravityMessages){
205
  function openaiMessageToAntigravity(openaiMessages, enableThinking, actualModelName, sessionId){
206
  const antigravityMessages = [];
207
  for (const message of openaiMessages) {
208
- if (message.role === "user") {
209
- const extracted = extractImagesFromContent(message.content);
210
- handleUserMessage(extracted, antigravityMessages);
211
- } else if (message.role === "system") {
212
- // 中间的 system 消息作为 user 处理(开头的 system 已在 generateRequestBody 中过滤)
213
  const extracted = extractImagesFromContent(message.content);
214
  handleUserMessage(extracted, antigravityMessages);
215
  } else if (message.role === "assistant") {
@@ -226,13 +223,18 @@ function openaiMessageToAntigravity(openaiMessages, enableThinking, actualModelN
226
  * 从 OpenAI 消息中提取并合并 system 指令
227
  * 规则:
228
  * 1. SYSTEM_INSTRUCTION 作为基础 system,可为空
229
- * 2. 保留用户首条 system 信息,合并在基础 system 后面
230
- * 3. 如果连续多条 system合并成一条 system
231
- * 4. 避免把真正的 system 重复作为 user 发送
232
  */
233
  function extractSystemInstruction(openaiMessages) {
234
  const baseSystem = config.systemInstruction || '';
235
 
 
 
 
 
 
236
  // 收集开头连续的 system 消息
237
  const systemTexts = [];
238
  for (const message of openaiMessages) {
@@ -414,13 +416,16 @@ function generateRequestBody(openaiMessages,modelName,parameters,openaiTools,tok
414
  // 提取合并后的 system 指令
415
  const mergedSystemInstruction = extractSystemInstruction(openaiMessages);
416
 
417
- // 过滤掉开头连续的 system 消息,避免重复作为 user 发送
418
  let startIndex = 0;
419
- for (let i = 0; i < openaiMessages.length; i++) {
420
- if (openaiMessages[i].role === 'system') {
421
- startIndex = i + 1;
422
- } else {
423
- break;
 
 
 
424
  }
425
  }
426
  const filteredMessages = openaiMessages.slice(startIndex);
 
205
  function openaiMessageToAntigravity(openaiMessages, enableThinking, actualModelName, sessionId){
206
  const antigravityMessages = [];
207
  for (const message of openaiMessages) {
208
+ if (message.role === "user" || message.role === "system") {
209
+ // system 消息作为 user 处理(开头的 system 已在 generateRequestBody 中过滤)
 
 
 
210
  const extracted = extractImagesFromContent(message.content);
211
  handleUserMessage(extracted, antigravityMessages);
212
  } else if (message.role === "assistant") {
 
223
  * 从 OpenAI 消息中提取并合并 system 指令
224
  * 规则:
225
  * 1. SYSTEM_INSTRUCTION 作为基础 system,可为空
226
+ * 2. 根据 useContextSystemPrompt 配置决定是否收集请求中的 system 消息
227
+ * 3. 如果 useContextSystemPrompt=true,收集开头连续 system 消息并合并
228
+ * 4. 如果 useContextSystemPrompt=false,只使用基础 SYSTEM_INSTRUCTION
229
  */
230
  function extractSystemInstruction(openaiMessages) {
231
  const baseSystem = config.systemInstruction || '';
232
 
233
+ // 如果不使用上下文 system,只返回基础 system
234
+ if (!config.useContextSystemPrompt) {
235
+ return baseSystem;
236
+ }
237
+
238
  // 收集开头连续的 system 消息
239
  const systemTexts = [];
240
  for (const message of openaiMessages) {
 
416
  // 提取合并后的 system 指令
417
  const mergedSystemInstruction = extractSystemInstruction(openaiMessages);
418
 
419
+ // 根据 useContextSystemPrompt 配置决定如何处理 system 消息
420
  let startIndex = 0;
421
+ if (config.useContextSystemPrompt) {
422
+ // 过滤掉开头连续的 system 消息,避免重复作为 user 发送
423
+ for (let i = 0; i < openaiMessages.length; i++) {
424
+ if (openaiMessages[i].role === 'system') {
425
+ startIndex = i + 1;
426
+ } else {
427
+ break;
428
+ }
429
  }
430
  }
431
  const filteredMessages = openaiMessages.slice(startIndex);