mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
refactor(app): modularize UI and usage logic
This commit is contained in:
1293
src/modules/ai-providers.js
Normal file
1293
src/modules/ai-providers.js
Normal file
File diff suppressed because it is too large
Load Diff
273
src/modules/api-keys.js
Normal file
273
src/modules/api-keys.js
Normal file
@@ -0,0 +1,273 @@
|
||||
export const apiKeysModule = {
|
||||
// 加载API密钥
|
||||
async loadApiKeys() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['api-keys']) {
|
||||
this.renderApiKeys(config['api-keys']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载API密钥失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染API密钥列表
|
||||
renderApiKeys(keys) {
|
||||
const container = document.getElementById('api-keys-list');
|
||||
|
||||
if (keys.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-key"></i>
|
||||
<h3>${i18n.t('api_keys.empty_title')}</h3>
|
||||
<p>${i18n.t('api_keys.empty_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = keys.map((key, index) => {
|
||||
const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
|
||||
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
|
||||
const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '"');
|
||||
return `
|
||||
<div class="key-item">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${i18n.t('api_keys.item_title')} #${index + 1}</div>
|
||||
<div class="item-value">${maskedDisplay}</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn btn-secondary" onclick="manager.editApiKey(${index}, ${keyArgument})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteApiKey(${index})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
// 遮蔽API密钥显示
|
||||
maskApiKey(key) {
|
||||
if (key === null || key === undefined) {
|
||||
return '';
|
||||
}
|
||||
const normalizedKey = typeof key === 'string' ? key : String(key);
|
||||
if (normalizedKey.length > 8) {
|
||||
return normalizedKey.substring(0, 4) + '...' + normalizedKey.substring(normalizedKey.length - 4);
|
||||
} else if (normalizedKey.length > 4) {
|
||||
return normalizedKey.substring(0, 2) + '...' + normalizedKey.substring(normalizedKey.length - 2);
|
||||
} else if (normalizedKey.length > 2) {
|
||||
return normalizedKey.substring(0, 1) + '...' + normalizedKey.substring(normalizedKey.length - 1);
|
||||
}
|
||||
return normalizedKey;
|
||||
},
|
||||
|
||||
// HTML 转义,防止 XSS
|
||||
escapeHtml(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
// 兼容服务端返回的数组结构
|
||||
normalizeArrayResponse(data, key) {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data && Array.isArray(data[key])) {
|
||||
return data[key];
|
||||
}
|
||||
if (data && Array.isArray(data.items)) {
|
||||
return data.items;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
// 添加一行自定义请求头输入
|
||||
addHeaderField(wrapperId, header = {}) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'header-input-row';
|
||||
const keyValue = typeof header.key === 'string' ? header.key : '';
|
||||
const valueValue = typeof header.value === 'string' ? header.value : '';
|
||||
row.innerHTML = `
|
||||
<div class="input-group header-input-group">
|
||||
<input type="text" class="header-key-input" placeholder="${i18n.t('common.custom_headers_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
|
||||
<span class="header-separator">:</span>
|
||||
<input type="text" class="header-value-input" placeholder="${i18n.t('common.custom_headers_value_placeholder')}" value="${this.escapeHtml(valueValue)}">
|
||||
<button type="button" class="btn btn-small btn-danger header-remove-btn"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const removeBtn = row.querySelector('.header-remove-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
wrapper.removeChild(row);
|
||||
if (wrapper.childElementCount === 0) {
|
||||
this.addHeaderField(wrapperId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wrapper.appendChild(row);
|
||||
},
|
||||
|
||||
// 填充自定义请求头输入
|
||||
populateHeaderFields(wrapperId, headers = null) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return;
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
const entries = (headers && typeof headers === 'object')
|
||||
? Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null)
|
||||
: [];
|
||||
|
||||
if (!entries.length) {
|
||||
this.addHeaderField(wrapperId);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(([key, value]) => this.addHeaderField(wrapperId, { key, value: String(value ?? '') }));
|
||||
},
|
||||
|
||||
// 收集自定义请求头输入
|
||||
collectHeaderInputs(wrapperId) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return null;
|
||||
|
||||
const rows = Array.from(wrapper.querySelectorAll('.header-input-row'));
|
||||
const headers = {};
|
||||
|
||||
rows.forEach(row => {
|
||||
const keyInput = row.querySelector('.header-key-input');
|
||||
const valueInput = row.querySelector('.header-value-input');
|
||||
const key = keyInput ? keyInput.value.trim() : '';
|
||||
const value = valueInput ? valueInput.value.trim() : '';
|
||||
if (key && value) {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(headers).length ? headers : null;
|
||||
},
|
||||
|
||||
addApiKeyEntryField(wrapperId, entry = {}) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'api-key-input-row';
|
||||
const keyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : '';
|
||||
const proxyValue = typeof entry?.['proxy-url'] === 'string' ? entry['proxy-url'] : '';
|
||||
row.innerHTML = `
|
||||
<div class="input-group api-key-input-group">
|
||||
<input type="text" class="api-key-value-input" placeholder="${i18n.t('ai_providers.openai_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
|
||||
<input type="text" class="api-key-proxy-input" placeholder="${i18n.t('ai_providers.openai_proxy_placeholder')}" value="${this.escapeHtml(proxyValue)}">
|
||||
<button type="button" class="btn btn-small btn-danger api-key-remove-btn"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const removeBtn = row.querySelector('.api-key-remove-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
wrapper.removeChild(row);
|
||||
if (wrapper.childElementCount === 0) {
|
||||
this.addApiKeyEntryField(wrapperId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wrapper.appendChild(row);
|
||||
},
|
||||
|
||||
populateApiKeyEntryFields(wrapperId, entries = []) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return;
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
this.addApiKeyEntryField(wrapperId);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(entry => this.addApiKeyEntryField(wrapperId, entry));
|
||||
},
|
||||
|
||||
collectApiKeyEntryInputs(wrapperId) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
if (!wrapper) return [];
|
||||
|
||||
const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row'));
|
||||
const entries = [];
|
||||
|
||||
rows.forEach(row => {
|
||||
const keyInput = row.querySelector('.api-key-value-input');
|
||||
const proxyInput = row.querySelector('.api-key-proxy-input');
|
||||
const key = keyInput ? keyInput.value.trim() : '';
|
||||
const proxy = proxyInput ? proxyInput.value.trim() : '';
|
||||
if (key) {
|
||||
entries.push({ 'api-key': key, 'proxy-url': proxy });
|
||||
}
|
||||
});
|
||||
|
||||
return entries;
|
||||
},
|
||||
|
||||
// 规范化并写入请求头
|
||||
applyHeadersToConfig(target, headers) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (headers && typeof headers === 'object' && Object.keys(headers).length) {
|
||||
target.headers = { ...headers };
|
||||
} else {
|
||||
delete target.headers;
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染请求头徽章
|
||||
renderHeaderBadges(headers) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const entries = Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null && value !== '');
|
||||
if (!entries.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const badges = entries.map(([key, value]) => `
|
||||
<span class="header-badge"><strong>${this.escapeHtml(key)}:</strong> ${this.escapeHtml(String(value))}</span>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="item-subtitle header-badges-wrapper">
|
||||
<span class="header-badges-label">${i18n.t('common.custom_headers_label')}:</span>
|
||||
<div class="header-badge-list">
|
||||
${badges}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// 构造Codex配置,保持未展示的字段
|
||||
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) {
|
||||
const result = {
|
||||
...original,
|
||||
'api-key': apiKey,
|
||||
'base-url': baseUrl || '',
|
||||
'proxy-url': proxyUrl || ''
|
||||
};
|
||||
this.applyHeadersToConfig(result, headers);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
967
src/modules/auth-files.js
Normal file
967
src/modules/auth-files.js
Normal file
@@ -0,0 +1,967 @@
|
||||
export const authFilesModule = {
|
||||
// 加载认证文件
|
||||
async loadAuthFiles(keyStats = null) {
|
||||
try {
|
||||
const data = await this.makeRequest('/auth-files');
|
||||
if (!keyStats) {
|
||||
keyStats = await this.getKeyStats();
|
||||
}
|
||||
await this.renderAuthFiles(data.files || [], keyStats);
|
||||
} catch (error) {
|
||||
console.error('加载认证文件失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染认证文件列表
|
||||
async renderAuthFiles(files, keyStats = null) {
|
||||
const container = document.getElementById('auth-files-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = Array.isArray(files) ? files : [];
|
||||
const visibleFiles = allFiles.filter(file => {
|
||||
if (!file) return false;
|
||||
return this.shouldDisplayDisabledGeminiCli(file) || file.disabled !== true;
|
||||
});
|
||||
const stats = keyStats || await this.getKeyStats();
|
||||
|
||||
this.cachedAuthFiles = visibleFiles.map(file => ({ ...file }));
|
||||
this.authFileStatsCache = stats || {};
|
||||
this.syncAuthFileControls();
|
||||
|
||||
if (this.cachedAuthFiles.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>${i18n.t('auth_files.empty_title')}</h3>
|
||||
<p>${i18n.t('auth_files.empty_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
this.updateFilterButtons(new Set(['all']));
|
||||
this.bindAuthFileFilterEvents();
|
||||
this.applyAuthFileFilterState(false);
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
this.authFilesPagination.totalPages = 1;
|
||||
this.updatePaginationControls(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTypes = new Set(['all']);
|
||||
this.cachedAuthFiles.forEach(file => {
|
||||
if (file.type) {
|
||||
existingTypes.add(file.type);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateFilterButtons(existingTypes);
|
||||
this.bindAuthFileFilterEvents();
|
||||
this.applyAuthFileFilterState(false);
|
||||
this.renderAuthFilesPage(this.authFilesPagination.currentPage);
|
||||
this.bindAuthFileActionEvents();
|
||||
},
|
||||
|
||||
isRuntimeOnlyAuthFile(file) {
|
||||
if (!file) return false;
|
||||
const runtimeValue = file.runtime_only;
|
||||
return runtimeValue === true || runtimeValue === 'true';
|
||||
},
|
||||
|
||||
shouldDisplayDisabledGeminiCli(file) {
|
||||
if (!file) return false;
|
||||
const provider = typeof file.provider === 'string' ? file.provider.toLowerCase() : '';
|
||||
const type = typeof file.type === 'string' ? file.type.toLowerCase() : '';
|
||||
const isGeminiCli = provider === 'gemini-cli' || type === 'gemini-cli';
|
||||
return isGeminiCli && !this.isRuntimeOnlyAuthFile(file);
|
||||
},
|
||||
|
||||
resolveAuthFileStats(file, stats = {}) {
|
||||
const rawFileName = typeof file?.name === 'string' ? file.name : '';
|
||||
const defaultStats = { success: 0, failure: 0 };
|
||||
if (!rawFileName) {
|
||||
return defaultStats;
|
||||
}
|
||||
|
||||
const fromName = stats[rawFileName];
|
||||
if (fromName && (fromName.success > 0 || fromName.failure > 0)) {
|
||||
return fromName;
|
||||
}
|
||||
|
||||
let fileStats = fromName || defaultStats;
|
||||
if (fileStats.success === 0 && fileStats.failure === 0) {
|
||||
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, "");
|
||||
|
||||
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||
const candidateNames = new Set([nameWithoutExt]);
|
||||
const normalizedName = nameWithoutExt.toLowerCase();
|
||||
const typePrefix = typeof file?.type === 'string' ? file.type.trim().toLowerCase() : '';
|
||||
const providerPrefix = typeof file?.provider === 'string' ? file.provider.trim().toLowerCase() : '';
|
||||
const prefixList = [];
|
||||
|
||||
if (typePrefix) {
|
||||
prefixList.push(`${typePrefix}-`);
|
||||
}
|
||||
if (providerPrefix && providerPrefix !== typePrefix) {
|
||||
prefixList.push(`${providerPrefix}-`);
|
||||
}
|
||||
|
||||
prefixList.forEach(prefix => {
|
||||
if (prefix && normalizedName.startsWith(prefix)) {
|
||||
const trimmed = nameWithoutExt.substring(prefix.length);
|
||||
if (trimmed) {
|
||||
candidateNames.add(trimmed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const candidate of candidateNames) {
|
||||
const candidateStats = stats[candidate];
|
||||
if (candidateStats && (candidateStats.success > 0 || candidateStats.failure > 0)) {
|
||||
fileStats = candidateStats;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileStats || defaultStats;
|
||||
},
|
||||
|
||||
buildAuthFileItemHtml(file) {
|
||||
const rawFileName = typeof file?.name === 'string' ? file.name : '';
|
||||
const safeFileName = this.escapeHtml(rawFileName);
|
||||
const stats = this.authFileStatsCache || {};
|
||||
const fileStats = this.resolveAuthFileStats(file, stats);
|
||||
const fileType = file.type || 'unknown';
|
||||
let typeDisplayKey;
|
||||
switch (fileType) {
|
||||
case 'qwen':
|
||||
typeDisplayKey = 'auth_files.type_qwen';
|
||||
break;
|
||||
case 'gemini':
|
||||
typeDisplayKey = 'auth_files.type_gemini';
|
||||
break;
|
||||
case 'gemini-cli':
|
||||
typeDisplayKey = 'auth_files.type_gemini-cli';
|
||||
break;
|
||||
case 'aistudio':
|
||||
typeDisplayKey = 'auth_files.type_aistudio';
|
||||
break;
|
||||
case 'claude':
|
||||
typeDisplayKey = 'auth_files.type_claude';
|
||||
break;
|
||||
case 'codex':
|
||||
typeDisplayKey = 'auth_files.type_codex';
|
||||
break;
|
||||
case 'iflow':
|
||||
typeDisplayKey = 'auth_files.type_iflow';
|
||||
break;
|
||||
case 'vertex':
|
||||
typeDisplayKey = 'auth_files.type_vertex';
|
||||
break;
|
||||
case 'empty':
|
||||
typeDisplayKey = 'auth_files.type_empty';
|
||||
break;
|
||||
default:
|
||||
typeDisplayKey = 'auth_files.type_unknown';
|
||||
break;
|
||||
}
|
||||
const typeBadge = `<span class="file-type-badge ${fileType}">${i18n.t(typeDisplayKey)}</span>`;
|
||||
const isRuntimeOnly = this.isRuntimeOnlyAuthFile(file);
|
||||
const shouldShowMainFlag = this.shouldDisplayDisabledGeminiCli(file);
|
||||
const mainFlagButton = shouldShowMainFlag ? `
|
||||
<button class="btn-small btn-warning main-flag-btn" title="主" disabled>主</button>` : '';
|
||||
const actionsHtml = isRuntimeOnly ? `
|
||||
<div class="item-actions">
|
||||
<span class="virtual-auth-badge">虚拟认证文件</span>
|
||||
</div>` : `
|
||||
<div class="item-actions" data-filename="${safeFileName}">
|
||||
${mainFlagButton}
|
||||
<button class="btn-small btn-info" data-action="showDetails" title="详细信息">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</button>
|
||||
<button class="btn-small btn-primary" data-action="download" title="下载">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn-small btn-danger" data-action="delete" title="删除">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-type="${fileType}" data-file-name="${safeFileName}" ${isRuntimeOnly ? 'data-runtime-only="true"' : ''}>
|
||||
<div class="item-content">
|
||||
<div class="item-title">${typeBadge}${safeFileName}</div>
|
||||
<div class="item-meta">
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')}</span>
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div class="item-footer">
|
||||
<div class="item-stats">
|
||||
<span class="stat-badge stat-success">
|
||||
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${fileStats.success}
|
||||
</span>
|
||||
<span class="stat-badge stat-failure">
|
||||
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${fileStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
${actionsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getFilteredAuthFiles(filterType = this.currentAuthFileFilter) {
|
||||
const files = Array.isArray(this.cachedAuthFiles) ? this.cachedAuthFiles : [];
|
||||
if (!files.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const typeFilter = (filterType || 'all').toLowerCase();
|
||||
const keyword = (this.authFileSearchQuery || '').trim().toLowerCase();
|
||||
|
||||
return files.filter(file => {
|
||||
const name = String(file?.name || '').toLowerCase();
|
||||
const type = String(file?.type || '').toLowerCase();
|
||||
const provider = String(file?.provider || '').toLowerCase();
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
if (!type) return false;
|
||||
if (type !== typeFilter) {
|
||||
if (!provider || provider !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyword) return true;
|
||||
return name.includes(keyword) || type.includes(keyword) || provider.includes(keyword);
|
||||
});
|
||||
},
|
||||
|
||||
updateAuthFileSearchQuery(value = '') {
|
||||
const normalized = (value || '').trim();
|
||||
if (this.authFileSearchQuery === normalized) {
|
||||
return;
|
||||
}
|
||||
this.authFileSearchQuery = normalized;
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
this.renderAuthFilesPage(1);
|
||||
},
|
||||
|
||||
updateAuthFilesPageSize(value) {
|
||||
const normalized = this.normalizeAuthFilesPageSize(value);
|
||||
if (this.authFilesPagination?.pageSize === normalized) {
|
||||
this.syncAuthFileControls();
|
||||
return;
|
||||
}
|
||||
this.authFilesPagination.pageSize = normalized;
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
try {
|
||||
localStorage.setItem(this.authFilesPageSizeKey, `${normalized}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist auth files page size:', error);
|
||||
}
|
||||
this.syncAuthFileControls();
|
||||
this.renderAuthFilesPage(1);
|
||||
},
|
||||
|
||||
syncAuthFileControls() {
|
||||
const searchInput = document.getElementById('auth-files-search-input');
|
||||
if (searchInput && searchInput.value !== this.authFileSearchQuery) {
|
||||
searchInput.value = this.authFileSearchQuery;
|
||||
}
|
||||
|
||||
const pageSizeInput = document.getElementById('auth-files-page-size-input');
|
||||
const targetSize = this.authFilesPagination?.pageSize || 9;
|
||||
if (pageSizeInput && parseInt(pageSizeInput.value, 10) !== targetSize) {
|
||||
pageSizeInput.value = targetSize;
|
||||
}
|
||||
},
|
||||
|
||||
bindAuthFilesSearchControl() {
|
||||
const searchInput = document.getElementById('auth-files-search-input');
|
||||
if (!searchInput) return;
|
||||
|
||||
if (searchInput._authFileSearchListener) {
|
||||
searchInput.removeEventListener('input', searchInput._authFileSearchListener);
|
||||
}
|
||||
|
||||
const debounced = this.debounce((value) => {
|
||||
this.updateAuthFileSearchQuery(value);
|
||||
}, 250);
|
||||
|
||||
const listener = (event) => {
|
||||
const value = event?.target?.value ?? '';
|
||||
debounced(value);
|
||||
};
|
||||
|
||||
searchInput._authFileSearchListener = listener;
|
||||
searchInput.addEventListener('input', listener);
|
||||
},
|
||||
|
||||
bindAuthFilesPageSizeControl() {
|
||||
const pageSizeInput = document.getElementById('auth-files-page-size-input');
|
||||
if (!pageSizeInput) return;
|
||||
|
||||
if (pageSizeInput._authFilePageSizeListener) {
|
||||
pageSizeInput.removeEventListener('change', pageSizeInput._authFilePageSizeListener);
|
||||
}
|
||||
|
||||
const listener = (event) => {
|
||||
const value = parseInt(event?.target?.value, 10);
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
this.updateAuthFilesPageSize(value);
|
||||
};
|
||||
|
||||
pageSizeInput._authFilePageSizeListener = listener;
|
||||
pageSizeInput.addEventListener('change', listener);
|
||||
|
||||
if (pageSizeInput._authFilePageSizeBlur) {
|
||||
pageSizeInput.removeEventListener('blur', pageSizeInput._authFilePageSizeBlur);
|
||||
}
|
||||
|
||||
const blurListener = () => {
|
||||
if (!pageSizeInput.value) {
|
||||
this.syncAuthFileControls();
|
||||
}
|
||||
};
|
||||
|
||||
pageSizeInput._authFilePageSizeBlur = blurListener;
|
||||
pageSizeInput.addEventListener('blur', blurListener);
|
||||
},
|
||||
|
||||
renderAuthFilesPage(page = null) {
|
||||
const container = document.getElementById('auth-files-list');
|
||||
if (!container) return;
|
||||
|
||||
const pageSize = this.authFilesPagination?.pageSize || 9;
|
||||
const filteredFiles = this.getFilteredAuthFiles();
|
||||
const totalItems = filteredFiles.length;
|
||||
const hasCachedFiles = Array.isArray(this.cachedAuthFiles) && this.cachedAuthFiles.length > 0;
|
||||
const filterApplied = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all');
|
||||
const searchApplied = Boolean((this.authFileSearchQuery || '').trim());
|
||||
|
||||
if (totalItems === 0) {
|
||||
const titleKey = hasCachedFiles && (filterApplied || searchApplied)
|
||||
? 'auth_files.search_empty_title'
|
||||
: 'auth_files.empty_title';
|
||||
const descKey = hasCachedFiles && (filterApplied || searchApplied)
|
||||
? 'auth_files.search_empty_desc'
|
||||
: 'auth_files.empty_desc';
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>${i18n.t(titleKey)}</h3>
|
||||
<p>${i18n.t(descKey)}</p>
|
||||
</div>
|
||||
`;
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
this.updatePaginationControls(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
let currentPage = page == null ? (this.authFilesPagination.currentPage || 1) : page;
|
||||
if (currentPage > maxPages) {
|
||||
currentPage = maxPages;
|
||||
}
|
||||
if (currentPage < 1) {
|
||||
currentPage = 1;
|
||||
}
|
||||
this.authFilesPagination.currentPage = currentPage;
|
||||
this.authFilesPagination.totalPages = maxPages;
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const pageFiles = filteredFiles.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
container.innerHTML = pageFiles.map(file => this.buildAuthFileItemHtml(file)).join('');
|
||||
this.updatePaginationControls(totalItems);
|
||||
this.bindAuthFileActionEvents();
|
||||
},
|
||||
|
||||
bindAuthFilesPaginationEvents() {
|
||||
const container = document.getElementById('auth-files-pagination');
|
||||
if (!container) return;
|
||||
|
||||
const oldListener = container._paginationListener;
|
||||
if (oldListener) {
|
||||
container.removeEventListener('click', oldListener);
|
||||
}
|
||||
|
||||
const listener = (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button || !container.contains(button)) return;
|
||||
event.preventDefault();
|
||||
const action = button.dataset.action;
|
||||
const currentPage = this.authFilesPagination?.currentPage || 1;
|
||||
if (action === 'prev') {
|
||||
this.renderAuthFilesPage(currentPage - 1);
|
||||
} else if (action === 'next') {
|
||||
this.renderAuthFilesPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
container._paginationListener = listener;
|
||||
container.addEventListener('click', listener);
|
||||
},
|
||||
|
||||
updatePaginationControls(totalItems = 0) {
|
||||
const paginationContainer = document.getElementById('auth-files-pagination');
|
||||
const infoEl = document.getElementById('auth-files-pagination-info');
|
||||
if (!paginationContainer || !infoEl) return;
|
||||
|
||||
const pageSize = this.authFilesPagination?.pageSize || 9;
|
||||
const currentPage = this.authFilesPagination?.currentPage || 1;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
|
||||
const start = (totalItems === 0) ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const end = Math.min(totalItems, currentPage * pageSize);
|
||||
|
||||
infoEl.textContent = totalItems === 0
|
||||
? i18n.t('auth_files.pagination_empty')
|
||||
: i18n.t('auth_files.pagination_info')
|
||||
.replace('{start}', start)
|
||||
.replace('{end}', end)
|
||||
.replace('{total}', totalItems);
|
||||
|
||||
const prevBtn = paginationContainer.querySelector('button[data-page=\"prev\"]');
|
||||
const nextBtn = paginationContainer.querySelector('button[data-page=\"next\"]');
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
prevBtn.disabled = currentPage <= 1;
|
||||
nextBtn.disabled = currentPage >= totalPages;
|
||||
|
||||
prevBtn.onclick = () => {
|
||||
if (this.authFilesPagination.currentPage <= 1) return;
|
||||
this.renderAuthFilesPage(currentPage - 1);
|
||||
};
|
||||
|
||||
nextBtn.onclick = () => {
|
||||
if (this.authFilesPagination.currentPage >= totalPages) return;
|
||||
this.renderAuthFilesPage(currentPage + 1);
|
||||
};
|
||||
},
|
||||
|
||||
updateFilterButtons(existingTypes) {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
const existingButtons = Array.from(filterContainer.querySelectorAll('.filter-btn'));
|
||||
const buttonMap = new Map();
|
||||
existingButtons.forEach(btn => {
|
||||
const type = btn.dataset.type || '';
|
||||
if (type) {
|
||||
buttonMap[type] = btn;
|
||||
}
|
||||
});
|
||||
|
||||
existingTypes.forEach(type => {
|
||||
const typeKey = type || 'all';
|
||||
const normalized = typeKey.toLowerCase();
|
||||
let btn = filterContainer.querySelector(`.filter-btn[data-type=\"${normalized}\"]`);
|
||||
|
||||
if (!btn) {
|
||||
const labelKey = normalized === 'all'
|
||||
? 'auth_files.filter_all'
|
||||
: `auth_files.filter_${normalized}`;
|
||||
const label = i18n.t(labelKey);
|
||||
btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-small filter-btn';
|
||||
btn.dataset.type = normalized;
|
||||
btn.setAttribute('data-i18n-text', labelKey);
|
||||
btn.textContent = label;
|
||||
const emptyBtn = filterContainer.querySelector('.filter-btn[data-type=\"all\"]');
|
||||
if (emptyBtn) {
|
||||
filterContainer.insertBefore(btn, emptyBtn);
|
||||
} else {
|
||||
filterContainer.appendChild(btn);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleFilterClick(clickedBtn, options = {}) {
|
||||
if (!clickedBtn) return;
|
||||
const { skipRender = false } = options;
|
||||
const filterBtns = document.querySelectorAll('.auth-file-filter .filter-btn');
|
||||
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
clickedBtn.classList.add('active');
|
||||
|
||||
const filterType = clickedBtn.dataset.type;
|
||||
this.currentAuthFileFilter = filterType || 'all';
|
||||
|
||||
if (!skipRender) {
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
this.renderAuthFilesPage(1);
|
||||
}
|
||||
|
||||
this.refreshFilterButtonTexts();
|
||||
},
|
||||
|
||||
generateDynamicTypeLabel(type) {
|
||||
if (!type) return '';
|
||||
const key = `auth_files.type_${type}`;
|
||||
const translated = i18n.t(key);
|
||||
if (translated && translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
},
|
||||
|
||||
bindAuthFileFilterEvents() {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
if (filterContainer._filterListener) {
|
||||
filterContainer.removeEventListener('click', filterContainer._filterListener);
|
||||
}
|
||||
|
||||
const listener = (event) => {
|
||||
const button = event.target.closest('.filter-btn');
|
||||
if (!button || !filterContainer.contains(button)) return;
|
||||
event.preventDefault();
|
||||
this.handleFilterClick(button);
|
||||
};
|
||||
|
||||
filterContainer._filterListener = listener;
|
||||
filterContainer.addEventListener('click', listener);
|
||||
|
||||
this.refreshFilterButtonTexts();
|
||||
},
|
||||
|
||||
applyAuthFileFilterState(shouldRender = false) {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
const currentType = this.currentAuthFileFilter || 'all';
|
||||
const buttons = filterContainer.querySelectorAll('.filter-btn');
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
let targetButton = null;
|
||||
buttons.forEach(btn => {
|
||||
if (btn.dataset.type === currentType) {
|
||||
targetButton = btn;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetButton) {
|
||||
targetButton = filterContainer.querySelector('.filter-btn[data-type=\"all\"]') || buttons[0];
|
||||
if (targetButton) {
|
||||
this.currentAuthFileFilter = targetButton.dataset.type || 'all';
|
||||
}
|
||||
}
|
||||
|
||||
if (targetButton) {
|
||||
this.handleFilterClick(targetButton, { skipRender: !shouldRender });
|
||||
}
|
||||
},
|
||||
|
||||
removeAuthFileElements(filenames = []) {
|
||||
if (!Array.isArray(filenames) || filenames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removalSet = new Set(filenames);
|
||||
this.cachedAuthFiles = (this.cachedAuthFiles || []).filter(file => file && !removalSet.has(file.name));
|
||||
|
||||
if (!this.cachedAuthFiles.length) {
|
||||
this.authFilesPagination.currentPage = 1;
|
||||
}
|
||||
this.renderAuthFilesPage(this.authFilesPagination.currentPage);
|
||||
},
|
||||
|
||||
refreshFilterButtonTexts() {
|
||||
document.querySelectorAll('.auth-file-filter .filter-btn[data-i18n-text]').forEach(btn => {
|
||||
const key = btn.getAttribute('data-i18n-text');
|
||||
if (key) {
|
||||
btn.textContent = i18n.t(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
bindAuthFileActionEvents() {
|
||||
const container = document.getElementById('auth-files-list');
|
||||
if (!container) return;
|
||||
|
||||
const oldListener = container._authFileActionListener;
|
||||
if (oldListener) {
|
||||
container.removeEventListener('click', oldListener);
|
||||
}
|
||||
|
||||
const listener = (e) => {
|
||||
const button = e.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
const actionsContainer = button.closest('.item-actions');
|
||||
if (!actionsContainer) return;
|
||||
|
||||
const filename = actionsContainer.dataset.filename;
|
||||
if (!filename) return;
|
||||
|
||||
switch (action) {
|
||||
case 'showDetails':
|
||||
this.showAuthFileDetails(filename);
|
||||
break;
|
||||
case 'download':
|
||||
this.downloadAuthFile(filename);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deleteAuthFile(filename);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
container._authFileActionListener = listener;
|
||||
container.addEventListener('click', listener);
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
openVertexFilePicker() {
|
||||
const fileInput = document.getElementById('vertex-file-input');
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
},
|
||||
|
||||
handleVertexFileSelection(event) {
|
||||
const fileInput = event?.target;
|
||||
const file = fileInput?.files?.[0] || null;
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
if (file && !file.name.toLowerCase().endsWith('.json')) {
|
||||
this.showNotification(i18n.t('vertex_import.file_required'), 'error');
|
||||
this.vertexImportState.file = null;
|
||||
this.updateVertexFileDisplay();
|
||||
this.updateVertexImportButtonState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.vertexImportState.file = file;
|
||||
this.updateVertexFileDisplay(file ? file.name : '');
|
||||
this.updateVertexImportButtonState();
|
||||
},
|
||||
|
||||
updateVertexFileDisplay(filename = '') {
|
||||
const displayInput = document.getElementById('vertex-file-display');
|
||||
if (!displayInput) return;
|
||||
displayInput.value = filename || '';
|
||||
},
|
||||
|
||||
updateVertexImportButtonState() {
|
||||
const importBtn = document.getElementById('vertex-import-btn');
|
||||
if (!importBtn) return;
|
||||
const disabled = !this.vertexImportState.file || this.vertexImportState.loading;
|
||||
importBtn.disabled = disabled;
|
||||
},
|
||||
|
||||
async importVertexCredential() {
|
||||
if (!this.vertexImportState.file) {
|
||||
this.showNotification(i18n.t('vertex_import.file_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const locationInput = document.getElementById('vertex-location');
|
||||
const location = locationInput ? locationInput.value.trim() : '';
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.vertexImportState.file, this.vertexImportState.file.name);
|
||||
if (location) {
|
||||
formData.append('location', location);
|
||||
}
|
||||
|
||||
try {
|
||||
this.vertexImportState.loading = true;
|
||||
this.updateVertexImportButtonState();
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/vertex/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData?.message || errorData?.error || errorMessage;
|
||||
} catch (parseError) {
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
errorMessage = text;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.vertexImportState.result = result;
|
||||
this.renderVertexImportResult(result);
|
||||
this.showNotification(i18n.t('vertex_import.success'), 'success');
|
||||
this.vertexImportState.file = null;
|
||||
this.updateVertexFileDisplay('');
|
||||
} catch (error) {
|
||||
console.error('Vertex credential import failed:', error);
|
||||
this.showNotification(`${i18n.t('notification.import_failed')}: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.vertexImportState.loading = false;
|
||||
this.updateVertexImportButtonState();
|
||||
const fileInput = document.getElementById('vertex-file-input');
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderVertexImportResult(result = null) {
|
||||
const container = document.getElementById('vertex-import-result');
|
||||
const projectEl = document.getElementById('vertex-result-project');
|
||||
const emailEl = document.getElementById('vertex-result-email');
|
||||
const locationEl = document.getElementById('vertex-result-location');
|
||||
const fileEl = document.getElementById('vertex-result-file');
|
||||
if (!container || !projectEl || !emailEl || !locationEl || !fileEl) return;
|
||||
|
||||
if (!result) {
|
||||
container.style.display = 'none';
|
||||
this.vertexImportState.result = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.vertexImportState.result = result;
|
||||
projectEl.textContent = result.project || '-';
|
||||
emailEl.textContent = result.email || '-';
|
||||
locationEl.textContent = result.location || '-';
|
||||
fileEl.textContent = result.file || '-';
|
||||
container.style.display = 'block';
|
||||
},
|
||||
|
||||
showAuthFileDetails(filename, content) {
|
||||
const detailsElement = document.getElementById('auth-file-details');
|
||||
if (!detailsElement) return;
|
||||
|
||||
const file = (this.cachedAuthFiles || []).find(f => f && f.name === filename);
|
||||
if (!file) return;
|
||||
|
||||
const stats = this.resolveAuthFileStats(file, this.authFileStatsCache || {});
|
||||
const size = typeof file.size === 'number' ? this.formatFileSize(file.size) : '-';
|
||||
const provider = file.provider || '-';
|
||||
const type = file.type || '-';
|
||||
const createdAt = file.created_at ? new Date(file.created_at).toLocaleString('zh-CN') : '-';
|
||||
|
||||
const jsonContent = content || JSON.stringify(file, null, 2);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="json-modal">
|
||||
<div class="modal json-modal">
|
||||
<div class="modal-header">
|
||||
<h3>${i18n.t('auth_files.details_title')} - ${this.escapeHtml(filename)}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="auth-file-meta">
|
||||
<div><strong>${i18n.t('auth_files.details_type')}:</strong> ${this.escapeHtml(type)}</div>
|
||||
<div><strong>${i18n.t('auth_files.details_provider')}:</strong> ${this.escapeHtml(provider)}</div>
|
||||
<div><strong>${i18n.t('auth_files.details_size')}:</strong> ${this.escapeHtml(size)}</div>
|
||||
<div><strong>${i18n.t('auth_files.details_created_at')}:</strong> ${this.escapeHtml(createdAt)}</div>
|
||||
<div><strong>${i18n.t('auth_files.details_success')}:</strong> ${stats.success}</div>
|
||||
<div><strong>${i18n.t('auth_files.details_failure')}:</strong> ${stats.failure}</div>
|
||||
</div>
|
||||
<pre class="json-content">${this.escapeHtml(jsonContent)}</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-action="copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
${i18n.t('common.copy')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" data-action="close">
|
||||
${i18n.t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const oldModal = document.getElementById('json-modal');
|
||||
if (oldModal) {
|
||||
oldModal.remove();
|
||||
}
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = document.getElementById('json-modal');
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.closeJsonModal();
|
||||
}
|
||||
});
|
||||
|
||||
this.bindJsonModalEvents(modal);
|
||||
},
|
||||
|
||||
bindJsonModalEvents(modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
const button = e.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
this.copyJsonContent();
|
||||
break;
|
||||
case 'close':
|
||||
this.closeJsonModal();
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
closeJsonModal() {
|
||||
const modal = document.getElementById('json-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
},
|
||||
|
||||
copyJsonContent() {
|
||||
const jsonContent = document.querySelector('.json-content');
|
||||
if (jsonContent) {
|
||||
const text = jsonContent.textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.showNotification('内容已复制到剪贴板', 'success');
|
||||
}).catch(() => {
|
||||
this.showNotification('复制失败', 'error');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async downloadAuthFile(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
this.showNotification(i18n.t('auth_files.download_success'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAuthFile(filename) {
|
||||
if (!confirm(`${i18n.t('auth_files.delete_confirm')} "${filename}" 吗?`)) return;
|
||||
|
||||
try {
|
||||
await this.makeRequest(`/auth-files?name=${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
this.removeAuthFileElements([filename]);
|
||||
this.clearCache();
|
||||
await this.loadAuthFiles();
|
||||
this.showNotification(i18n.t('auth_files.delete_success'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAllAuthFiles() {
|
||||
const filterType = (this.currentAuthFileFilter || 'all').toLowerCase();
|
||||
const isFiltered = filterType !== 'all';
|
||||
const typeLabel = this.generateDynamicTypeLabel(filterType);
|
||||
const confirmMessage = isFiltered
|
||||
? i18n.t('auth_files.delete_filtered_confirm').replace('{type}', typeLabel)
|
||||
: i18n.t('auth_files.delete_all_confirm');
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
if (!isFiltered) {
|
||||
const response = await this.makeRequest('/auth-files?all=true', { method: 'DELETE' });
|
||||
const currentNames = (this.cachedAuthFiles || []).map(file => file.name).filter(Boolean);
|
||||
if (currentNames.length > 0) {
|
||||
this.removeAuthFileElements(currentNames);
|
||||
}
|
||||
this.clearCache();
|
||||
this.currentAuthFileFilter = 'all';
|
||||
await this.loadAuthFiles();
|
||||
this.showNotification(`${i18n.t('auth_files.delete_all_success')} ${response.deleted} ${i18n.t('auth_files.files_count')}`, 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
const deletableFiles = (this.cachedAuthFiles || []).filter(file => {
|
||||
if (!file || file.runtime_only) return false;
|
||||
const fileType = (file.type || 'unknown').toLowerCase();
|
||||
return fileType === filterType;
|
||||
});
|
||||
|
||||
if (deletableFiles.length === 0) {
|
||||
this.showNotification(i18n.t('auth_files.delete_filtered_none').replace('{type}', typeLabel), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames = [];
|
||||
|
||||
for (const file of deletableFiles) {
|
||||
try {
|
||||
await this.makeRequest(`/auth-files?name=${encodeURIComponent(file.name)}`, { method: 'DELETE' });
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch (error) {
|
||||
console.error('删除认证文件失败:', file?.name, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedNames.length > 0) {
|
||||
this.removeAuthFileElements(deletedNames);
|
||||
}
|
||||
|
||||
this.clearCache();
|
||||
this.currentAuthFileFilter = 'all';
|
||||
await this.loadAuthFiles();
|
||||
|
||||
if (failed === 0) {
|
||||
const successMsg = i18n.t('auth_files.delete_filtered_success')
|
||||
.replace('{count}', success)
|
||||
.replace('{type}', typeLabel);
|
||||
this.showNotification(successMsg, 'success');
|
||||
} else {
|
||||
const warningMsg = i18n.t('auth_files.delete_filtered_partial')
|
||||
.replace('{success}', success)
|
||||
.replace('{failed}', failed)
|
||||
.replace('{type}', typeLabel);
|
||||
this.showNotification(warningMsg, 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
258
src/modules/config-editor.js
Normal file
258
src/modules/config-editor.js
Normal file
@@ -0,0 +1,258 @@
|
||||
export const configEditorModule = {
|
||||
setupConfigEditor() {
|
||||
const textarea = document.getElementById('config-editor');
|
||||
const saveBtn = document.getElementById('config-save-btn');
|
||||
const reloadBtn = document.getElementById('config-reload-btn');
|
||||
const statusEl = document.getElementById('config-editor-status');
|
||||
|
||||
this.configEditorElements = {
|
||||
textarea,
|
||||
editorInstance: null,
|
||||
saveBtn,
|
||||
reloadBtn,
|
||||
statusEl
|
||||
};
|
||||
|
||||
if (!textarea || !saveBtn || !reloadBtn || !statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.CodeMirror) {
|
||||
const editorInstance = window.CodeMirror.fromTextArea(textarea, {
|
||||
mode: 'yaml',
|
||||
theme: 'default',
|
||||
lineNumbers: true,
|
||||
indentUnit: 2,
|
||||
tabSize: 2,
|
||||
lineWrapping: true,
|
||||
autoCloseBrackets: true,
|
||||
extraKeys: {
|
||||
'Ctrl-/': 'toggleComment',
|
||||
'Cmd-/': 'toggleComment'
|
||||
}
|
||||
});
|
||||
|
||||
editorInstance.setSize('100%', '100%');
|
||||
editorInstance.on('change', () => {
|
||||
this.isConfigEditorDirty = true;
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
|
||||
});
|
||||
|
||||
this.configEditorElements.editorInstance = editorInstance;
|
||||
} else {
|
||||
textarea.addEventListener('input', () => {
|
||||
this.isConfigEditorDirty = true;
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
|
||||
});
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', () => this.saveConfigFile());
|
||||
reloadBtn.addEventListener('click', () => this.loadConfigFileEditor(true));
|
||||
|
||||
this.refreshConfigEditor();
|
||||
},
|
||||
|
||||
updateConfigEditorAvailability() {
|
||||
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
|
||||
if ((!textarea && !editorInstance) || !saveBtn || !reloadBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disabled = !this.isConnected;
|
||||
if (editorInstance) {
|
||||
editorInstance.setOption('readOnly', disabled ? 'nocursor' : false);
|
||||
const wrapper = editorInstance.getWrapperElement();
|
||||
if (wrapper) {
|
||||
wrapper.classList.toggle('cm-readonly', disabled);
|
||||
}
|
||||
} else if (textarea) {
|
||||
textarea.disabled = disabled;
|
||||
}
|
||||
|
||||
saveBtn.disabled = disabled;
|
||||
reloadBtn.disabled = disabled;
|
||||
|
||||
if (disabled) {
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
|
||||
}
|
||||
|
||||
this.refreshConfigEditor();
|
||||
this.lastEditorConnectionState = this.isConnected;
|
||||
},
|
||||
|
||||
refreshConfigEditor() {
|
||||
const instance = this.configEditorElements && this.configEditorElements.editorInstance;
|
||||
if (instance && typeof instance.refresh === 'function') {
|
||||
setTimeout(() => instance.refresh(), 0);
|
||||
}
|
||||
},
|
||||
|
||||
updateConfigEditorStatus(type, message) {
|
||||
const statusEl = (this.configEditorElements && this.configEditorElements.statusEl) || document.getElementById('config-editor-status');
|
||||
if (!statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = message;
|
||||
statusEl.classList.remove('success', 'error');
|
||||
|
||||
if (type === 'success') {
|
||||
statusEl.classList.add('success');
|
||||
} else if (type === 'error') {
|
||||
statusEl.classList.add('error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigFileEditor(forceRefresh = false) {
|
||||
const { textarea, editorInstance, reloadBtn } = this.configEditorElements || {};
|
||||
if (!textarea && !editorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (reloadBtn) {
|
||||
reloadBtn.disabled = true;
|
||||
}
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_loading'));
|
||||
|
||||
try {
|
||||
const yamlText = await this.fetchConfigFile(forceRefresh);
|
||||
|
||||
if (editorInstance) {
|
||||
editorInstance.setValue(yamlText || '');
|
||||
if (typeof editorInstance.markClean === 'function') {
|
||||
editorInstance.markClean();
|
||||
}
|
||||
} else if (textarea) {
|
||||
textarea.value = yamlText || '';
|
||||
}
|
||||
|
||||
this.isConfigEditorDirty = false;
|
||||
this.updateConfigEditorStatus('success', i18n.t('config_management.status_loaded'));
|
||||
this.refreshConfigEditor();
|
||||
} catch (error) {
|
||||
console.error('加载配置文件失败:', error);
|
||||
this.updateConfigEditorStatus('error', `${i18n.t('config_management.status_load_failed')}: ${error.message}`);
|
||||
} finally {
|
||||
if (reloadBtn) {
|
||||
reloadBtn.disabled = !this.isConnected;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchConfigFile(forceRefresh = false) {
|
||||
if (!forceRefresh && this.configYamlCache) {
|
||||
return this.configYamlCache;
|
||||
}
|
||||
|
||||
const requestUrl = '/config.yaml';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}${requestUrl}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`,
|
||||
'Accept': 'application/yaml'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const message = errorText || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!/yaml/i.test(contentType)) {
|
||||
throw new Error(i18n.t('config_management.error_yaml_not_supported'));
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
this.lastConfigFetchUrl = requestUrl;
|
||||
this.configYamlCache = text;
|
||||
return text;
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
},
|
||||
|
||||
async saveConfigFile() {
|
||||
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
|
||||
if ((!textarea && !editorInstance) || !saveBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.updateConfigEditorStatus('error', i18n.t('config_management.status_disconnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const yamlText = editorInstance ? editorInstance.getValue() : (textarea ? textarea.value : '');
|
||||
|
||||
saveBtn.disabled = true;
|
||||
if (reloadBtn) {
|
||||
reloadBtn.disabled = true;
|
||||
}
|
||||
this.updateConfigEditorStatus('info', i18n.t('config_management.status_saving'));
|
||||
|
||||
try {
|
||||
await this.writeConfigFile('/config.yaml', yamlText);
|
||||
this.lastConfigFetchUrl = '/config.yaml';
|
||||
this.configYamlCache = yamlText;
|
||||
this.isConfigEditorDirty = false;
|
||||
if (editorInstance && typeof editorInstance.markClean === 'function') {
|
||||
editorInstance.markClean();
|
||||
}
|
||||
this.showNotification(i18n.t('config_management.save_success'), 'success');
|
||||
this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved'));
|
||||
this.clearCache();
|
||||
await this.loadAllData(true);
|
||||
} catch (error) {
|
||||
const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`;
|
||||
this.updateConfigEditorStatus('error', errorMessage);
|
||||
this.showNotification(errorMessage, 'error');
|
||||
this.isConfigEditorDirty = true;
|
||||
} finally {
|
||||
saveBtn.disabled = !this.isConnected;
|
||||
if (reloadBtn) {
|
||||
reloadBtn.disabled = !this.isConnected;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async writeConfigFile(endpoint, yamlText) {
|
||||
const response = await fetch(`${this.apiUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`,
|
||||
'Content-Type': 'application/yaml',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: yamlText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let errorText = '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
errorText = data.message || data.error || '';
|
||||
} else {
|
||||
errorText = await response.text().catch(() => '');
|
||||
}
|
||||
throw new Error(errorText || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.ok === false) {
|
||||
throw new Error(data.message || data.error || 'Server rejected the update');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
26
src/modules/language.js
Normal file
26
src/modules/language.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export const languageModule = {
|
||||
setupLanguageSwitcher() {
|
||||
const loginToggle = document.getElementById('language-toggle');
|
||||
const mainToggle = document.getElementById('language-toggle-main');
|
||||
|
||||
if (loginToggle) {
|
||||
loginToggle.addEventListener('click', () => this.toggleLanguage());
|
||||
}
|
||||
if (mainToggle) {
|
||||
mainToggle.addEventListener('click', () => this.toggleLanguage());
|
||||
}
|
||||
},
|
||||
|
||||
toggleLanguage() {
|
||||
const currentLang = i18n.currentLanguage;
|
||||
const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
|
||||
i18n.setLanguage(newLang);
|
||||
|
||||
this.updateThemeButtons();
|
||||
this.updateConnectionStatus();
|
||||
|
||||
if (this.isLoggedIn && this.isConnected) {
|
||||
this.loadAllData(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
265
src/modules/login.js
Normal file
265
src/modules/login.js
Normal file
@@ -0,0 +1,265 @@
|
||||
export const loginModule = {
|
||||
async checkLoginStatus() {
|
||||
const savedBase = localStorage.getItem('apiBase');
|
||||
const savedKey = localStorage.getItem('managementKey');
|
||||
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
|
||||
if (savedBase && savedKey && wasLoggedIn) {
|
||||
try {
|
||||
console.log(i18n.t('auto_login.title'));
|
||||
this.showAutoLoginLoading();
|
||||
await this.attemptAutoLogin(savedBase, savedKey);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.log(`${i18n.t('notification.login_failed')}: ${error.message}`);
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
this.hideAutoLoginLoading();
|
||||
}
|
||||
}
|
||||
|
||||
this.showLoginPage();
|
||||
this.loadLoginSettings();
|
||||
},
|
||||
|
||||
showAutoLoginLoading() {
|
||||
document.getElementById('auto-login-loading').style.display = 'flex';
|
||||
document.getElementById('login-page').style.display = 'none';
|
||||
document.getElementById('main-page').style.display = 'none';
|
||||
},
|
||||
|
||||
hideAutoLoginLoading() {
|
||||
document.getElementById('auto-login-loading').style.display = 'none';
|
||||
},
|
||||
|
||||
async attemptAutoLogin(apiBase, managementKey) {
|
||||
try {
|
||||
this.setApiBase(apiBase);
|
||||
this.managementKey = managementKey;
|
||||
|
||||
const savedProxy = localStorage.getItem('proxyUrl');
|
||||
if (savedProxy) {
|
||||
// 代理设置会在后续的API请求中自动使用
|
||||
}
|
||||
|
||||
await this.testConnection();
|
||||
|
||||
this.isLoggedIn = true;
|
||||
this.hideAutoLoginLoading();
|
||||
this.showMainPage();
|
||||
|
||||
console.log(i18n.t('auto_login.title'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('自动登录失败:', error);
|
||||
this.isLoggedIn = false;
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
showLoginPage() {
|
||||
document.getElementById('login-page').style.display = 'flex';
|
||||
document.getElementById('main-page').style.display = 'none';
|
||||
this.isLoggedIn = false;
|
||||
this.updateLoginConnectionInfo();
|
||||
},
|
||||
|
||||
showMainPage() {
|
||||
document.getElementById('login-page').style.display = 'none';
|
||||
document.getElementById('main-page').style.display = 'block';
|
||||
this.isLoggedIn = true;
|
||||
this.updateConnectionInfo();
|
||||
},
|
||||
|
||||
async login(apiBase, managementKey) {
|
||||
try {
|
||||
this.setApiBase(apiBase);
|
||||
this.managementKey = managementKey;
|
||||
localStorage.setItem('managementKey', this.managementKey);
|
||||
|
||||
await this.testConnection();
|
||||
|
||||
this.isLoggedIn = true;
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
|
||||
this.showMainPage();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.isLoggedIn = false;
|
||||
this.isConnected = false;
|
||||
this.clearCache();
|
||||
this.stopStatusUpdateTimer();
|
||||
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('managementKey');
|
||||
|
||||
this.showLoginPage();
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
const apiBaseInput = document.getElementById('login-api-base');
|
||||
const managementKeyInput = document.getElementById('login-management-key');
|
||||
const managementKey = managementKeyInput ? managementKeyInput.value.trim() : '';
|
||||
|
||||
if (!managementKey) {
|
||||
this.showLoginError(i18n.t('login.error_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiBaseInput && apiBaseInput.value.trim()) {
|
||||
this.setApiBase(apiBaseInput.value.trim());
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('login-submit');
|
||||
const originalText = submitBtn ? submitBtn.innerHTML : '';
|
||||
|
||||
try {
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = `<div class=\"loading\"></div> ${i18n.t('login.submitting')}`;
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
this.hideLoginError();
|
||||
|
||||
this.managementKey = managementKey;
|
||||
localStorage.setItem('managementKey', this.managementKey);
|
||||
|
||||
await this.login(this.apiBase, this.managementKey);
|
||||
} catch (error) {
|
||||
this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`);
|
||||
} finally {
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleLoginKeyVisibility(button) {
|
||||
const inputGroup = button.closest('.input-group');
|
||||
const keyInput = inputGroup.querySelector('input[type=\"password\"], input[type=\"text\"]');
|
||||
|
||||
if (keyInput.type === 'password') {
|
||||
keyInput.type = 'text';
|
||||
button.innerHTML = '<i class=\"fas fa-eye-slash\"></i>';
|
||||
} else {
|
||||
keyInput.type = 'password';
|
||||
button.innerHTML = '<i class=\"fas fa-eye\"></i>';
|
||||
}
|
||||
},
|
||||
|
||||
showLoginError(message) {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
const errorMessage = document.getElementById('login-error-message');
|
||||
|
||||
errorMessage.textContent = message;
|
||||
errorDiv.style.display = 'flex';
|
||||
},
|
||||
|
||||
hideLoginError() {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.style.display = 'none';
|
||||
},
|
||||
|
||||
updateConnectionInfo() {
|
||||
const apiUrlElement = document.getElementById('display-api-url');
|
||||
const statusElement = document.getElementById('display-connection-status');
|
||||
|
||||
if (apiUrlElement) {
|
||||
apiUrlElement.textContent = this.apiBase || '-';
|
||||
}
|
||||
|
||||
if (statusElement) {
|
||||
let statusHtml = '';
|
||||
if (this.isConnected) {
|
||||
statusHtml = `<span class=\"status-indicator connected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.connected')}</span>`;
|
||||
} else {
|
||||
statusHtml = `<span class=\"status-indicator disconnected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.disconnected')}</span>`;
|
||||
}
|
||||
statusElement.innerHTML = statusHtml;
|
||||
}
|
||||
},
|
||||
|
||||
loadLoginSettings() {
|
||||
const savedBase = localStorage.getItem('apiBase');
|
||||
const savedKey = localStorage.getItem('managementKey');
|
||||
const loginKeyInput = document.getElementById('login-management-key');
|
||||
const apiBaseInput = document.getElementById('login-api-base');
|
||||
|
||||
if (savedBase) {
|
||||
this.setApiBase(savedBase);
|
||||
} else {
|
||||
this.setApiBase(this.detectApiBaseFromLocation());
|
||||
}
|
||||
|
||||
if (apiBaseInput) {
|
||||
apiBaseInput.value = this.apiBase || '';
|
||||
}
|
||||
|
||||
if (loginKeyInput && savedKey) {
|
||||
loginKeyInput.value = savedKey;
|
||||
}
|
||||
|
||||
this.setupLoginAutoSave();
|
||||
},
|
||||
|
||||
setupLoginAutoSave() {
|
||||
const loginKeyInput = document.getElementById('login-management-key');
|
||||
const apiBaseInput = document.getElementById('login-api-base');
|
||||
const resetButton = document.getElementById('login-reset-api-base');
|
||||
|
||||
const saveKey = (val) => {
|
||||
if (val.trim()) {
|
||||
this.managementKey = val;
|
||||
localStorage.setItem('managementKey', this.managementKey);
|
||||
}
|
||||
};
|
||||
const saveKeyDebounced = this.debounce(saveKey, 500);
|
||||
|
||||
if (loginKeyInput) {
|
||||
loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
|
||||
loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
|
||||
}
|
||||
|
||||
if (apiBaseInput) {
|
||||
const persistBase = (val) => {
|
||||
const normalized = this.normalizeBase(val);
|
||||
if (normalized) {
|
||||
this.setApiBase(normalized);
|
||||
}
|
||||
};
|
||||
const persistBaseDebounced = this.debounce(persistBase, 500);
|
||||
|
||||
apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value));
|
||||
apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value));
|
||||
}
|
||||
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener('click', () => {
|
||||
const detected = this.detectApiBaseFromLocation();
|
||||
this.setApiBase(detected);
|
||||
if (apiBaseInput) {
|
||||
apiBaseInput.value = detected;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.updateLoginConnectionInfo();
|
||||
},
|
||||
|
||||
updateLoginConnectionInfo() {
|
||||
const connectionUrlElement = document.getElementById('login-connection-url');
|
||||
const customInput = document.getElementById('login-api-base');
|
||||
if (connectionUrlElement) {
|
||||
connectionUrlElement.textContent = this.apiBase || '-';
|
||||
}
|
||||
if (customInput && customInput !== document.activeElement) {
|
||||
customInput.value = this.apiBase || '';
|
||||
}
|
||||
}
|
||||
};
|
||||
410
src/modules/logs.js
Normal file
410
src/modules/logs.js
Normal file
@@ -0,0 +1,410 @@
|
||||
export const logsModule = {
|
||||
toggleLogsNavItem(show) {
|
||||
const logsNavItem = document.getElementById('logs-nav-item');
|
||||
if (logsNavItem) {
|
||||
logsNavItem.style.display = show ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async refreshLogs(incremental = false) {
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
if (!logsContent) return;
|
||||
|
||||
try {
|
||||
if (incremental && !this.latestLogTimestamp) {
|
||||
incremental = false;
|
||||
}
|
||||
|
||||
if (!incremental) {
|
||||
logsContent.innerHTML = '<div class="loading-placeholder" data-i18n="logs.loading">' + i18n.t('logs.loading') + '</div>';
|
||||
}
|
||||
|
||||
let url = '/logs';
|
||||
if (incremental && this.latestLogTimestamp) {
|
||||
url += `?after=${this.latestLogTimestamp}`;
|
||||
}
|
||||
|
||||
const response = await this.makeRequest(url, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (response && response.lines) {
|
||||
if (response['latest-timestamp']) {
|
||||
this.latestLogTimestamp = response['latest-timestamp'];
|
||||
}
|
||||
|
||||
if (incremental && response.lines.length > 0) {
|
||||
this.appendLogs(response.lines, response['line-count'] || 0);
|
||||
} else if (!incremental && response.lines.length > 0) {
|
||||
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
|
||||
} else if (!incremental) {
|
||||
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
|
||||
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
|
||||
i18n.t('logs.empty_desc') + '</p></div>';
|
||||
this.latestLogTimestamp = null;
|
||||
}
|
||||
} else if (!incremental) {
|
||||
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
|
||||
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
|
||||
i18n.t('logs.empty_desc') + '</p></div>';
|
||||
this.latestLogTimestamp = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载日志失败:', error);
|
||||
if (!incremental) {
|
||||
const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found'));
|
||||
|
||||
if (is404) {
|
||||
logsContent.innerHTML = '<div class="upgrade-notice"><i class="fas fa-arrow-circle-up"></i><h3 data-i18n="logs.upgrade_required_title">' +
|
||||
i18n.t('logs.upgrade_required_title') + '</h3><p data-i18n="logs.upgrade_required_desc">' +
|
||||
i18n.t('logs.upgrade_required_desc') + '</p></div>';
|
||||
} else {
|
||||
logsContent.innerHTML = '<div class="error-state"><i class="fas fa-exclamation-triangle"></i><p data-i18n="logs.load_error">' +
|
||||
i18n.t('logs.load_error') + '</p><p>' + error.message + '</p></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderLogs(lines, lineCount, scrollToBottom = true) {
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
if (!logsContent) return;
|
||||
|
||||
if (!lines || lines.length === 0) {
|
||||
this.displayedLogLines = [];
|
||||
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
|
||||
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
|
||||
i18n.t('logs.empty_desc') + '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredLines = lines.filter(line => !line.includes('/v0/management/'));
|
||||
let displayedLines = filteredLines;
|
||||
if (filteredLines.length > this.maxDisplayLogLines) {
|
||||
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
|
||||
displayedLines = filteredLines.slice(linesToRemove);
|
||||
}
|
||||
|
||||
this.displayedLogLines = displayedLines.slice();
|
||||
|
||||
const displayedLineCount = this.displayedLogLines.length;
|
||||
logsContent.innerHTML = `
|
||||
<div class="logs-info">
|
||||
<span><i class="fas fa-list-ol"></i> ${displayedLineCount} ${i18n.t('logs.lines')}</span>
|
||||
</div>
|
||||
<pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre>
|
||||
`;
|
||||
|
||||
if (scrollToBottom) {
|
||||
const logsTextElement = logsContent.querySelector('.logs-text');
|
||||
if (logsTextElement) {
|
||||
logsTextElement.scrollTop = logsTextElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
appendLogs(newLines, totalLineCount) {
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
if (!logsContent) return;
|
||||
|
||||
if (!newLines || newLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logsTextElement = logsContent.querySelector('.logs-text');
|
||||
const logsInfoElement = logsContent.querySelector('.logs-info');
|
||||
|
||||
const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/'));
|
||||
if (filteredNewLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logsTextElement) {
|
||||
this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50;
|
||||
|
||||
this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines);
|
||||
if (this.displayedLogLines.length > this.maxDisplayLogLines) {
|
||||
this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines);
|
||||
}
|
||||
|
||||
logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines);
|
||||
|
||||
if (logsInfoElement) {
|
||||
const displayedLines = this.displayedLogLines.length;
|
||||
logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`;
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
logsTextElement.scrollTop = logsTextElement.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
buildLogsHtml(lines) {
|
||||
if (!lines || lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return lines.map(line => {
|
||||
let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, '');
|
||||
const highlights = [];
|
||||
|
||||
const statusInfo = this.detectHttpStatus(line);
|
||||
if (statusInfo) {
|
||||
const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`);
|
||||
const match = statusPattern.exec(processedLine);
|
||||
if (match) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: `log-status-tag log-status-${statusInfo.bucket}`,
|
||||
priority: 10
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g;
|
||||
let match;
|
||||
while ((match = timestampPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-timestamp',
|
||||
priority: 5
|
||||
});
|
||||
}
|
||||
|
||||
const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g;
|
||||
while ((match = bracketTimestampPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-timestamp',
|
||||
priority: 5
|
||||
});
|
||||
}
|
||||
|
||||
const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi;
|
||||
while ((match = levelPattern.exec(processedLine)) !== null) {
|
||||
const level = match[1].toUpperCase();
|
||||
let className = 'log-level';
|
||||
if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) {
|
||||
className += ' log-level-error';
|
||||
} else if (['WARN', 'WARNING'].includes(level)) {
|
||||
className += ' log-level-warn';
|
||||
} else if (level === 'INFO') {
|
||||
className += ' log-level-info';
|
||||
} else if (['DEBUG', 'TRACE'].includes(level)) {
|
||||
className += ' log-level-debug';
|
||||
}
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className,
|
||||
priority: 8
|
||||
});
|
||||
}
|
||||
|
||||
const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g;
|
||||
while ((match = methodPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-http-method',
|
||||
priority: 6
|
||||
});
|
||||
}
|
||||
|
||||
const urlPattern = /(https?:\/\/[^\s<>"']+)/g;
|
||||
while ((match = urlPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-path',
|
||||
priority: 4
|
||||
});
|
||||
}
|
||||
|
||||
const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
|
||||
while ((match = ipPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-ip',
|
||||
priority: 7
|
||||
});
|
||||
}
|
||||
|
||||
const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi;
|
||||
while ((match = successPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-keyword-success',
|
||||
priority: 3
|
||||
});
|
||||
}
|
||||
|
||||
const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi;
|
||||
while ((match = errorPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-keyword-error',
|
||||
priority: 3
|
||||
});
|
||||
}
|
||||
|
||||
const headersPattern = /\b(x-[a-z0-9-]+|authorization|content-type|user-agent)\b/gi;
|
||||
while ((match = headersPattern.exec(processedLine)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: 'log-header-key',
|
||||
priority: 2
|
||||
});
|
||||
}
|
||||
|
||||
highlights.sort((a, b) => {
|
||||
if (a.start === b.start) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
let cursor = 0;
|
||||
let result = '';
|
||||
|
||||
highlights.forEach((highlight) => {
|
||||
if (highlight.start < cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
result += this.escapeHtml(processedLine.slice(cursor, highlight.start));
|
||||
result += `<span class="${highlight.className}">${this.escapeHtml(processedLine.slice(highlight.start, highlight.end))}</span>`;
|
||||
cursor = highlight.end;
|
||||
});
|
||||
|
||||
result += this.escapeHtml(processedLine.slice(cursor));
|
||||
|
||||
return `<span class="log-line">${result}</span>`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
detectHttpStatus(line) {
|
||||
if (!line) return null;
|
||||
|
||||
const patterns = [
|
||||
/\|\s*([1-5]\d{2})\s*\|/,
|
||||
/\b([1-5]\d{2})\s*-/,
|
||||
/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/,
|
||||
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
|
||||
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
const code = parseInt(match[1], 10);
|
||||
if (Number.isNaN(code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= 500) {
|
||||
return { code, bucket: '5xx', match: match[1] };
|
||||
}
|
||||
if (code >= 400) {
|
||||
return { code, bucket: '4xx', match: match[1] };
|
||||
}
|
||||
if (code >= 300) {
|
||||
return { code, bucket: '3xx', match: match[1] };
|
||||
}
|
||||
if (code >= 200) {
|
||||
return { code, bucket: '2xx', match: match[1] };
|
||||
}
|
||||
if (code >= 100) {
|
||||
return { code, bucket: '1xx', match: match[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async downloadLogs() {
|
||||
try {
|
||||
const response = await this.makeRequest('/logs', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (response && response.lines && response.lines.length > 0) {
|
||||
const logsText = response.lines.join('\n');
|
||||
const blob = new Blob([logsText], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cli-proxy-api-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
this.showNotification(i18n.t('logs.download_success'), 'success');
|
||||
} else {
|
||||
this.showNotification(i18n.t('logs.empty_title'), 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载日志失败:', error);
|
||||
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async clearLogs() {
|
||||
if (!confirm(i18n.t('logs.clear_confirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('/logs', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response && response.status === 'ok') {
|
||||
const removedCount = response.removed || 0;
|
||||
const message = `${i18n.t('logs.clear_success')} (${i18n.t('logs.removed')}: ${removedCount} ${i18n.t('logs.lines')})`;
|
||||
this.showNotification(message, 'success');
|
||||
} else {
|
||||
this.showNotification(i18n.t('logs.clear_success'), 'success');
|
||||
}
|
||||
|
||||
this.latestLogTimestamp = null;
|
||||
await this.refreshLogs(false);
|
||||
} catch (error) {
|
||||
console.error('清空日志失败:', error);
|
||||
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
toggleLogsAutoRefresh(enabled) {
|
||||
if (enabled) {
|
||||
if (this.logsRefreshTimer) {
|
||||
clearInterval(this.logsRefreshTimer);
|
||||
}
|
||||
this.logsRefreshTimer = setInterval(() => {
|
||||
const logsSection = document.getElementById('logs');
|
||||
if (logsSection && logsSection.classList.contains('active')) {
|
||||
this.refreshLogs(true);
|
||||
}
|
||||
}, 5000);
|
||||
this.showNotification(i18n.t('logs.auto_refresh_enabled'), 'success');
|
||||
} else {
|
||||
if (this.logsRefreshTimer) {
|
||||
clearInterval(this.logsRefreshTimer);
|
||||
this.logsRefreshTimer = null;
|
||||
}
|
||||
this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info');
|
||||
}
|
||||
}
|
||||
};
|
||||
102
src/modules/navigation.js
Normal file
102
src/modules/navigation.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export const navigationModule = {
|
||||
setupNavigation() {
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navItems.forEach(nav => nav.classList.remove('active'));
|
||||
document.querySelectorAll('.content-section').forEach(section => section.classList.remove('active'));
|
||||
|
||||
item.classList.add('active');
|
||||
const sectionId = item.getAttribute('data-section');
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
section.classList.add('active');
|
||||
}
|
||||
|
||||
if (sectionId === 'logs') {
|
||||
this.refreshLogs(false);
|
||||
} else if (sectionId === 'config-management') {
|
||||
this.loadConfigFileEditor();
|
||||
this.refreshConfigEditor();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
toggleMobileSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const layout = document.getElementById('layout-container');
|
||||
const mainWrapper = document.getElementById('main-wrapper');
|
||||
|
||||
if (sidebar && overlay) {
|
||||
const isOpen = sidebar.classList.toggle('mobile-open');
|
||||
overlay.classList.toggle('active');
|
||||
if (layout) {
|
||||
layout.classList.toggle('sidebar-open', isOpen);
|
||||
}
|
||||
if (mainWrapper) {
|
||||
mainWrapper.classList.toggle('sidebar-open', isOpen);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeMobileSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const layout = document.getElementById('layout-container');
|
||||
const mainWrapper = document.getElementById('main-wrapper');
|
||||
|
||||
if (sidebar && overlay) {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
overlay.classList.remove('active');
|
||||
if (layout) {
|
||||
layout.classList.remove('sidebar-open');
|
||||
}
|
||||
if (mainWrapper) {
|
||||
mainWrapper.classList.remove('sidebar-open');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const layout = document.getElementById('layout-container');
|
||||
|
||||
if (sidebar && layout) {
|
||||
const isCollapsed = sidebar.classList.toggle('collapsed');
|
||||
layout.classList.toggle('sidebar-collapsed', isCollapsed);
|
||||
|
||||
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
|
||||
|
||||
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
|
||||
toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
restoreSidebarState() {
|
||||
if (window.innerWidth > 1024) {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState === 'true') {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const layout = document.getElementById('layout-container');
|
||||
|
||||
if (sidebar && layout) {
|
||||
sidebar.classList.add('collapsed');
|
||||
layout.classList.add('sidebar-collapsed');
|
||||
|
||||
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand');
|
||||
toggleBtn.title = i18n.t('sidebar.toggle_expand');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
738
src/modules/oauth.js
Normal file
738
src/modules/oauth.js
Normal file
@@ -0,0 +1,738 @@
|
||||
export const oauthModule = {
|
||||
// ===== Codex OAuth 相关方法 =====
|
||||
|
||||
// 开始 Codex OAuth 流程
|
||||
async startCodexOAuth() {
|
||||
try {
|
||||
const response = await this.makeRequest('/codex-auth-url?is_webui=1');
|
||||
const authUrl = response.url;
|
||||
const state = this.extractStateFromUrl(authUrl);
|
||||
|
||||
// 显示授权链接
|
||||
const urlInput = document.getElementById('codex-oauth-url');
|
||||
const content = document.getElementById('codex-oauth-content');
|
||||
const status = document.getElementById('codex-oauth-status');
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.value = authUrl;
|
||||
}
|
||||
if (content) {
|
||||
content.style.display = 'block';
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
|
||||
status.style.color = 'var(--warning-text)';
|
||||
}
|
||||
|
||||
// 开始轮询认证状态
|
||||
this.startCodexOAuthPolling(state);
|
||||
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('auth_login.codex_oauth_start_error')} ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 从 URL 中提取 state 参数
|
||||
extractStateFromUrl(url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.searchParams.get('state');
|
||||
} catch (error) {
|
||||
console.error('Failed to extract state from URL:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// 打开 Codex 授权链接
|
||||
openCodexLink() {
|
||||
const urlInput = document.getElementById('codex-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
window.open(urlInput.value, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制 Codex 授权链接
|
||||
async copyCodexLink() {
|
||||
const urlInput = document.getElementById('codex-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlInput.value);
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
urlInput.select();
|
||||
document.execCommand('copy');
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 开始轮询 OAuth 状态
|
||||
startCodexOAuthPolling(state) {
|
||||
if (!state) {
|
||||
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
|
||||
const status = response.status;
|
||||
const statusElement = document.getElementById('codex-oauth-status');
|
||||
|
||||
if (status === 'ok') {
|
||||
// 认证成功
|
||||
clearInterval(pollInterval);
|
||||
// 隐藏授权链接相关内容,恢复到初始状态
|
||||
this.resetCodexOAuthUI();
|
||||
// 显示成功通知
|
||||
this.showNotification(i18n.t('auth_login.codex_oauth_status_success'), 'success');
|
||||
// 刷新认证文件列表
|
||||
this.loadAuthFiles();
|
||||
} else if (status === 'error') {
|
||||
// 认证失败
|
||||
clearInterval(pollInterval);
|
||||
const errorMessage = response.error || 'Unknown error';
|
||||
// 显示错误信息
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetCodexOAuthUI();
|
||||
}, 3000);
|
||||
} else if (status === 'wait') {
|
||||
// 继续等待
|
||||
if (statusElement) {
|
||||
statusElement.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
|
||||
statusElement.style.color = 'var(--warning-text)';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
const statusElement = document.getElementById('codex-oauth-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetCodexOAuthUI();
|
||||
}, 3000);
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
|
||||
// 设置超时,5分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 重置 Codex OAuth UI 到初始状态
|
||||
resetCodexOAuthUI() {
|
||||
const urlInput = document.getElementById('codex-oauth-url');
|
||||
const content = document.getElementById('codex-oauth-content');
|
||||
const status = document.getElementById('codex-oauth-status');
|
||||
|
||||
// 清空并隐藏授权链接输入框
|
||||
if (urlInput) {
|
||||
urlInput.value = '';
|
||||
}
|
||||
|
||||
// 隐藏整个授权链接内容区域
|
||||
if (content) {
|
||||
content.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空状态显示
|
||||
if (status) {
|
||||
status.textContent = '';
|
||||
status.style.color = '';
|
||||
status.className = '';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Anthropic OAuth 相关方法 =====
|
||||
|
||||
// 开始 Anthropic OAuth 流程
|
||||
async startAnthropicOAuth() {
|
||||
try {
|
||||
const response = await this.makeRequest('/anthropic-auth-url?is_webui=1');
|
||||
const authUrl = response.url;
|
||||
const state = this.extractStateFromUrl(authUrl);
|
||||
|
||||
// 显示授权链接
|
||||
const urlInput = document.getElementById('anthropic-oauth-url');
|
||||
const content = document.getElementById('anthropic-oauth-content');
|
||||
const status = document.getElementById('anthropic-oauth-status');
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.value = authUrl;
|
||||
}
|
||||
if (content) {
|
||||
content.style.display = 'block';
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
|
||||
status.style.color = 'var(--warning-text)';
|
||||
}
|
||||
|
||||
// 开始轮询认证状态
|
||||
this.startAnthropicOAuthPolling(state);
|
||||
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_start_error')} ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开 Anthropic 授权链接
|
||||
openAnthropicLink() {
|
||||
const urlInput = document.getElementById('anthropic-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
window.open(urlInput.value, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制 Anthropic 授权链接
|
||||
async copyAnthropicLink() {
|
||||
const urlInput = document.getElementById('anthropic-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlInput.value);
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
urlInput.select();
|
||||
document.execCommand('copy');
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 开始轮询 Anthropic OAuth 状态
|
||||
startAnthropicOAuthPolling(state) {
|
||||
if (!state) {
|
||||
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
|
||||
const status = response.status;
|
||||
const statusElement = document.getElementById('anthropic-oauth-status');
|
||||
|
||||
if (status === 'ok') {
|
||||
// 认证成功
|
||||
clearInterval(pollInterval);
|
||||
// 隐藏授权链接相关内容,恢复到初始状态
|
||||
this.resetAnthropicOAuthUI();
|
||||
// 显示成功通知
|
||||
this.showNotification(i18n.t('auth_login.anthropic_oauth_status_success'), 'success');
|
||||
// 刷新认证文件列表
|
||||
this.loadAuthFiles();
|
||||
} else if (status === 'error') {
|
||||
// 认证失败
|
||||
clearInterval(pollInterval);
|
||||
const errorMessage = response.error || 'Unknown error';
|
||||
// 显示错误信息
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetAnthropicOAuthUI();
|
||||
}, 3000);
|
||||
} else if (status === 'wait') {
|
||||
// 继续等待
|
||||
if (statusElement) {
|
||||
statusElement.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
|
||||
statusElement.style.color = 'var(--warning-text)';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
const statusElement = document.getElementById('anthropic-oauth-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetAnthropicOAuthUI();
|
||||
}, 3000);
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
|
||||
// 设置超时,5分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 重置 Anthropic OAuth UI 到初始状态
|
||||
resetAnthropicOAuthUI() {
|
||||
const urlInput = document.getElementById('anthropic-oauth-url');
|
||||
const content = document.getElementById('anthropic-oauth-content');
|
||||
const status = document.getElementById('anthropic-oauth-status');
|
||||
|
||||
// 清空并隐藏授权链接输入框
|
||||
if (urlInput) {
|
||||
urlInput.value = '';
|
||||
}
|
||||
|
||||
// 隐藏整个授权链接内容区域
|
||||
if (content) {
|
||||
content.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空状态显示
|
||||
if (status) {
|
||||
status.textContent = '';
|
||||
status.style.color = '';
|
||||
status.className = '';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Gemini CLI OAuth 相关方法 =====
|
||||
|
||||
// 开始 Gemini CLI OAuth 流程
|
||||
async startGeminiCliOAuth() {
|
||||
try {
|
||||
const response = await this.makeRequest('/gemini-cli-auth-url?is_webui=1');
|
||||
const authUrl = response.url;
|
||||
const state = this.extractStateFromUrl(authUrl);
|
||||
|
||||
// 显示授权链接
|
||||
const urlInput = document.getElementById('gemini-cli-oauth-url');
|
||||
const content = document.getElementById('gemini-cli-oauth-content');
|
||||
const status = document.getElementById('gemini-cli-oauth-status');
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.value = authUrl;
|
||||
}
|
||||
if (content) {
|
||||
content.style.display = 'block';
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
|
||||
status.style.color = 'var(--warning-text)';
|
||||
}
|
||||
|
||||
// 开始轮询认证状态
|
||||
this.startGeminiCliOAuthPolling(state);
|
||||
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_start_error')} ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开 Gemini CLI 授权链接
|
||||
openGeminiCliLink() {
|
||||
const urlInput = document.getElementById('gemini-cli-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
window.open(urlInput.value, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制 Gemini CLI 授权链接
|
||||
async copyGeminiCliLink() {
|
||||
const urlInput = document.getElementById('gemini-cli-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlInput.value);
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
urlInput.select();
|
||||
document.execCommand('copy');
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 开始轮询 Gemini CLI OAuth 状态
|
||||
startGeminiCliOAuthPolling(state) {
|
||||
if (!state) {
|
||||
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
|
||||
const status = response.status;
|
||||
const statusElement = document.getElementById('gemini-cli-oauth-status');
|
||||
|
||||
if (status === 'ok') {
|
||||
// 认证成功
|
||||
clearInterval(pollInterval);
|
||||
// 隐藏授权链接相关内容,恢复到初始状态
|
||||
this.resetGeminiCliOAuthUI();
|
||||
// 显示成功通知
|
||||
this.showNotification(i18n.t('auth_login.gemini_cli_oauth_status_success'), 'success');
|
||||
// 刷新认证文件列表
|
||||
this.loadAuthFiles();
|
||||
} else if (status === 'error') {
|
||||
// 认证失败
|
||||
clearInterval(pollInterval);
|
||||
const errorMessage = response.error || 'Unknown error';
|
||||
// 显示错误信息
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetGeminiCliOAuthUI();
|
||||
}, 3000);
|
||||
} else if (status === 'wait') {
|
||||
// 继续等待
|
||||
if (statusElement) {
|
||||
statusElement.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
|
||||
statusElement.style.color = 'var(--warning-text)';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
const statusElement = document.getElementById('gemini-cli-oauth-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetGeminiCliOAuthUI();
|
||||
}, 3000);
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
|
||||
// 设置超时,5分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 重置 Gemini CLI OAuth UI 到初始状态
|
||||
resetGeminiCliOAuthUI() {
|
||||
const urlInput = document.getElementById('gemini-cli-oauth-url');
|
||||
const content = document.getElementById('gemini-cli-oauth-content');
|
||||
const status = document.getElementById('gemini-cli-oauth-status');
|
||||
|
||||
// 清空并隐藏授权链接输入框
|
||||
if (urlInput) {
|
||||
urlInput.value = '';
|
||||
}
|
||||
|
||||
// 隐藏整个授权链接内容区域
|
||||
if (content) {
|
||||
content.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空状态显示
|
||||
if (status) {
|
||||
status.textContent = '';
|
||||
status.style.color = '';
|
||||
status.className = '';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Qwen OAuth 相关方法 =====
|
||||
|
||||
// 开始 Qwen OAuth 流程
|
||||
async startQwenOAuth() {
|
||||
try {
|
||||
const response = await this.makeRequest('/qwen-auth-url?is_webui=1');
|
||||
const authUrl = response.url;
|
||||
const state = this.extractStateFromUrl(authUrl);
|
||||
|
||||
// 显示授权链接
|
||||
const urlInput = document.getElementById('qwen-oauth-url');
|
||||
const content = document.getElementById('qwen-oauth-content');
|
||||
const status = document.getElementById('qwen-oauth-status');
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.value = authUrl;
|
||||
}
|
||||
if (content) {
|
||||
content.style.display = 'block';
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
|
||||
status.style.color = 'var(--warning-text)';
|
||||
}
|
||||
|
||||
// 开始轮询认证状态
|
||||
this.startQwenOAuthPolling(state);
|
||||
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('auth_login.qwen_oauth_start_error')} ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开 Qwen 授权链接
|
||||
openQwenLink() {
|
||||
const urlInput = document.getElementById('qwen-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
window.open(urlInput.value, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制 Qwen 授权链接
|
||||
async copyQwenLink() {
|
||||
const urlInput = document.getElementById('qwen-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlInput.value);
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
urlInput.select();
|
||||
document.execCommand('copy');
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 开始轮询 Qwen OAuth 状态
|
||||
startQwenOAuthPolling(state) {
|
||||
if (!state) {
|
||||
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
|
||||
const status = response.status;
|
||||
const statusElement = document.getElementById('qwen-oauth-status');
|
||||
|
||||
if (status === 'ok') {
|
||||
// 认证成功
|
||||
clearInterval(pollInterval);
|
||||
// 隐藏授权链接相关内容,恢复到初始状态
|
||||
this.resetQwenOAuthUI();
|
||||
// 显示成功通知
|
||||
this.showNotification(i18n.t('auth_login.qwen_oauth_status_success'), 'success');
|
||||
// 刷新认证文件列表
|
||||
this.loadAuthFiles();
|
||||
} else if (status === 'error') {
|
||||
// 认证失败
|
||||
clearInterval(pollInterval);
|
||||
const errorMessage = response.error || 'Unknown error';
|
||||
// 显示错误信息
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetQwenOAuthUI();
|
||||
}, 3000);
|
||||
} else if (status === 'wait') {
|
||||
// 继续等待
|
||||
if (statusElement) {
|
||||
statusElement.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
|
||||
statusElement.style.color = 'var(--warning-text)';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
const statusElement = document.getElementById('qwen-oauth-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetQwenOAuthUI();
|
||||
}, 3000);
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
|
||||
// 设置超时,5分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 重置 Qwen OAuth UI 到初始状态
|
||||
resetQwenOAuthUI() {
|
||||
const urlInput = document.getElementById('qwen-oauth-url');
|
||||
const content = document.getElementById('qwen-oauth-content');
|
||||
const status = document.getElementById('qwen-oauth-status');
|
||||
|
||||
// 清空并隐藏授权链接输入框
|
||||
if (urlInput) {
|
||||
urlInput.value = '';
|
||||
}
|
||||
|
||||
// 隐藏整个授权链接内容区域
|
||||
if (content) {
|
||||
content.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空状态显示
|
||||
if (status) {
|
||||
status.textContent = '';
|
||||
status.style.color = '';
|
||||
status.className = '';
|
||||
}
|
||||
},
|
||||
|
||||
// ===== iFlow OAuth 相关方法 =====
|
||||
|
||||
// 开始 iFlow OAuth 流程
|
||||
async startIflowOAuth() {
|
||||
try {
|
||||
const response = await this.makeRequest('/iflow-auth-url?is_webui=1');
|
||||
const authUrl = response.url;
|
||||
const state = this.extractStateFromUrl(authUrl);
|
||||
|
||||
// 显示授权链接
|
||||
const urlInput = document.getElementById('iflow-oauth-url');
|
||||
const content = document.getElementById('iflow-oauth-content');
|
||||
const status = document.getElementById('iflow-oauth-status');
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.value = authUrl;
|
||||
}
|
||||
if (content) {
|
||||
content.style.display = 'block';
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
|
||||
status.style.color = 'var(--warning-text)';
|
||||
}
|
||||
|
||||
// 开始轮询认证状态
|
||||
this.startIflowOAuthPolling(state);
|
||||
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('auth_login.iflow_oauth_start_error')} ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 打开 iFlow 授权链接
|
||||
openIflowLink() {
|
||||
const urlInput = document.getElementById('iflow-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
window.open(urlInput.value, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制 iFlow 授权链接
|
||||
async copyIflowLink() {
|
||||
const urlInput = document.getElementById('iflow-oauth-url');
|
||||
if (urlInput && urlInput.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlInput.value);
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
urlInput.select();
|
||||
document.execCommand('copy');
|
||||
this.showNotification(i18n.t('notification.link_copied'), 'success');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 开始轮询 iFlow OAuth 状态
|
||||
startIflowOAuthPolling(state) {
|
||||
if (!state) {
|
||||
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
|
||||
const status = response.status;
|
||||
const statusElement = document.getElementById('iflow-oauth-status');
|
||||
|
||||
if (status === 'ok') {
|
||||
// 认证成功
|
||||
clearInterval(pollInterval);
|
||||
// 隐藏授权链接相关内容,恢复到初始状态
|
||||
this.resetIflowOAuthUI();
|
||||
// 显示成功通知
|
||||
this.showNotification(i18n.t('auth_login.iflow_oauth_status_success'), 'success');
|
||||
// 刷新认证文件列表
|
||||
this.loadAuthFiles();
|
||||
} else if (status === 'error') {
|
||||
// 认证失败
|
||||
clearInterval(pollInterval);
|
||||
const errorMessage = response.error || 'Unknown error';
|
||||
// 显示错误信息
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetIflowOAuthUI();
|
||||
}, 3000);
|
||||
} else if (status === 'wait') {
|
||||
// 继续等待
|
||||
if (statusElement) {
|
||||
statusElement.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
|
||||
statusElement.style.color = 'var(--warning-text)';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
const statusElement = document.getElementById('iflow-oauth-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`;
|
||||
statusElement.style.color = 'var(--error-text)';
|
||||
}
|
||||
this.showNotification(`${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`, 'error');
|
||||
// 3秒后重置UI,让用户能够重新开始
|
||||
setTimeout(() => {
|
||||
this.resetIflowOAuthUI();
|
||||
}, 3000);
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
|
||||
// 设置超时,5分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
}, 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 重置 iFlow OAuth UI 到初始状态
|
||||
resetIflowOAuthUI() {
|
||||
const urlInput = document.getElementById('iflow-oauth-url');
|
||||
const content = document.getElementById('iflow-oauth-content');
|
||||
const status = document.getElementById('iflow-oauth-status');
|
||||
|
||||
// 清空并隐藏授权链接输入框
|
||||
if (urlInput) {
|
||||
urlInput.value = '';
|
||||
}
|
||||
|
||||
// 隐藏整个授权链接内容区域
|
||||
if (content) {
|
||||
content.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空状态显示
|
||||
if (status) {
|
||||
status.textContent = '';
|
||||
status.style.color = '';
|
||||
status.className = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
271
src/modules/settings.js
Normal file
271
src/modules/settings.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// 设置与开关相关方法模块
|
||||
// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification 等基础能力
|
||||
|
||||
export async function updateDebug(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/debug', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.debug_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
// 恢复原状态
|
||||
document.getElementById('debug-toggle').checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProxyUrl() {
|
||||
const proxyUrl = document.getElementById('proxy-url').value.trim();
|
||||
|
||||
try {
|
||||
await this.makeRequest('/proxy-url', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: proxyUrl })
|
||||
});
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.proxy_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearProxyUrl() {
|
||||
try {
|
||||
await this.makeRequest('/proxy-url', { method: 'DELETE' });
|
||||
document.getElementById('proxy-url').value = '';
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.proxy_cleared'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRequestRetry() {
|
||||
const retryCount = parseInt(document.getElementById('request-retry').value);
|
||||
|
||||
try {
|
||||
await this.makeRequest('/request-retry', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: retryCount })
|
||||
});
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.retry_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDebugSettings() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config.debug !== undefined) {
|
||||
document.getElementById('debug-toggle').checked = config.debug;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载调试设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProxySettings() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['proxy-url'] !== undefined) {
|
||||
document.getElementById('proxy-url').value = config['proxy-url'] || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载代理设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRetrySettings() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['request-retry'] !== undefined) {
|
||||
document.getElementById('request-retry').value = config['request-retry'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载重试设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadQuotaSettings() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['quota-exceeded']) {
|
||||
if (config['quota-exceeded']['switch-project'] !== undefined) {
|
||||
document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project'];
|
||||
}
|
||||
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
|
||||
document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配额设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUsageStatisticsSettings() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['usage-statistics-enabled'] !== undefined) {
|
||||
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
|
||||
if (usageToggle) {
|
||||
usageToggle.checked = config['usage-statistics-enabled'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载使用统计设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRequestLogSetting() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['request-log'] !== undefined) {
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.checked = config['request-log'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载请求日志设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWsAuthSetting() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['ws-auth'] !== undefined) {
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.checked = config['ws-auth'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 WebSocket 鉴权设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUsageStatisticsEnabled(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/usage-statistics-enabled', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.usage_statistics_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
|
||||
if (usageToggle) {
|
||||
usageToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRequestLog(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/request-log', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.request_log_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWsAuth(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/ws-auth', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.ws_auth_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLoggingToFile(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/logging-to-file', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.logging_to_file_updated'), 'success');
|
||||
// 显示或隐藏日志查看栏目
|
||||
this.toggleLogsNavItem(enabled);
|
||||
// 如果启用了日志记录,自动刷新日志
|
||||
if (enabled) {
|
||||
setTimeout(() => this.refreshLogs(), 500);
|
||||
}
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const loggingToggle = document.getElementById('logging-to-file-toggle');
|
||||
if (loggingToggle) {
|
||||
loggingToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSwitchProject(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/quota-exceeded/switch-project', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.quota_switch_project_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
document.getElementById('switch-project-toggle').checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSwitchPreviewModel(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/quota-exceeded/switch-preview-model', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache(); // 清除缓存
|
||||
this.showNotification(i18n.t('notification.quota_switch_preview_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
document.getElementById('switch-preview-model-toggle').checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsModule = {
|
||||
updateDebug,
|
||||
updateProxyUrl,
|
||||
clearProxyUrl,
|
||||
updateRequestRetry,
|
||||
loadDebugSettings,
|
||||
loadProxySettings,
|
||||
loadRetrySettings,
|
||||
loadQuotaSettings,
|
||||
loadUsageStatisticsSettings,
|
||||
loadRequestLogSetting,
|
||||
loadWsAuthSetting,
|
||||
updateUsageStatisticsEnabled,
|
||||
updateRequestLog,
|
||||
updateWsAuth,
|
||||
updateLoggingToFile,
|
||||
updateSwitchProject,
|
||||
updateSwitchPreviewModel
|
||||
};
|
||||
73
src/modules/theme.js
Normal file
73
src/modules/theme.js
Normal file
@@ -0,0 +1,73 @@
|
||||
export const themeModule = {
|
||||
initializeTheme() {
|
||||
const savedTheme = localStorage.getItem('preferredTheme');
|
||||
if (savedTheme && ['light', 'dark'].includes(savedTheme)) {
|
||||
this.currentTheme = savedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
this.currentTheme = 'dark';
|
||||
} else {
|
||||
this.currentTheme = 'light';
|
||||
}
|
||||
|
||||
this.applyTheme(this.currentTheme);
|
||||
this.updateThemeButtons();
|
||||
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('preferredTheme')) {
|
||||
this.currentTheme = e.matches ? 'dark' : 'light';
|
||||
this.applyTheme(this.currentTheme);
|
||||
this.updateThemeButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
this.currentTheme = theme;
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
|
||||
this.applyTheme(newTheme);
|
||||
this.updateThemeButtons();
|
||||
localStorage.setItem('preferredTheme', newTheme);
|
||||
},
|
||||
|
||||
updateThemeButtons() {
|
||||
const loginThemeBtn = document.getElementById('theme-toggle');
|
||||
const mainThemeBtn = document.getElementById('theme-toggle-main');
|
||||
|
||||
const updateButton = (btn) => {
|
||||
if (!btn) return;
|
||||
const icon = btn.querySelector('i');
|
||||
if (this.currentTheme === 'dark') {
|
||||
icon.className = 'fas fa-sun';
|
||||
btn.title = i18n.t('theme.switch_to_light');
|
||||
} else {
|
||||
icon.className = 'fas fa-moon';
|
||||
btn.title = i18n.t('theme.switch_to_dark');
|
||||
}
|
||||
};
|
||||
|
||||
updateButton(loginThemeBtn);
|
||||
updateButton(mainThemeBtn);
|
||||
},
|
||||
|
||||
setupThemeSwitcher() {
|
||||
const loginToggle = document.getElementById('theme-toggle');
|
||||
const mainToggle = document.getElementById('theme-toggle-main');
|
||||
|
||||
if (loginToggle) {
|
||||
loginToggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
if (mainToggle) {
|
||||
mainToggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
}
|
||||
};
|
||||
682
src/modules/usage.js
Normal file
682
src/modules/usage.js
Normal file
@@ -0,0 +1,682 @@
|
||||
// 获取API密钥的统计信息
|
||||
export async function getKeyStats() {
|
||||
try {
|
||||
const response = await this.makeRequest('/usage');
|
||||
const usage = response?.usage || null;
|
||||
|
||||
if (!usage) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sourceStats = {};
|
||||
const apis = usage.apis || {};
|
||||
|
||||
Object.values(apis).forEach(apiEntry => {
|
||||
const models = apiEntry.models || {};
|
||||
|
||||
Object.values(models).forEach(modelEntry => {
|
||||
const details = modelEntry.details || [];
|
||||
|
||||
details.forEach(detail => {
|
||||
const source = detail.source;
|
||||
if (!source) return;
|
||||
|
||||
if (!sourceStats[source]) {
|
||||
sourceStats[source] = {
|
||||
success: 0,
|
||||
failure: 0
|
||||
};
|
||||
}
|
||||
|
||||
const isFailed = detail.failed === true;
|
||||
if (isFailed) {
|
||||
sourceStats[source].failure += 1;
|
||||
} else {
|
||||
sourceStats[source].success += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return sourceStats;
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 加载使用统计
|
||||
export async function loadUsageStats(usageData = null) {
|
||||
try {
|
||||
let usage = usageData;
|
||||
// 如果没有传入usage数据,则调用API获取
|
||||
if (!usage) {
|
||||
const response = await this.makeRequest('/usage');
|
||||
usage = response?.usage || null;
|
||||
}
|
||||
this.currentUsageData = usage;
|
||||
|
||||
if (!usage) {
|
||||
throw new Error('usage payload missing');
|
||||
}
|
||||
|
||||
// 更新概览卡片
|
||||
this.updateUsageOverview(usage);
|
||||
this.updateModelFilterOptions(usage);
|
||||
|
||||
// 读取当前图表周期
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||||
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
|
||||
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
|
||||
|
||||
// 初始化图表(使用当前周期)
|
||||
this.initializeRequestsChart(requestsPeriod);
|
||||
this.initializeTokensChart(tokensPeriod);
|
||||
|
||||
// 更新API详细统计表格
|
||||
this.updateApiStatsTable(usage);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载使用统计失败:', error);
|
||||
this.currentUsageData = null;
|
||||
this.updateModelFilterOptions(null);
|
||||
|
||||
// 清空概览数据
|
||||
['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = '-';
|
||||
});
|
||||
|
||||
// 清空图表
|
||||
if (this.requestsChart) {
|
||||
this.requestsChart.destroy();
|
||||
this.requestsChart = null;
|
||||
}
|
||||
if (this.tokensChart) {
|
||||
this.tokensChart.destroy();
|
||||
this.tokensChart = null;
|
||||
}
|
||||
|
||||
const tableElement = document.getElementById('api-stats-table');
|
||||
if (tableElement) {
|
||||
tableElement.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.loading_error')}: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用统计概览
|
||||
export function updateUsageOverview(data) {
|
||||
const safeData = data || {};
|
||||
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
|
||||
document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
|
||||
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
|
||||
document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0;
|
||||
}
|
||||
|
||||
export function getModelNamesFromUsage(usage) {
|
||||
if (!usage) {
|
||||
return [];
|
||||
}
|
||||
const apis = usage.apis || {};
|
||||
const names = new Set();
|
||||
Object.values(apis).forEach(apiEntry => {
|
||||
const models = apiEntry.models || {};
|
||||
Object.keys(models).forEach(modelName => {
|
||||
if (modelName) {
|
||||
names.add(modelName);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function updateModelFilterOptions(usage) {
|
||||
const select = document.getElementById('model-filter-select');
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelNames = this.getModelNamesFromUsage(usage);
|
||||
const previousSelection = this.currentModelFilter || 'all';
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = 'all';
|
||||
allOption.textContent = i18n.t('usage_stats.model_filter_all');
|
||||
fragment.appendChild(allOption);
|
||||
|
||||
modelNames.forEach(name => {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
select.innerHTML = '';
|
||||
select.appendChild(fragment);
|
||||
|
||||
let nextSelection = previousSelection;
|
||||
if (nextSelection !== 'all' && !modelNames.includes(nextSelection)) {
|
||||
nextSelection = 'all';
|
||||
}
|
||||
this.currentModelFilter = nextSelection;
|
||||
select.value = nextSelection;
|
||||
select.disabled = modelNames.length === 0;
|
||||
}
|
||||
|
||||
export function handleModelFilterChange(value) {
|
||||
const normalized = value || 'all';
|
||||
if (this.currentModelFilter === normalized) {
|
||||
return;
|
||||
}
|
||||
this.currentModelFilter = normalized;
|
||||
this.refreshChartsForModelFilter();
|
||||
}
|
||||
|
||||
export function refreshChartsForModelFilter() {
|
||||
if (!this.currentUsageData) {
|
||||
return;
|
||||
}
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||||
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
|
||||
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
|
||||
|
||||
if (this.requestsChart) {
|
||||
this.requestsChart.data = this.getRequestsChartData(requestsPeriod);
|
||||
this.requestsChart.update();
|
||||
} else {
|
||||
this.initializeRequestsChart(requestsPeriod);
|
||||
}
|
||||
|
||||
if (this.tokensChart) {
|
||||
this.tokensChart.data = this.getTokensChartData(tokensPeriod);
|
||||
this.tokensChart.update();
|
||||
} else {
|
||||
this.initializeTokensChart(tokensPeriod);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有请求明细,供图表等复用
|
||||
export function collectUsageDetailsFromUsage(usage) {
|
||||
if (!usage) {
|
||||
return [];
|
||||
}
|
||||
const apis = usage.apis || {};
|
||||
const details = [];
|
||||
Object.values(apis).forEach(apiEntry => {
|
||||
const models = apiEntry.models || {};
|
||||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||
modelDetails.forEach(detail => {
|
||||
if (detail && detail.timestamp) {
|
||||
details.push({
|
||||
...detail,
|
||||
__modelName: modelName
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
export function collectUsageDetails() {
|
||||
return this.collectUsageDetailsFromUsage(this.currentUsageData);
|
||||
}
|
||||
|
||||
// 构建最近24小时的统计序列
|
||||
export function buildRecentHourlySeries(metric = 'requests') {
|
||||
const details = this.collectUsageDetails();
|
||||
if (!details.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelFilter = this.currentModelFilter || 'all';
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - 23);
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
const labels = [];
|
||||
const values = new Array(24).fill(0);
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const bucketStart = earliestTime + i * hourMs;
|
||||
labels.push(this.formatHourLabel(new Date(bucketStart)));
|
||||
}
|
||||
|
||||
const latestBucketStart = earliestTime + (values.length - 1) * hourMs;
|
||||
let hasMatch = false;
|
||||
|
||||
details.forEach(detail => {
|
||||
if (modelFilter !== 'all' && detail.__modelName !== modelFilter) {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
if (bucketStart < earliestTime || bucketStart > latestBucketStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metric === 'tokens') {
|
||||
values[bucketIndex] += this.extractTotalTokens(detail);
|
||||
} else {
|
||||
values[bucketIndex] += 1;
|
||||
}
|
||||
hasMatch = true;
|
||||
});
|
||||
|
||||
if (!hasMatch) {
|
||||
return modelFilter === 'all' ? null : { labels, values };
|
||||
}
|
||||
|
||||
return { labels, values };
|
||||
}
|
||||
|
||||
export function buildDailySeries(metric = 'requests') {
|
||||
const details = this.collectUsageDetails();
|
||||
if (!details.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelFilter = this.currentModelFilter || 'all';
|
||||
const dayBuckets = {};
|
||||
let hasMatch = false;
|
||||
|
||||
details.forEach(detail => {
|
||||
if (modelFilter !== 'all' && detail.__modelName !== modelFilter) {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return;
|
||||
}
|
||||
const dayLabel = this.formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dayBuckets[dayLabel]) {
|
||||
dayBuckets[dayLabel] = 0;
|
||||
}
|
||||
if (metric === 'tokens') {
|
||||
dayBuckets[dayLabel] += this.extractTotalTokens(detail);
|
||||
} else {
|
||||
dayBuckets[dayLabel] += 1;
|
||||
}
|
||||
hasMatch = true;
|
||||
});
|
||||
|
||||
if (!hasMatch) {
|
||||
return modelFilter === 'all' ? null : { labels: [], values: [] };
|
||||
}
|
||||
|
||||
const labels = Object.keys(dayBuckets).sort();
|
||||
const values = labels.map(label => dayBuckets[label] || 0);
|
||||
return { labels, values };
|
||||
}
|
||||
|
||||
// 统一格式化小时标签
|
||||
export function formatHourLabel(date) {
|
||||
if (!(date instanceof Date)) {
|
||||
return '';
|
||||
}
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hour = date.getHours().toString().padStart(2, '0');
|
||||
return `${month}-${day} ${hour}:00`;
|
||||
}
|
||||
|
||||
export function formatDayLabel(date) {
|
||||
if (!(date instanceof Date)) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function extractTotalTokens(detail) {
|
||||
const tokens = detail?.tokens || {};
|
||||
if (typeof tokens.total_tokens === 'number') {
|
||||
return tokens.total_tokens;
|
||||
}
|
||||
const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens'];
|
||||
return tokenKeys.reduce((sum, key) => {
|
||||
const value = tokens[key];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
export function initializeCharts() {
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||||
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
|
||||
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
|
||||
}
|
||||
|
||||
// 初始化请求趋势图表
|
||||
export function initializeRequestsChart(period = 'day') {
|
||||
const ctx = document.getElementById('requests-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
// 销毁现有图表
|
||||
if (this.requestsChart) {
|
||||
this.requestsChart.destroy();
|
||||
}
|
||||
|
||||
const data = this.getRequestsChartData(period);
|
||||
|
||||
this.requestsChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: i18n.t('usage_stats.requests_count')
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
point: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化Token使用趋势图表
|
||||
export function initializeTokensChart(period = 'day') {
|
||||
const ctx = document.getElementById('tokens-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
// 销毁现有图表
|
||||
if (this.tokensChart) {
|
||||
this.tokensChart.destroy();
|
||||
}
|
||||
|
||||
const data = this.getTokensChartData(period);
|
||||
|
||||
this.tokensChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: i18n.t('usage_stats.tokens_count')
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
point: {
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取请求图表数据
|
||||
export function getRequestsChartData(period) {
|
||||
if (!this.currentUsageData) {
|
||||
return { labels: [], datasets: [{ data: [] }] };
|
||||
}
|
||||
|
||||
let dataSource, labels, values;
|
||||
|
||||
if (period === 'hour') {
|
||||
const hourlySeries = this.buildRecentHourlySeries('requests');
|
||||
if (hourlySeries) {
|
||||
labels = hourlySeries.labels;
|
||||
values = hourlySeries.values;
|
||||
} else {
|
||||
dataSource = this.currentUsageData.requests_by_hour || {};
|
||||
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
values = labels.map(hour => dataSource[hour] || 0);
|
||||
}
|
||||
} else {
|
||||
const dailySeries = this.buildDailySeries('requests');
|
||||
if (dailySeries) {
|
||||
labels = dailySeries.labels;
|
||||
values = dailySeries.values;
|
||||
} else {
|
||||
dataSource = this.currentUsageData.requests_by_day || {};
|
||||
labels = Object.keys(dataSource).sort();
|
||||
values = labels.map(day => dataSource[day] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 获取Token图表数据
|
||||
export function getTokensChartData(period) {
|
||||
if (!this.currentUsageData) {
|
||||
return { labels: [], datasets: [{ data: [] }] };
|
||||
}
|
||||
|
||||
let dataSource, labels, values;
|
||||
|
||||
if (period === 'hour') {
|
||||
const hourlySeries = this.buildRecentHourlySeries('tokens');
|
||||
if (hourlySeries) {
|
||||
labels = hourlySeries.labels;
|
||||
values = hourlySeries.values;
|
||||
} else {
|
||||
dataSource = this.currentUsageData.tokens_by_hour || {};
|
||||
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
values = labels.map(hour => dataSource[hour] || 0);
|
||||
}
|
||||
} else {
|
||||
const dailySeries = this.buildDailySeries('tokens');
|
||||
if (dailySeries) {
|
||||
labels = dailySeries.labels;
|
||||
values = dailySeries.values;
|
||||
} else {
|
||||
dataSource = this.currentUsageData.tokens_by_day || {};
|
||||
labels = Object.keys(dataSource).sort();
|
||||
values = labels.map(day => dataSource[day] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 切换请求图表时间周期
|
||||
export function switchRequestsPeriod(period) {
|
||||
// 更新按钮状态
|
||||
document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour');
|
||||
document.getElementById('requests-day-btn').classList.toggle('active', period === 'day');
|
||||
|
||||
// 更新图表数据
|
||||
if (this.requestsChart) {
|
||||
const newData = this.getRequestsChartData(period);
|
||||
this.requestsChart.data = newData;
|
||||
this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
|
||||
this.requestsChart.update();
|
||||
}
|
||||
}
|
||||
|
||||
// 切换Token图表时间周期
|
||||
export function switchTokensPeriod(period) {
|
||||
// 更新按钮状态
|
||||
document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour');
|
||||
document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day');
|
||||
|
||||
// 更新图表数据
|
||||
if (this.tokensChart) {
|
||||
const newData = this.getTokensChartData(period);
|
||||
this.tokensChart.data = newData;
|
||||
this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
|
||||
this.tokensChart.update();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新API详细统计表格
|
||||
export function updateApiStatsTable(data) {
|
||||
const container = document.getElementById('api-stats-table');
|
||||
if (!container) return;
|
||||
|
||||
const apis = data.apis || {};
|
||||
|
||||
if (Object.keys(apis).length === 0) {
|
||||
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.no_data')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHtml = `
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${i18n.t('usage_stats.api_endpoint')}</th>
|
||||
<th>${i18n.t('usage_stats.requests_count')}</th>
|
||||
<th>${i18n.t('usage_stats.tokens_count')}</th>
|
||||
<th>${i18n.t('usage_stats.success_rate')}</th>
|
||||
<th>${i18n.t('usage_stats.models')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
Object.entries(apis).forEach(([endpoint, apiData]) => {
|
||||
const totalRequests = apiData.total_requests || 0;
|
||||
const successCount = apiData.success_count ?? null;
|
||||
const successRate = successCount !== null && totalRequests > 0
|
||||
? Math.round((successCount / totalRequests) * 100)
|
||||
: null;
|
||||
|
||||
// 构建模型详情
|
||||
let modelsHtml = '';
|
||||
if (apiData.models && Object.keys(apiData.models).length > 0) {
|
||||
modelsHtml = '<div class="model-details">';
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
const modelRequests = modelData.total_requests ?? 0;
|
||||
const modelTokens = modelData.total_tokens ?? 0;
|
||||
modelsHtml += `
|
||||
<div class="model-item">
|
||||
<span class="model-name">${modelName}</span>
|
||||
<span>${modelRequests} 请求 / ${modelTokens} tokens</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
modelsHtml += '</div>';
|
||||
}
|
||||
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td>${endpoint}</td>
|
||||
<td>${totalRequests}</td>
|
||||
<td>${apiData.total_tokens || 0}</td>
|
||||
<td>${successRate !== null ? successRate + '%' : '-'}</td>
|
||||
<td>${modelsHtml || '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHtml += '</tbody></table>';
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
export const usageModule = {
|
||||
getKeyStats,
|
||||
loadUsageStats,
|
||||
updateUsageOverview,
|
||||
getModelNamesFromUsage,
|
||||
updateModelFilterOptions,
|
||||
handleModelFilterChange,
|
||||
refreshChartsForModelFilter,
|
||||
collectUsageDetailsFromUsage,
|
||||
collectUsageDetails,
|
||||
buildRecentHourlySeries,
|
||||
buildDailySeries,
|
||||
formatHourLabel,
|
||||
formatDayLabel,
|
||||
extractTotalTokens,
|
||||
initializeCharts,
|
||||
initializeRequestsChart,
|
||||
initializeTokensChart,
|
||||
getRequestsChartData,
|
||||
getTokensChartData,
|
||||
switchRequestsPeriod,
|
||||
switchTokensPeriod,
|
||||
updateApiStatsTable
|
||||
};
|
||||
Reference in New Issue
Block a user