Compare commits

...

4 Commits

9 changed files with 968 additions and 15 deletions

22
app.js
View File

@@ -14,7 +14,7 @@ import { aiProvidersModule } from './src/modules/ai-providers.js';
// 工具函数导入
import { escapeHtml } from './src/utils/html.js';
import { maskApiKey } from './src/utils/string.js';
import { maskApiKey, formatFileSize } from './src/utils/string.js';
import { normalizeArrayResponse } from './src/utils/array.js';
import { debounce } from './src/utils/dom.js';
import {
@@ -97,6 +97,10 @@ class CLIProxyManager {
this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE;
this.loadAuthFilePreferences();
// OAuth 模型排除列表状态
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
// Vertex AI credential import state
this.vertexImportState = {
file: null,
@@ -325,6 +329,7 @@ class CLIProxyManager {
// 日志查看
const refreshLogs = document.getElementById('refresh-logs');
const selectErrorLog = document.getElementById('select-error-log');
const downloadLogs = document.getElementById('download-logs');
const clearLogs = document.getElementById('clear-logs');
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
@@ -332,6 +337,9 @@ class CLIProxyManager {
if (refreshLogs) {
refreshLogs.addEventListener('click', () => this.refreshLogs());
}
if (selectErrorLog) {
selectErrorLog.addEventListener('click', () => this.openErrorLogsModal());
}
if (downloadLogs) {
downloadLogs.addEventListener('click', () => this.downloadLogs());
}
@@ -385,6 +393,17 @@ class CLIProxyManager {
this.bindAuthFilesPageSizeControl();
this.syncAuthFileControls();
// OAuth 排除列表
const oauthExcludedAdd = document.getElementById('oauth-excluded-add');
const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh');
if (oauthExcludedAdd) {
oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor());
}
if (oauthExcludedRefresh) {
oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true));
}
// Vertex AI credential import
const vertexSelectFile = document.getElementById('vertex-select-file');
const vertexFileInput = document.getElementById('vertex-file-input');
@@ -690,6 +709,7 @@ Object.assign(
// 将工具函数绑定到原型上,供模块使用
CLIProxyManager.prototype.escapeHtml = escapeHtml;
CLIProxyManager.prototype.maskApiKey = maskApiKey;
CLIProxyManager.prototype.formatFileSize = formatFileSize;
CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse;
CLIProxyManager.prototype.debounce = debounce;

94
i18n.js
View File

@@ -149,6 +149,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥',
'ai_providers.gemini_edit_modal_key_label': 'API密钥:',
'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗',
'ai_providers.excluded_models_label': '排除的模型 (可选):',
'ai_providers.excluded_models_placeholder': '用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': '留空表示不过滤;保存时会自动去重并忽略空白。',
'ai_providers.excluded_models_count': '排除 {count} 个模型',
'ai_providers.codex_title': 'Codex API 配置',
'ai_providers.codex_add_button': '添加配置',
@@ -306,6 +310,40 @@ const i18n = {
'vertex_import.result_location': '区域',
'vertex_import.result_file': '存储文件',
// OAuth 排除模型
'oauth_excluded.title': 'OAuth 排除列表',
'oauth_excluded.description': '按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。',
'oauth_excluded.add': '新增排除',
'oauth_excluded.add_title': '新增提供商排除列表',
'oauth_excluded.edit_title': '编辑 {provider} 的排除列表',
'oauth_excluded.refresh': '刷新',
'oauth_excluded.refreshing': '刷新中...',
'oauth_excluded.provider_label': '提供商',
'oauth_excluded.provider_auto': '跟随当前过滤',
'oauth_excluded.provider_placeholder': '例如 gemini-cli / openai',
'oauth_excluded.provider_hint': '默认选中当前筛选的提供商,也可直接输入或选择其他名称。',
'oauth_excluded.models_label': '排除的模型',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': '逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。',
'oauth_excluded.save': '保存/更新',
'oauth_excluded.saving': '正在保存...',
'oauth_excluded.save_success': '排除列表已更新',
'oauth_excluded.save_failed': '更新排除列表失败',
'oauth_excluded.delete': '删除提供商',
'oauth_excluded.delete_confirm': '确定要删除 {provider} 的排除列表吗?',
'oauth_excluded.delete_success': '已删除该提供商的排除列表',
'oauth_excluded.delete_failed': '删除排除列表失败',
'oauth_excluded.deleting': '正在删除...',
'oauth_excluded.no_models': '未配置排除模型',
'oauth_excluded.model_count': '排除 {count} 个模型',
'oauth_excluded.list_empty_all': '暂无任何提供商的排除列表,点击“新增排除”创建。',
'oauth_excluded.list_empty_filtered': '当前筛选下没有排除项,点击“新增排除”添加。',
'oauth_excluded.disconnected': '请先连接服务器以查看排除列表',
'oauth_excluded.load_failed': '加载排除列表失败',
'oauth_excluded.provider_required': '请先填写提供商名称',
'oauth_excluded.scope_all': '当前范围:全局(显示所有提供商)',
'oauth_excluded.scope_provider': '当前范围:{provider}',
// Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth',
@@ -457,6 +495,15 @@ const i18n = {
'logs.refresh_button': '刷新日志',
'logs.clear_button': '清空日志',
'logs.download_button': '下载日志',
'logs.error_log_button': '选择错误日志',
'logs.error_logs_modal_title': '错误请求日志',
'logs.error_logs_description': '请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。',
'logs.error_logs_empty': '暂无错误请求日志文件',
'logs.error_logs_load_error': '加载错误日志列表失败',
'logs.error_logs_size': '大小',
'logs.error_logs_modified': '最后修改',
'logs.error_logs_download': '下载',
'logs.error_log_download_success': '错误日志下载成功',
'logs.empty_title': '暂无日志记录',
'logs.empty_desc': '当启用"日志记录到文件"功能后,日志将显示在这里',
'logs.log_content': '日志内容',
@@ -721,6 +768,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key',
'ai_providers.gemini_edit_modal_key_label': 'API Key:',
'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?',
'ai_providers.excluded_models_label': 'Excluded models (optional):',
'ai_providers.excluded_models_placeholder': 'Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': 'Leave empty to allow all models; values are trimmed and deduplicated automatically.',
'ai_providers.excluded_models_count': 'Excluding {count} models',
'ai_providers.codex_title': 'Codex API Configuration',
'ai_providers.codex_add_button': 'Add Configuration',
@@ -878,6 +929,40 @@ const i18n = {
'vertex_import.result_location': 'Region',
'vertex_import.result_file': 'Persisted file',
// OAuth excluded models
'oauth_excluded.title': 'OAuth Excluded Models',
'oauth_excluded.description': 'Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.',
'oauth_excluded.add': 'Add Exclusion',
'oauth_excluded.add_title': 'Add provider exclusion',
'oauth_excluded.edit_title': 'Edit exclusions for {provider}',
'oauth_excluded.refresh': 'Refresh',
'oauth_excluded.refreshing': 'Refreshing...',
'oauth_excluded.provider_label': 'Provider',
'oauth_excluded.provider_auto': 'Follow current filter',
'oauth_excluded.provider_placeholder': 'e.g. gemini-cli',
'oauth_excluded.provider_hint': 'Defaults to the current filter; pick an existing provider or type a new name.',
'oauth_excluded.models_label': 'Models to exclude',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': 'Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.',
'oauth_excluded.save': 'Save/Update',
'oauth_excluded.saving': 'Saving...',
'oauth_excluded.save_success': 'Excluded models updated',
'oauth_excluded.save_failed': 'Failed to update excluded models',
'oauth_excluded.delete': 'Delete Provider',
'oauth_excluded.delete_confirm': 'Delete the exclusion list for {provider}?',
'oauth_excluded.delete_success': 'Exclusion list removed',
'oauth_excluded.delete_failed': 'Failed to delete exclusion list',
'oauth_excluded.deleting': 'Deleting...',
'oauth_excluded.no_models': 'No excluded models',
'oauth_excluded.model_count': '{count} models excluded',
'oauth_excluded.list_empty_all': 'No exclusions yet—use “Add Exclusion” to create one.',
'oauth_excluded.list_empty_filtered': 'No exclusions in this scope; click “Add Exclusion” to add.',
'oauth_excluded.disconnected': 'Connect to the server to view exclusions',
'oauth_excluded.load_failed': 'Failed to load exclusion list',
'oauth_excluded.provider_required': 'Please enter a provider first',
'oauth_excluded.scope_all': 'Scope: All providers',
'oauth_excluded.scope_provider': 'Scope: {provider}',
// Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth',
'auth_login.codex_oauth_button': 'Start Codex Login',
@@ -1028,6 +1113,15 @@ const i18n = {
'logs.refresh_button': 'Refresh Logs',
'logs.clear_button': 'Clear Logs',
'logs.download_button': 'Download Logs',
'logs.error_log_button': 'Select Error Log',
'logs.error_logs_modal_title': 'Error Request Logs',
'logs.error_logs_description': 'Pick an error request log file to download (only generated when request logging is off).',
'logs.error_logs_empty': 'No error request log files found',
'logs.error_logs_load_error': 'Failed to load error log list',
'logs.error_logs_size': 'Size',
'logs.error_logs_modified': 'Last modified',
'logs.error_logs_download': 'Download',
'logs.error_log_download_success': 'Error log downloaded successfully',
'logs.empty_title': 'No Logs Available',
'logs.empty_desc': 'When "Enable logging to file" is enabled, logs will be displayed here',
'logs.log_content': 'Log Content',

View File

@@ -557,6 +557,31 @@
</div>
</div>
<!-- OAuth 排除列表 -->
<div class="card" id="oauth-excluded-card">
<div class="card-header card-header-with-filter">
<div class="header-left">
<h3><i class="fas fa-ban"></i> <span data-i18n="oauth_excluded.title">OAuth 排除列表</span></h3>
<div class="oauth-excluded-scope" id="oauth-excluded-scope"></div>
</div>
<div class="card-actions">
<button id="oauth-excluded-refresh" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i> <span data-i18n="oauth_excluded.refresh">刷新</span>
</button>
<button id="oauth-excluded-add" class="btn btn-primary">
<i class="fas fa-plus"></i> <span data-i18n="oauth_excluded.add">新增排除</span>
</button>
</div>
</div>
<div class="card-content">
<p class="form-hint" data-i18n="oauth_excluded.description">为 OAuth/文件凭据配置模型黑名单,支持通配符。</p>
<div id="oauth-excluded-status" class="form-hint subtle"></div>
<div id="oauth-excluded-list" class="oauth-excluded-list oauth-excluded-grid provider-list">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div>
</div>
<!-- Codex OAuth -->
<div class="card" id="codex-oauth-card">
<div class="card-header">
@@ -822,16 +847,19 @@
<input type="checkbox" id="logs-auto-refresh-toggle">
<span class="slider"></span>
</label>
<span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span>
</div>
<button id="refresh-logs" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span>
</button>
<button id="download-logs" class="btn btn-secondary">
<i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span>
</button>
<button id="clear-logs" class="btn btn-danger">
<i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span>
<span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span>
</div>
<button id="refresh-logs" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span>
</button>
<button id="select-error-log" class="btn btn-secondary">
<i class="fas fa-file-circle-exclamation"></i> <span data-i18n="logs.error_log_button">选择错误日志</span>
</button>
<button id="download-logs" class="btn btn-secondary">
<i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span>
</button>
<button id="clear-logs" class="btn btn-danger">
<i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span>
</button>
</div>
</div>

View File

@@ -59,6 +59,59 @@ const normalizeModelList = (payload) => {
return [];
};
const normalizeExcludedModels = (input) => {
const rawList = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\n,]/) : []);
const seen = new Set();
const normalized = [];
rawList.forEach(item => {
if (item === undefined || item === null) {
return;
}
const trimmed = String(item).trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push(trimmed);
});
return normalized;
};
export function collectExcludedModels(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return [];
return normalizeExcludedModels(textarea.value);
}
export function setExcludedModelsValue(textareaId, models = []) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
textarea.value = normalizeExcludedModels(models).join('\n');
}
export function renderExcludedModelBadges(models) {
const normalized = normalizeExcludedModels(models);
if (!normalized.length) {
return '';
}
const badges = normalized.map(model => `
<span class="provider-model-tag excluded-model-tag">
<span class="model-name">${this.escapeHtml(model)}</span>
</span>
`).join('');
return `
<div class="item-subtitle">${i18n.t('ai_providers.excluded_models_count', { count: normalized.length })}</div>
<div class="provider-models excluded-models">
${badges}
</div>
`;
}
export async function loadGeminiKeys() {
try {
const config = await this.getConfig();
@@ -144,6 +197,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
const configJson = JSON.stringify(config).replace(/"/g, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
const baseUrl = config['base-url'] || config['base_url'] || '';
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return `
<div class="key-item">
<div class="item-content">
@@ -151,6 +205,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''}
${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -191,6 +246,11 @@ export function showAddGeminiKeyModal() {
<div id="new-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label for="new-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button>
@@ -200,11 +260,13 @@ export function showAddGeminiKeyModal() {
modal.style.display = 'block';
this.populateGeminiKeyFields('new-gemini-keys-wrapper');
this.populateHeaderFields('new-gemini-headers-wrapper');
this.setExcludedModelsValue('new-gemini-excluded-models');
}
export async function addGeminiKey() {
const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper');
const headers = this.collectHeaderInputs('new-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-gemini-excluded-models');
if (!entries.length) {
this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error');
@@ -245,6 +307,7 @@ export async function addGeminiKey() {
} else {
delete newConfig['base-url'];
}
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers);
const nextKeys = [...currentKeys, newConfig];
@@ -371,6 +434,11 @@ export function editGeminiKey(index, config) {
<div id="edit-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label for="edit-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button>
@@ -380,6 +448,7 @@ export function editGeminiKey(index, config) {
modal.style.display = 'block';
this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false });
this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-gemini-excluded-models', config['excluded-models'] || []);
}
export async function updateGeminiKey(index) {
@@ -392,6 +461,7 @@ export async function updateGeminiKey(index) {
const newKey = entry['api-key'];
const baseUrl = entry['base-url'] || '';
const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-gemini-excluded-models');
if (!newKey) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
@@ -406,6 +476,7 @@ export async function updateGeminiKey(index) {
} else {
delete newConfig['base-url'];
}
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers);
await this.makeRequest('/gemini-api-key', {
@@ -477,6 +548,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;');
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return `
<div class="provider-item">
<div class="item-content">
@@ -485,6 +557,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -525,6 +598,11 @@ export function showAddCodexKeyModal() {
<label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label>
<input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}">
</div>
<div class="form-group">
<label for="new-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -539,6 +617,7 @@ export function showAddCodexKeyModal() {
modal.style.display = 'block';
this.populateHeaderFields('new-codex-headers-wrapper');
this.setExcludedModelsValue('new-codex-excluded-models');
}
export async function addCodexKey() {
@@ -546,6 +625,7 @@ export async function addCodexKey() {
const baseUrl = document.getElementById('new-codex-url').value.trim();
const proxyUrl = document.getElementById('new-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('new-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-codex-excluded-models');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -560,7 +640,7 @@ export async function addCodexKey() {
const data = await this.makeRequest('/codex-api-key');
const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item }));
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers);
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers, excludedModels);
currentKeys.push(newConfig);
@@ -596,6 +676,11 @@ export function editCodexKey(index, config) {
<label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label>
<input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}">
</div>
<div class="form-group">
<label for="edit-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -610,6 +695,7 @@ export function editCodexKey(index, config) {
modal.style.display = 'block';
this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-codex-excluded-models', config['excluded-models'] || []);
}
export async function updateCodexKey(index) {
@@ -617,6 +703,7 @@ export async function updateCodexKey(index) {
const baseUrl = document.getElementById('edit-codex-url').value.trim();
const proxyUrl = document.getElementById('edit-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('edit-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-codex-excluded-models');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -636,7 +723,7 @@ export async function updateCodexKey(index) {
}
const original = currentList[index] ? { ...currentList[index] } : {};
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers);
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers, excludedModels);
await this.makeRequest('/codex-api-key', {
method: 'PATCH',
@@ -1002,7 +1089,7 @@ export async function renderOpenAIProviders(providers, keyStats = null) {
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
<div class="item-subtitle provider-base-url" title="${this.escapeHtml(baseUrl)}">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
${this.renderHeaderBadges(item.headers)}
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
@@ -1607,5 +1694,8 @@ export const aiProvidersModule = {
populateModelFields,
collectModelInputs,
renderModelBadges,
renderExcludedModelBadges,
collectExcludedModels,
setExcludedModelsValue,
validateOpenAIProviderInput
};

View File

@@ -230,7 +230,7 @@ export const apiKeysModule = {
},
// 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) {
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null, excludedModels = null) {
const result = {
...original,
'api-key': apiKey,
@@ -238,6 +238,9 @@ export const apiKeysModule = {
'proxy-url': proxyUrl || ''
};
this.applyHeadersToConfig(result, headers);
if (Array.isArray(excludedModels)) {
result['excluded-models'] = excludedModels;
}
return result;
},

View File

@@ -540,6 +540,7 @@ export const authFilesModule = {
}
this.refreshFilterButtonTexts();
this.renderOauthExcludedModels();
},
generateDynamicTypeLabel(type) {
@@ -1031,6 +1032,469 @@ export const authFilesModule = {
}
},
normalizeOauthExcludedMap(payload = {}) {
const raw = (payload && (payload['oauth-excluded-models'] || payload.items)) || payload || {};
if (!raw || typeof raw !== 'object') {
return {};
}
const normalized = {};
Object.entries(raw).forEach(([provider, models]) => {
const key = typeof provider === 'string' ? provider.trim() : '';
if (!key) return;
const list = Array.isArray(models)
? models.map(item => String(item || '').trim()).filter(Boolean)
: [];
normalized[key.toLowerCase()] = list;
});
return normalized;
},
getFilteredOauthExcludedMap(filterType = this.currentAuthFileFilter) {
const map = this.oauthExcludedModels || {};
if (!map || typeof map !== 'object') {
return {};
}
const type = (filterType || 'all').toLowerCase();
if (type === 'all') {
return map;
}
const result = {};
Object.entries(map).forEach(([provider, models]) => {
if ((provider || '').toLowerCase() === type) {
result[provider] = models;
}
});
return result;
},
findOauthExcludedEntry(provider) {
if (!provider || provider === 'all') {
return null;
}
const normalized = provider.toLowerCase();
const map = this.oauthExcludedModels || {};
for (const [key, models] of Object.entries(map)) {
if ((key || '').toLowerCase() === normalized) {
return { provider: key, models: Array.isArray(models) ? models : [] };
}
}
return null;
},
setOauthExcludedForm(provider = '', models = null) {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const modelsInput = document.getElementById('oauth-excluded-models');
const normalizedProvider = (provider || '').trim();
if (providerSelect) {
const options = Array.from(providerSelect.options || []);
let match = options.find(opt => (opt.value || '').toLowerCase() === normalizedProvider.toLowerCase());
if (!match && normalizedProvider) {
match = new Option(this.generateDynamicTypeLabel(normalizedProvider) || normalizedProvider, normalizedProvider);
providerSelect.appendChild(match);
}
if (normalizedProvider && match) {
providerSelect.value = match.value;
} else {
providerSelect.value = 'auto';
}
}
if (modelsInput && models !== null && models !== undefined) {
const list = Array.isArray(models) ? models : [];
modelsInput.value = list.map(item => item || '').join('\n');
}
},
syncOauthExcludedFormWithFilter(overrideModels = false) {
const filterType = (this.currentAuthFileFilter || 'all').toLowerCase();
const entry = this.findOauthExcludedEntry(filterType);
if (filterType === 'all') {
if (overrideModels) {
if (entry) {
this.setOauthExcludedForm(entry.provider, entry.models);
} else {
this.setOauthExcludedForm('', '');
}
}
return;
}
if (overrideModels) {
this.setOauthExcludedForm(filterType, entry ? entry.models : []);
} else {
this.setOauthExcludedForm(filterType);
}
},
getOauthExcludedProviderValue() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const filterFallback = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all')
? this.currentAuthFileFilter
: '';
let selected = (providerSelect && providerSelect.value) ? providerSelect.value.trim() : '';
if (!selected || selected === 'auto') {
return filterFallback;
}
return selected;
},
refreshOauthProviderOptions() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
if (!providerSelect) return;
const allowedProviders = ['gemini-cli', 'vertex', 'aistudio', 'antigravity', 'claude', 'codex', 'qwen', 'iflow'];
const mapProviders = Object.keys(this.oauthExcludedModels || {});
const filterType = (this.currentAuthFileFilter || '').toLowerCase();
const providers = Array.from(new Set([...allowedProviders, ...mapProviders].filter(Boolean)));
const prevValue = providerSelect.value || 'auto';
providerSelect.innerHTML = '';
const addOption = (value, textKey, fallbackText = null) => {
const opt = document.createElement('option');
opt.value = value;
if (textKey) {
opt.setAttribute('data-i18n-text', textKey);
opt.textContent = i18n.t(textKey);
} else {
opt.textContent = fallbackText || value;
}
providerSelect.appendChild(opt);
};
addOption('auto', 'oauth_excluded.provider_auto');
providers.sort((a, b) => a.localeCompare(b)).forEach(item => addOption(item, null, this.generateDynamicTypeLabel(item) || item));
const restoreValue = (() => {
if (prevValue && Array.from(providerSelect.options).some(opt => opt.value === prevValue)) {
return prevValue;
}
if (filterType && Array.from(providerSelect.options).some(opt => opt.value === filterType)) {
return filterType;
}
return 'auto';
})();
providerSelect.value = restoreValue;
},
parseOauthExcludedModelsInput(input = '') {
const tokens = (input || '').split(/[\n,]/).map(token => token.trim()).filter(Boolean);
const unique = [];
tokens.forEach(token => {
if (!unique.includes(token)) {
unique.push(token);
}
});
return unique;
},
openOauthExcludedEditor(provider = '', models = null) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
if (!modal || !modalBody) return;
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider
|| ((this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all') ? this.currentAuthFileFilter : '');
let targetModels = models;
if ((targetModels === null || targetModels === undefined) && fallbackProvider) {
const existing = this.findOauthExcludedEntry(fallbackProvider);
if (existing) {
targetModels = existing.models;
}
}
modalBody.innerHTML = `
<h3>${fallbackProvider
? i18n.t('oauth_excluded.edit_title', { provider: this.generateDynamicTypeLabel(fallbackProvider) })
: i18n.t('oauth_excluded.add_title')
}</h3>
<div class="provider-item oauth-excluded-editor-card">
<div class="item-content">
<div class="form-group">
<label for="oauth-excluded-provider-select" data-i18n="oauth_excluded.provider_label">${i18n.t('oauth_excluded.provider_label')}</label>
<select id="oauth-excluded-provider-select"></select>
<p class="form-hint" data-i18n="oauth_excluded.provider_hint">${i18n.t('oauth_excluded.provider_hint')}</p>
</div>
<div class="form-group">
<label for="oauth-excluded-models" data-i18n="oauth_excluded.models_label">${i18n.t('oauth_excluded.models_label')}</label>
<textarea id="oauth-excluded-models" rows="5" data-i18n-placeholder="oauth_excluded.models_placeholder" placeholder="${i18n.t('oauth_excluded.models_placeholder')}"></textarea>
<p class="form-hint" data-i18n="oauth_excluded.models_hint">${i18n.t('oauth_excluded.models_hint')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="oauth-excluded-save">
<i class="fas fa-save"></i> ${i18n.t('oauth_excluded.save')}
</button>
<button class="btn btn-danger" id="oauth-excluded-delete">
<i class="fas fa-trash"></i> ${i18n.t('oauth_excluded.delete')}
</button>
<button class="btn btn-secondary" onclick="manager.closeModal()">
${i18n.t('common.cancel')}
</button>
</div>
`;
this.refreshOauthProviderOptions();
this.setOauthExcludedForm(fallbackProvider, targetModels != null ? targetModels : []);
this.showModal();
const saveBtn = document.getElementById('oauth-excluded-save');
if (saveBtn) {
saveBtn.onclick = () => this.saveOauthExcludedEntry();
}
const deleteBtn = document.getElementById('oauth-excluded-delete');
if (deleteBtn) {
deleteBtn.onclick = () => this.deleteOauthExcludedEntry();
}
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const syncDeleteState = () => {
if (deleteBtn) {
deleteBtn.disabled = !this.getOauthExcludedProviderValue();
}
};
if (providerSelect) {
providerSelect.addEventListener('change', syncDeleteState);
}
syncDeleteState();
this.updateOauthExcludedButtonsState(false);
},
buildOauthExcludedItem(provider, models = []) {
const providerLabel = this.generateDynamicTypeLabel(provider) || provider;
const normalizedModels = Array.isArray(models) ? models.filter(Boolean) : [];
const tags = normalizedModels.length
? normalizedModels.map(model => `<span class="provider-model-tag"><span class="model-name">${this.escapeHtml(String(model))}</span></span>`).join('')
: `<span class="oauth-excluded-empty">${i18n.t('oauth_excluded.no_models')}</span>`;
const modelCount = normalizedModels.length;
return `
<div class="provider-item oauth-excluded-card" data-provider="${this.escapeHtml(provider)}">
<div class="item-content">
<div class="item-title">${this.escapeHtml(providerLabel)}</div>
<div class="item-meta">
<span class="item-subtitle">
${modelCount > 0
? i18n.t('oauth_excluded.model_count', { count: modelCount })
: i18n.t('oauth_excluded.no_models')}
</span>
</div>
<div class="provider-models oauth-excluded-tags">
${tags}
</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" data-action="edit" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-danger" data-action="delete" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
},
renderOauthExcludedModels(filterType = this.currentAuthFileFilter) {
const container = document.getElementById('oauth-excluded-list');
const scopeEl = document.getElementById('oauth-excluded-scope');
if (!container) return;
const currentType = (filterType || 'all').toLowerCase();
const map = this.getFilteredOauthExcludedMap(currentType);
const providers = Object.keys(map || {});
if (scopeEl) {
const label = currentType === 'all'
? i18n.t('oauth_excluded.scope_all')
: i18n.t('oauth_excluded.scope_provider', { provider: this.generateDynamicTypeLabel(currentType) });
scopeEl.textContent = label;
}
if (!this.isConnected) {
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t('oauth_excluded.disconnected')}</div>`;
return;
}
if (this._oauthExcludedLoading) {
container.innerHTML = `<div class="loading-placeholder">${i18n.t('common.loading')}</div>`;
return;
}
if (!providers.length) {
const emptyKey = currentType === 'all'
? 'oauth_excluded.list_empty_all'
: 'oauth_excluded.list_empty_filtered';
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t(emptyKey)}</div>`;
return;
}
const itemsHtml = providers
.sort((a, b) => a.localeCompare(b))
.map(provider => this.buildOauthExcludedItem(provider, map[provider]))
.join('');
container.innerHTML = itemsHtml;
this.refreshOauthProviderOptions();
this.bindOauthExcludedActionEvents();
},
bindOauthExcludedActionEvents() {
const container = document.getElementById('oauth-excluded-list');
if (!container) return;
if (container._oauthExcludedListener) {
container.removeEventListener('click', container._oauthExcludedListener);
}
const listener = (event) => {
const button = event.target.closest('button[data-action]');
if (!button || !container.contains(button)) return;
const provider = button.dataset.provider;
if (!provider) return;
const entry = this.findOauthExcludedEntry(provider);
if (button.dataset.action === 'edit') {
this.openOauthExcludedEditor(provider, entry ? entry.models : []);
} else if (button.dataset.action === 'delete') {
this.deleteOauthExcludedEntry(provider);
}
};
container._oauthExcludedListener = listener;
container.addEventListener('click', listener);
},
updateOauthExcludedButtonsState(isLoading = false) {
const refreshBtn = document.getElementById('oauth-excluded-refresh');
const saveBtn = document.getElementById('oauth-excluded-save');
const deleteBtn = document.getElementById('oauth-excluded-delete');
const addBtn = document.getElementById('oauth-excluded-add');
const disabled = isLoading || !this.isConnected;
[refreshBtn, saveBtn, deleteBtn, addBtn].forEach(btn => {
if (btn) {
btn.disabled = disabled;
}
});
},
setOauthExcludedStatus(message = '') {
const statusEl = document.getElementById('oauth-excluded-status');
if (statusEl) {
statusEl.textContent = message || '';
}
},
async loadOauthExcludedModels(forceRefresh = false) {
if (!this.isConnected) {
this.renderOauthExcludedModels();
this.updateOauthExcludedButtonsState();
return;
}
if (this._oauthExcludedLoading) {
return;
}
this._oauthExcludedLoading = true;
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.refreshing'));
this.renderOauthExcludedModels();
try {
const data = await this.makeRequest('/oauth-excluded-models');
this.oauthExcludedModels = this.normalizeOauthExcludedMap(data);
this.refreshOauthProviderOptions();
this.setOauthExcludedStatus('');
} catch (error) {
console.error('加载 OAuth 排除列表失败:', error);
const message = `${i18n.t('oauth_excluded.load_failed')}: ${error.message}`;
this.setOauthExcludedStatus(message);
this.showNotification(message, 'error');
} finally {
this._oauthExcludedLoading = false;
this.updateOauthExcludedButtonsState(false);
this.renderOauthExcludedModels();
}
},
async saveOauthExcludedEntry() {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const modelsInput = document.getElementById('oauth-excluded-models');
if (!modelsInput) return;
const providerValue = this.getOauthExcludedProviderValue();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
const models = this.parseOauthExcludedModelsInput(modelsInput.value);
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.saving'));
try {
await this.makeRequest('/oauth-excluded-models', {
method: 'PATCH',
body: JSON.stringify({
provider: providerValue,
models
})
});
const successKey = models.length === 0 ? 'oauth_excluded.delete_success' : 'oauth_excluded.save_success';
this.showNotification(i18n.t(successKey), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.save_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
async deleteOauthExcludedEntry(providerOverride = null) {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const providerValue = (providerOverride || this.getOauthExcludedProviderValue() || '').trim();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
if (!confirm(i18n.t('oauth_excluded.delete_confirm', { provider: providerValue }))) {
return;
}
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.deleting'));
try {
await this.makeRequest(`/oauth-excluded-models?provider=${encodeURIComponent(providerValue)}`, { method: 'DELETE' });
this.showNotification(i18n.t('oauth_excluded.delete_success'), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.delete_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
registerAuthFilesListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
@@ -1043,6 +1507,21 @@ export const authFilesModule = {
} catch (error) {
console.error('加载认证文件失败:', error);
}
try {
await this.loadOauthExcludedModels(true);
} catch (error) {
console.error('加载 OAuth 排除列表失败:', error);
}
});
this.events.on('connection:status-changed', (event) => {
const detail = event?.detail || {};
this.updateOauthExcludedButtonsState(false);
if (detail.isConnected) {
this.loadOauthExcludedModels(true);
} else {
this.renderOauthExcludedModels();
}
});
}
};

View File

@@ -101,6 +101,11 @@ export const loginModule = {
this.stopStatusUpdateTimer();
this.resetVersionInfo();
this.setManagementKey('', { persist: false });
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
if (typeof this.renderOauthExcludedModels === 'function') {
this.renderOauthExcludedModels('all');
}
localStorage.removeItem('isLoggedIn');
secureStorage.removeItem('managementKey');

View File

@@ -346,6 +346,162 @@ export const logsModule = {
return null;
},
async openErrorLogsModal() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="loading-placeholder">${i18n.t('common.loading')}</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showModal();
try {
const response = await this.makeRequest('/request-error-logs', {
method: 'GET'
});
const files = Array.isArray(response?.files) ? response.files.slice() : [];
if (files.length > 1) {
files.sort((a, b) => (b.modified || 0) - (a.modified || 0));
}
modalBody.innerHTML = this.buildErrorLogsModal(files);
this.showModal();
this.bindErrorLogDownloadButtons();
} catch (error) {
console.error('加载错误日志列表失败:', error);
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<p>${i18n.t('logs.error_logs_load_error')}</p>
<p>${this.escapeHtml(error.message || '')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showNotification(`${i18n.t('logs.error_logs_load_error')}: ${error.message}`, 'error');
}
},
buildErrorLogsModal(files) {
const listHtml = Array.isArray(files) && files.length > 0
? files.map(file => this.buildErrorLogCard(file)).join('')
: `
<div class="empty-state">
<i class="fas fa-inbox"></i>
<h3>${i18n.t('logs.error_logs_empty')}</h3>
<p>${i18n.t('logs.error_logs_description')}</p>
</div>
`;
return `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="provider-list">
${listHtml}
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
},
buildErrorLogCard(file) {
const name = file?.name || '';
const size = typeof file?.size === 'number' ? this.formatFileSize(file.size) : '-';
const modified = file?.modified ? this.formatErrorLogTime(file.modified) : '-';
return `
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_size')}: ${this.escapeHtml(size)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_modified')}: ${this.escapeHtml(modified)}</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary error-log-download-btn" data-log-name="${this.escapeHtml(name)}">
<i class="fas fa-download"></i> ${i18n.t('logs.error_logs_download')}
</button>
</div>
</div>
`;
},
bindErrorLogDownloadButtons() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
const buttons = modalBody.querySelectorAll('.error-log-download-btn');
buttons.forEach(button => {
button.onclick = () => {
const filename = button.getAttribute('data-log-name');
if (filename) {
this.downloadErrorLog(filename);
}
};
});
},
formatErrorLogTime(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return '-';
}
const date = new Date(numeric * 1000);
if (Number.isNaN(date.getTime())) {
return '-';
}
const locale = i18n?.currentLanguage || undefined;
return date.toLocaleString(locale);
},
async downloadErrorLog(filename) {
if (!filename) return;
try {
const response = await this.apiClient.requestRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
method: 'GET'
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
}
} catch (parseError) {
// ignore JSON parse error and use default message
}
throw new Error(errorMessage);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showNotification(i18n.t('logs.error_log_download_success'), 'success');
} catch (error) {
console.error('下载错误日志失败:', error);
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
},
async downloadLogs() {
try {
const response = await this.makeRequest('/logs', {

View File

@@ -1748,6 +1748,71 @@ input:checked+.slider:before {
white-space: nowrap;
}
.oauth-excluded-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
margin-top: 8px;
}
.oauth-excluded-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
margin-top: 12px;
}
.oauth-excluded-scope {
color: var(--text-secondary);
font-weight: 600;
margin: 0;
}
.oauth-excluded-list {
margin-top: 12px;
}
.oauth-excluded-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
align-items: stretch;
}
.oauth-excluded-card {
height: 100%;
}
.oauth-excluded-card .provider-models {
margin-top: 6px;
}
.oauth-excluded-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.oauth-excluded-card .item-actions {
top: 14px;
right: 14px;
transform: none;
}
.oauth-excluded-editor-card .item-content {
padding-right: 0;
}
.oauth-excluded-editor-card textarea {
min-height: 140px;
}
.oauth-excluded-empty {
color: var(--text-tertiary);
font-size: 0.95rem;
}
/* 认证文件工具栏 */
.auth-file-toolbar {
display: flex;
@@ -2136,6 +2201,13 @@ input:checked+.slider:before {
font-size: 0.9rem;
}
.provider-base-url {
word-break: break-all;
overflow-wrap: anywhere;
white-space: normal;
display: block;
}
.provider-item .provider-models {
margin-top: 8px;
display: flex;
@@ -2156,6 +2228,12 @@ input:checked+.slider:before {
font-size: 0.85rem;
}
.provider-item .excluded-models .provider-model-tag {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
.provider-model-tag .model-name {
font-weight: 600;
}