优化代码结构、前端逻辑、认证提示、前端添加原生axios和系统提示词收集配置的配置
Browse files- config.json +2 -1
- public/app.js +0 -1362
- public/index.html +29 -6
- public/js/README.md +96 -0
- public/js/auth.js +158 -0
- public/js/config.js +189 -0
- public/js/main.js +56 -0
- public/js/quota.js +394 -0
- public/js/tokens.js +361 -0
- public/js/ui.js +68 -0
- public/js/utils.js +65 -0
- public/style.css +56 -4
- scripts/oauth-server.js +10 -111
- src/auth/oauth_manager.js +146 -0
- src/config/config.js +59 -49
- src/routes/admin.js +13 -82
- src/utils/configReloader.js +3 -62
- src/utils/utils.js +19 -14
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"
|
| 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"
|
| 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"
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
<input type="number" name="HEARTBEAT_INTERVAL" placeholder="15000">
|
| 192 |
</div>
|
| 193 |
<div class="form-group compact">
|
| 194 |
-
<label>内存阈值(MB) <span class="help-tip"
|
| 195 |
<input type="number" name="MEMORY_THRESHOLD" placeholder="100">
|
| 196 |
</div>
|
| 197 |
</div>
|
|
@@ -214,6 +230,13 @@
|
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 额度耗尽:用完额度才切换 自定义次数:指定次数后切换">?</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
|
| 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 |
-
|
| 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(
|
| 135 |
-
|
| 136 |
setTimeout(() => server.close(), 1000);
|
| 137 |
}).catch(err => {
|
| 138 |
-
log.error('
|
| 139 |
-
|
| 140 |
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
| 141 |
-
res.end('<h1>
|
| 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 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
log.info('✓ 配置加载成功');
|
| 149 |
|
|
@@ -157,5 +165,7 @@ export function getConfigJson() {
|
|
| 157 |
}
|
| 158 |
|
| 159 |
export function saveConfigJson(data) {
|
| 160 |
-
|
| 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
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 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('
|
| 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 |
-
|
| 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 |
-
//
|
| 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,
|
| 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
|
| 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 |
-
|
| 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.
|
| 230 |
-
* 3. 如果连续
|
| 231 |
-
* 4.
|
| 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 |
-
//
|
| 418 |
let startIndex = 0;
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
| 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);
|