- 在提供商管理界面添加刷新状态徽章,显示“刷新中”状态 - 为更新功能添加版本选择下拉框,支持选择特定版本进行更新 - 在提供商状态中新增 needsRefresh 字段用于跟踪刷新状态 - 修复冷启动时刷新状态重置逻辑,避免持久化状态影响新会话 - 为刷新操作添加超时保护机制,防止适配器调用无限挂起 - 完善国际化翻译,添加相关状态和版本标签
3425 lines
168 KiB
JavaScript
3425 lines
168 KiB
JavaScript
// 提供商管理功能模块
|
||
|
||
import { providerStats, updateProviderStats } from './constants.js';
|
||
import { showToast, formatUptime, getProviderConfigs, getBaseProviderConfigs } from './utils.js';
|
||
import { fileUploadHandler } from './file-upload.js';
|
||
import { t, getCurrentLanguage } from './i18n.js';
|
||
import { renderRoutingExamples } from './routing-examples.js';
|
||
import { updateModelsProviderConfigs } from './models-manager.js';
|
||
import { updateTutorialProviderConfigs } from './tutorial-manager.js';
|
||
import { updateUsageProviderConfigs } from './usage-manager.js';
|
||
import { updateConfigProviderConfigs } from './config-manager.js';
|
||
import { loadConfigList, updateProviderFilterOptions } from './upload-config-manager.js';
|
||
import { setServiceMode } from './event-handlers.js';
|
||
|
||
// 保存初始服务器时间和运行时间
|
||
let initialServerTime = null;
|
||
let initialUptime = null;
|
||
let initialLoadTime = null;
|
||
let isStaticProviderConfigsUpdated = false;
|
||
let cachedSupportedProviders = null;
|
||
|
||
/**
|
||
* 加载系统信息
|
||
*/
|
||
async function loadSystemInfo() {
|
||
try {
|
||
const data = await window.apiClient.get('/system');
|
||
|
||
const appVersionEl = document.getElementById('appVersion');
|
||
const nodeVersionEl = document.getElementById('nodeVersion');
|
||
const serverTimeEl = document.getElementById('serverTime');
|
||
const memoryUsageEl = document.getElementById('memoryUsage');
|
||
const cpuUsageEl = document.getElementById('cpuUsage');
|
||
const uptimeEl = document.getElementById('uptime');
|
||
|
||
if (appVersionEl) appVersionEl.textContent = data.appVersion ? `v${data.appVersion}` : '--';
|
||
|
||
// 自动检查更新
|
||
if (data.appVersion) {
|
||
checkUpdate(true);
|
||
}
|
||
|
||
if (nodeVersionEl) nodeVersionEl.textContent = data.nodeVersion || '--';
|
||
if (memoryUsageEl) memoryUsageEl.textContent = data.memoryUsage || '--';
|
||
if (cpuUsageEl) cpuUsageEl.textContent = data.cpuUsage || '--';
|
||
|
||
// 保存初始时间用于本地计算
|
||
if (data.serverTime && data.uptime !== undefined) {
|
||
initialServerTime = new Date(data.serverTime);
|
||
initialUptime = data.uptime;
|
||
initialLoadTime = Date.now();
|
||
}
|
||
|
||
// 初始显示
|
||
if (serverTimeEl) {
|
||
serverTimeEl.textContent = data.serverTime ? new Date(data.serverTime).toLocaleString(getCurrentLanguage()) : '--';
|
||
}
|
||
if (uptimeEl) uptimeEl.textContent = data.uptime ? formatUptime(data.uptime) : '--';
|
||
|
||
// 加载服务模式信息
|
||
await loadServiceModeInfo();
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load system info:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载服务运行模式信息
|
||
*/
|
||
async function loadServiceModeInfo() {
|
||
try {
|
||
const data = await window.apiClient.get('/service-mode');
|
||
|
||
const serviceModeEl = document.getElementById('serviceMode');
|
||
const processPidEl = document.getElementById('processPid');
|
||
const platformInfoEl = document.getElementById('platformInfo');
|
||
|
||
// 更新服务模式到 event-handlers
|
||
setServiceMode(data.mode || 'worker');
|
||
|
||
// 更新重启/重载按钮显示
|
||
updateRestartButton(data.mode);
|
||
|
||
if (serviceModeEl) {
|
||
const modeText = data.mode === 'worker'
|
||
? t('dashboard.serviceMode.worker')
|
||
: t('dashboard.serviceMode.standalone');
|
||
const canRestartIcon = data.canAutoRestart
|
||
? '<i class="fas fa-check-circle" style="color: #10b981; margin-left: 4px;" title="' + t('dashboard.serviceMode.canRestart') + '"></i>'
|
||
: '';
|
||
serviceModeEl.innerHTML = modeText;
|
||
}
|
||
|
||
if (processPidEl) {
|
||
processPidEl.textContent = data.pid || '--';
|
||
}
|
||
|
||
if (platformInfoEl) {
|
||
// 格式化平台信息
|
||
const platformMap = {
|
||
'win32': 'Windows',
|
||
'darwin': 'macOS',
|
||
'linux': 'Linux',
|
||
'freebsd': 'FreeBSD'
|
||
};
|
||
platformInfoEl.textContent = platformMap[data.platform] || data.platform || '--';
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load service mode info:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据服务模式更新重启/重载按钮显示
|
||
* @param {string} mode - 服务模式 ('worker' 或 'standalone')
|
||
*/
|
||
function updateRestartButton(mode) {
|
||
const restartBtn = document.getElementById('restartBtn');
|
||
const restartBtnIcon = document.getElementById('restartBtnIcon');
|
||
const restartBtnText = document.getElementById('restartBtnText');
|
||
|
||
if (!restartBtn) return;
|
||
|
||
if (mode === 'standalone') {
|
||
// 独立模式:显示"重载"按钮
|
||
if (restartBtnIcon) {
|
||
restartBtnIcon.className = 'fas fa-sync-alt';
|
||
}
|
||
if (restartBtnText) {
|
||
restartBtnText.textContent = t('header.reload');
|
||
restartBtnText.setAttribute('data-i18n', 'header.reload');
|
||
}
|
||
restartBtn.setAttribute('aria-label', t('header.reload'));
|
||
restartBtn.setAttribute('data-i18n-aria-label', 'header.reload');
|
||
restartBtn.title = t('header.reload');
|
||
} else {
|
||
// 子进程模式:显示"重启"按钮
|
||
if (restartBtnIcon) {
|
||
restartBtnIcon.className = 'fas fa-redo';
|
||
}
|
||
if (restartBtnText) {
|
||
restartBtnText.textContent = t('header.restart');
|
||
restartBtnText.setAttribute('data-i18n', 'header.restart');
|
||
}
|
||
restartBtn.setAttribute('aria-label', t('header.restart'));
|
||
restartBtn.setAttribute('data-i18n-aria-label', 'header.restart');
|
||
restartBtn.title = t('header.restart');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新服务器时间和运行时间显示(本地计算)
|
||
*/
|
||
function updateTimeDisplay() {
|
||
if (!initialServerTime || initialUptime === null || !initialLoadTime) {
|
||
return;
|
||
}
|
||
|
||
const serverTimeEl = document.getElementById('serverTime');
|
||
const uptimeEl = document.getElementById('uptime');
|
||
|
||
// 计算经过的秒数
|
||
const elapsedSeconds = Math.floor((Date.now() - initialLoadTime) / 1000);
|
||
|
||
// 更新服务器时间
|
||
if (serverTimeEl) {
|
||
const currentServerTime = new Date(initialServerTime.getTime() + elapsedSeconds * 1000);
|
||
serverTimeEl.textContent = currentServerTime.toLocaleString(getCurrentLanguage());
|
||
}
|
||
|
||
// 更新运行时间
|
||
if (uptimeEl) {
|
||
const currentUptime = initialUptime + elapsedSeconds;
|
||
uptimeEl.textContent = formatUptime(currentUptime);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载提供商数据
|
||
* @param {boolean} forceRefreshSupported - 是否强制刷新支持的提供商列表
|
||
*/
|
||
async function loadProviders(forceRefreshSupported = false) {
|
||
try {
|
||
// 获取合并后的数据(包括 providers 和 supportedProviders)
|
||
const data = await window.apiClient.get('/providers');
|
||
if (!data || !data.providers) return;
|
||
|
||
const { providers, supportedProviders } = data;
|
||
|
||
// 检查支持列表是否发生了变化(或者是否尚未初始化)
|
||
const isChanged = !cachedSupportedProviders ||
|
||
supportedProviders.length !== cachedSupportedProviders.length ||
|
||
supportedProviders.some((p, i) => p !== cachedSupportedProviders[i]);
|
||
|
||
// 如果强制刷新或是对象类型(可能是由事件触发),则也视为需要刷新
|
||
const shouldForce = forceRefreshSupported === true || (typeof forceRefreshSupported === 'object');
|
||
|
||
if (isChanged || shouldForce) {
|
||
cachedSupportedProviders = supportedProviders;
|
||
const providerConfigs = getProviderConfigs(cachedSupportedProviders);
|
||
|
||
// 动态更新各个页面的提供商信息
|
||
updateModelsProviderConfigs(providerConfigs);
|
||
updateTutorialProviderConfigs(providerConfigs);
|
||
updateUsageProviderConfigs(providerConfigs);
|
||
updateConfigProviderConfigs(providerConfigs);
|
||
updateProviderFilterOptions(providerConfigs);
|
||
renderRoutingExamples(providerConfigs);
|
||
|
||
isStaticProviderConfigsUpdated = true;
|
||
}
|
||
|
||
renderProviders(providers, cachedSupportedProviders);
|
||
} catch (error) {
|
||
console.error('Failed to load providers:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染提供商列表
|
||
* @param {Object} providers - 提供商数据
|
||
* @param {string[]} supportedProviders - 已注册的提供商类型列表
|
||
*/
|
||
function renderProviders(providers, supportedProviders = []) {
|
||
const container = document.getElementById('providersList');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '';
|
||
|
||
// 检查是否有提供商池数据
|
||
const hasProviders = Object.keys(providers).length > 0;
|
||
const statsGrid = document.querySelector('#providers .stats-grid');
|
||
|
||
// 始终显示统计卡片
|
||
if (statsGrid) statsGrid.style.display = 'grid';
|
||
|
||
const providerConfigs = getProviderConfigs(supportedProviders);
|
||
|
||
// 提取显示的 ID 顺序
|
||
const providerDisplayOrder = providerConfigs.filter(c => c.visible !== false).map(c => c.id);
|
||
|
||
// 建立 ID 到配置的映射,方便获取显示名称
|
||
const configMap = providerConfigs.reduce((map, config) => {
|
||
map[config.id] = config;
|
||
return map;
|
||
}, {});
|
||
|
||
// 获取所有提供商类型并按指定顺序排序
|
||
// 优先显示预定义的所有提供商类型,即使某些提供商没有数据也要显示
|
||
let allProviderTypes;
|
||
if (hasProviders) {
|
||
// 合并预定义类型和实际存在的类型,确保显示所有预定义提供商
|
||
const actualProviderTypes = Object.keys(providers);
|
||
// 只保留配置中标记为 visible 的,或者不在配置中的(默认显示)
|
||
allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])];
|
||
} else {
|
||
allProviderTypes = providerDisplayOrder;
|
||
}
|
||
|
||
// 过滤掉明确设置为不显示的提供商
|
||
const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type))
|
||
.concat(allProviderTypes.filter(type => !providerDisplayOrder.some(t => t === type) && !configMap[type]?.visible === false));
|
||
|
||
// 计算总统计
|
||
let totalAccounts = 0;
|
||
let totalHealthy = 0;
|
||
|
||
// 按照排序后的提供商类型渲染
|
||
sortedProviderTypes.forEach((providerType) => {
|
||
// 如果配置中明确设置为不显示,则跳过
|
||
if (configMap[providerType] && configMap[providerType].visible === false) {
|
||
return;
|
||
}
|
||
|
||
const accounts = hasProviders ? providers[providerType] || [] : [];
|
||
const providerDiv = document.createElement('div');
|
||
providerDiv.className = 'provider-item';
|
||
providerDiv.dataset.providerType = providerType;
|
||
providerDiv.style.cursor = 'pointer';
|
||
|
||
const healthyCount = accounts.filter(acc => acc.isHealthy && !acc.isDisabled).length;
|
||
const totalCount = accounts.length;
|
||
const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0);
|
||
const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0);
|
||
|
||
totalAccounts += totalCount;
|
||
totalHealthy += healthyCount;
|
||
|
||
// 更新全局统计变量
|
||
if (!providerStats.providerTypeStats[providerType]) {
|
||
providerStats.providerTypeStats[providerType] = {
|
||
totalAccounts: 0,
|
||
healthyAccounts: 0,
|
||
totalUsage: 0,
|
||
totalErrors: 0,
|
||
lastUpdate: null
|
||
};
|
||
}
|
||
|
||
const typeStats = providerStats.providerTypeStats[providerType];
|
||
typeStats.totalAccounts = totalCount;
|
||
typeStats.healthyAccounts = healthyCount;
|
||
typeStats.totalUsage = usageCount;
|
||
typeStats.totalErrors = errorCount;
|
||
typeStats.lastUpdate = new Date().toISOString();
|
||
|
||
// 为无数据状态设置特殊样式
|
||
const isEmptyState = !hasProviders || totalCount === 0;
|
||
const statusClass = isEmptyState ? 'status-empty' : (healthyCount === totalCount ? 'status-healthy' : 'status-unhealthy');
|
||
const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle');
|
||
const statusText = isEmptyState ? t('providers.status.empty') : t('providers.status.healthy', { healthy: healthyCount, total: totalCount });
|
||
|
||
// 获取显示名称
|
||
const displayName = configMap[providerType]?.name || providerType;
|
||
|
||
providerDiv.innerHTML = `
|
||
<div class="provider-header">
|
||
<div class="provider-name">
|
||
<span class="provider-type-text">${displayName}</span>
|
||
</div>
|
||
<div class="provider-header-right">
|
||
${generateAddGroupButton(providerType)}
|
||
${generateAuthButton(providerType)}
|
||
<div class="provider-status ${statusClass}">
|
||
<i class="fas fa-${statusIcon}"></i>
|
||
<span>${statusText}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="provider-stats">
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label" data-i18n="providers.stat.totalAccounts">${t('providers.stat.totalAccounts')}</span>
|
||
<span class="provider-stat-value">${totalCount}</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label" data-i18n="providers.stat.healthyAccounts">${t('providers.stat.healthyAccounts')}</span>
|
||
<span class="provider-stat-value">${healthyCount}</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label" data-i18n="providers.stat.usageCount">${t('providers.stat.usageCount')}</span>
|
||
<span class="provider-stat-value">${usageCount}</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label" data-i18n="providers.stat.errorCount">${t('providers.stat.errorCount')}</span>
|
||
<span class="provider-stat-value">${errorCount}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 如果是空状态,添加特殊样式
|
||
if (isEmptyState) {
|
||
providerDiv.classList.add('empty-provider');
|
||
}
|
||
|
||
// 添加点击事件 - 整个提供商组都可以点击
|
||
providerDiv.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
openProviderManager(providerType);
|
||
});
|
||
|
||
container.appendChild(providerDiv);
|
||
|
||
// 为添加分组按钮添加事件监听
|
||
const addGroupBtn = providerDiv.querySelector('.add-group-btn');
|
||
if (addGroupBtn) {
|
||
addGroupBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
|
||
// 使用自定义的主题风格 Prompt
|
||
showSimplePrompt(
|
||
t('providers.addGroup.title'),
|
||
t('providers.addGroup.suffixPlaceholder'),
|
||
async (suffix) => {
|
||
const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||
if (!cleanSuffix) {
|
||
showToast(t('common.warning'), t('common.invalidSuffix'), 'warning');
|
||
return;
|
||
}
|
||
|
||
const newProviderType = `${providerType}-${cleanSuffix}`;
|
||
|
||
// 显示加载状态
|
||
addGroupBtn.disabled = true;
|
||
const originalHtml = addGroupBtn.innerHTML;
|
||
addGroupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||
|
||
try {
|
||
const response = await window.apiClient.post('/providers', {
|
||
providerType: newProviderType,
|
||
providerConfig: {
|
||
customName: cleanSuffix.toUpperCase(),
|
||
isHealthy: true,
|
||
isDisabled: false,
|
||
usageCount: 0,
|
||
errorCount: 0
|
||
}
|
||
});
|
||
|
||
if (response.success) {
|
||
showToast(t('common.success'), t('providers.addGroup.success'), 'success');
|
||
await loadProviders(true);
|
||
setTimeout(() => openProviderManager(newProviderType), 500);
|
||
} else {
|
||
throw new Error(response.error?.message || 'Unknown error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to add provider group:', error);
|
||
showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error');
|
||
addGroupBtn.disabled = false;
|
||
addGroupBtn.innerHTML = originalHtml;
|
||
}
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// 为授权按钮添加事件监听
|
||
const authBtn = providerDiv.querySelector('.generate-auth-btn');
|
||
if (authBtn) {
|
||
authBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation(); // 阻止事件冒泡到父元素
|
||
handleGenerateAuthUrl(providerType);
|
||
});
|
||
}
|
||
});
|
||
|
||
// 更新统计卡片数据
|
||
const activeProviders = hasProviders ? Object.keys(providers).length : 0;
|
||
updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts);
|
||
}
|
||
|
||
/**
|
||
* 更新提供商统计信息
|
||
* @param {number} activeProviders - 活跃提供商数
|
||
* @param {number} healthyProviders - 健康提供商数
|
||
* @param {number} totalAccounts - 总账户数
|
||
*/
|
||
function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAccounts) {
|
||
// 更新全局统计变量
|
||
const newStats = {
|
||
activeProviders,
|
||
healthyProviders,
|
||
totalAccounts,
|
||
lastUpdateTime: new Date().toISOString()
|
||
};
|
||
|
||
updateProviderStats(newStats);
|
||
|
||
// 计算总请求数和错误数
|
||
let totalUsage = 0;
|
||
let totalErrors = 0;
|
||
Object.values(providerStats.providerTypeStats).forEach(typeStats => {
|
||
totalUsage += typeStats.totalUsage || 0;
|
||
totalErrors += typeStats.totalErrors || 0;
|
||
});
|
||
|
||
const finalStats = {
|
||
...newStats,
|
||
totalRequests: totalUsage,
|
||
totalErrors: totalErrors
|
||
};
|
||
|
||
updateProviderStats(finalStats);
|
||
|
||
// 修改:根据使用次数统计"活跃提供商"和"活动连接"
|
||
// "活跃提供商":统计有使用次数(usageCount > 0)的提供商类型数量
|
||
let activeProvidersByUsage = 0;
|
||
Object.entries(providerStats.providerTypeStats).forEach(([providerType, typeStats]) => {
|
||
if (typeStats.totalUsage > 0) {
|
||
activeProvidersByUsage++;
|
||
}
|
||
});
|
||
|
||
// "活动连接":统计所有提供商账户的使用次数总和
|
||
const activeConnections = totalUsage;
|
||
|
||
// 更新页面显示
|
||
const activeProvidersEl = document.getElementById('activeProviders');
|
||
const healthyProvidersEl = document.getElementById('healthyProviders');
|
||
const activeConnectionsEl = document.getElementById('activeConnections');
|
||
|
||
if (activeProvidersEl) activeProvidersEl.textContent = activeProvidersByUsage;
|
||
if (healthyProvidersEl) healthyProvidersEl.textContent = healthyProviders;
|
||
if (activeConnectionsEl) activeConnectionsEl.textContent = activeConnections;
|
||
|
||
// 打印调试信息到控制台
|
||
console.log('Provider Stats Updated:', {
|
||
activeProviders,
|
||
activeProvidersByUsage,
|
||
healthyProviders,
|
||
totalAccounts,
|
||
totalUsage,
|
||
totalErrors,
|
||
providerTypeStats: providerStats.providerTypeStats
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 打开提供商管理模态框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
async function openProviderManager(providerType) {
|
||
try {
|
||
const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`);
|
||
|
||
showProviderManagerModal(data);
|
||
} catch (error) {
|
||
console.error('Failed to load provider details:', error);
|
||
showToast(t('common.error'), t('modal.provider.load.failed'), 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成授权按钮HTML
|
||
* @param {string} providerType - 提供商类型
|
||
* @returns {string} 授权按钮HTML
|
||
*/
|
||
function generateAuthButton(providerType) {
|
||
// 只为支持OAuth的提供商显示授权按钮
|
||
const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth'];
|
||
|
||
if (!oauthProviders.includes(providerType)) {
|
||
return '';
|
||
}
|
||
|
||
// Codex 提供商使用特殊图标
|
||
if (providerType === 'openai-codex-oauth') {
|
||
return `
|
||
<button class="generate-auth-btn" title="生成 Codex OAuth 授权链接">
|
||
<i class="fas fa-key"></i>
|
||
<span data-i18n="providers.auth.generate">${t('providers.auth.generate')}</span>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<button class="generate-auth-btn" title="生成OAuth授权链接">
|
||
<i class="fas fa-key"></i>
|
||
<span data-i18n="providers.auth.generate">${t('providers.auth.generate')}</span>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 显示一个极简的主题风格输入框
|
||
* @param {string} title - 标题
|
||
* @param {string} placeholder - 占位符
|
||
* @param {function} callback - 确认回调
|
||
*/
|
||
function showSimplePrompt(title, placeholder, callback) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.style.display = 'flex';
|
||
overlay.style.zIndex = '3000';
|
||
overlay.style.background = 'rgba(0, 0, 0, 0.2)';
|
||
overlay.style.backdropFilter = 'blur(2px)';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal-content" style="max-width: 320px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: 1px solid var(--border-color); padding: 20px;">
|
||
<div style="margin-bottom: 12px; font-weight: 600; font-size: 14px; color: var(--text-primary);">${title}</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<input type="text" id="simple-prompt-input" placeholder="${placeholder}" style="flex: 1; padding: 8px 12px; border: 1.5px solid var(--border-color); border-radius: 6px; font-size: 13px; outline: none;">
|
||
<button id="simple-prompt-submit" class="btn btn-primary btn-sm" style="padding: 0 12px; height: 34px; border-radius: 6px; font-size: 13px;">${t('common.confirm')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const input = overlay.querySelector('#simple-prompt-input');
|
||
const submitBtn = overlay.querySelector('#simple-prompt-submit');
|
||
|
||
input.focus();
|
||
|
||
const finish = () => {
|
||
const val = input.value.trim();
|
||
if (val) {
|
||
overlay.remove();
|
||
callback(val);
|
||
}
|
||
};
|
||
|
||
submitBtn.onclick = finish;
|
||
input.onkeydown = (e) => {
|
||
if (e.key === 'Enter') finish();
|
||
if (e.key === 'Escape') overlay.remove();
|
||
};
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) overlay.remove();
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 生成添加分组按钮HTML
|
||
* @param {string} providerType - 提供商类型
|
||
* @returns {string} 按钮HTML
|
||
*/
|
||
function generateAddGroupButton(providerType) {
|
||
const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom'];
|
||
if (!allowedTypes.includes(providerType)) {
|
||
return '';
|
||
}
|
||
|
||
return `
|
||
<button class="add-group-btn" title="${t('providers.addGroup.title')}">
|
||
<i class="fas fa-folder-plus"></i>
|
||
<span data-i18n="providers.addGroup">${t('providers.addGroup')}</span>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 处理生成授权链接
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
async function handleGenerateAuthUrl(providerType) {
|
||
// 如果是 Kiro OAuth,先显示认证方式选择对话框
|
||
if (providerType === 'claude-kiro-oauth') {
|
||
showKiroAuthMethodSelector(providerType);
|
||
return;
|
||
}
|
||
|
||
// 如果是 Gemini OAuth 或 Antigravity,显示认证方式选择对话框
|
||
if (providerType === 'gemini-cli-oauth' || providerType === 'gemini-antigravity') {
|
||
showGeminiAuthMethodSelector(providerType);
|
||
return;
|
||
}
|
||
|
||
// 如果是 Codex OAuth,显示认证方式选择对话框
|
||
if (providerType === 'openai-codex-oauth') {
|
||
showCodexAuthMethodSelector(providerType);
|
||
return;
|
||
}
|
||
|
||
await executeGenerateAuthUrl(providerType, {});
|
||
}
|
||
|
||
/**
|
||
* 显示 Codex OAuth 认证方式选择对话框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
function showCodexAuthMethodSelector(providerType) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.gemini.selectMethod">${t('oauth.gemini.selectMethod')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="auth-method-options" style="display: flex; flex-direction: column; gap: 12px;">
|
||
<button class="auth-method-btn" data-method="oauth" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fab fa-google" style="font-size: 24px; color: #4285f4;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.oauth">${t('oauth.gemini.oauth')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.oauthDesc">${t('oauth.gemini.oauthDesc')}</div>
|
||
</div>
|
||
</button>
|
||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.codex.batchImport">${t('oauth.codex.batchImport')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.codex.batchImportDesc">${t('oauth.codex.batchImportDesc')}</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭按钮事件
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 认证方式选择按钮事件
|
||
const methodBtns = modal.querySelectorAll('.auth-method-btn');
|
||
methodBtns.forEach(btn => {
|
||
btn.addEventListener('mouseenter', () => {
|
||
btn.style.borderColor = '#4285f4';
|
||
btn.style.background = '#f8faff';
|
||
});
|
||
btn.addEventListener('mouseleave', () => {
|
||
btn.style.borderColor = '#e0e0e0';
|
||
btn.style.background = 'white';
|
||
});
|
||
btn.addEventListener('click', async () => {
|
||
const method = btn.dataset.method;
|
||
modal.remove();
|
||
|
||
if (method === 'batch-import') {
|
||
showCodexBatchImportModal(providerType);
|
||
} else {
|
||
await executeGenerateAuthUrl(providerType, {});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Codex 批量导入模态框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
function showCodexBatchImportModal(providerType) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.codex.batchImport">${t('oauth.codex.batchImport')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px;">
|
||
<p style="margin: 0; font-size: 14px; color: #1e40af;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<span data-i18n="oauth.codex.importInstructions">${t('oauth.codex.importInstructions')}</span>
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="batchCodexTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<span data-i18n="oauth.codex.tokensLabel">${t('oauth.codex.tokensLabel')}</span>
|
||
</label>
|
||
<textarea
|
||
id="batchCodexTokens"
|
||
rows="10"
|
||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||
placeholder='${t('oauth.codex.tokensPlaceholder')}'
|
||
data-i18n-placeholder="oauth.codex.tokensPlaceholder"
|
||
></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top: 12px; margin-bottom: 16px;">
|
||
<details style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
|
||
<i class="fas fa-code" style="color: #4285f4; margin-right: 8px;"></i>
|
||
<span data-i18n="oauth.codex.jsonExample">${t('oauth.codex.jsonExample')}</span>
|
||
</summary>
|
||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||
"access_token": "eyJhbG...",
|
||
"id_token": "eyJhbG...",
|
||
"refresh_token": "...",
|
||
"token_type": "Bearer",
|
||
"expires_in": 3600
|
||
}</pre>
|
||
</div>
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||
{
|
||
"access_token": "token1...",
|
||
"id_token": "id1..."
|
||
},
|
||
{
|
||
"access_token": "token2...",
|
||
"id_token": "id2..."
|
||
}
|
||
]</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="batch-import-stats" id="codexBatchStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span data-i18n="oauth.codex.tokenCount">${t('oauth.codex.tokenCount')}</span>
|
||
<span id="codexTokenCountValue" style="font-weight: 600;">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-progress" id="codexBatchProgress" style="display: none; margin-top: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||
<span data-i18n="oauth.codex.importing">${t('oauth.codex.importing')}</span>
|
||
</div>
|
||
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||
<div id="codexImportProgressBar" style="height: 100%; width: 0%; background: #4285f4; transition: width 0.3s;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-result" id="codexBatchResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="btn btn-primary batch-import-submit" id="codexBatchSubmit">
|
||
<i class="fas fa-upload"></i>
|
||
<span data-i18n="oauth.codex.startImport">${t('oauth.codex.startImport')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const textarea = modal.querySelector('#batchCodexTokens');
|
||
const statsDiv = modal.querySelector('#codexBatchStats');
|
||
const tokenCountValue = modal.querySelector('#codexTokenCountValue');
|
||
const progressDiv = modal.querySelector('#codexBatchProgress');
|
||
const progressBar = modal.querySelector('#codexImportProgressBar');
|
||
const resultDiv = modal.querySelector('#codexBatchResult');
|
||
const submitBtn = modal.querySelector('#codexBatchSubmit');
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
|
||
// 实时统计 token 数量
|
||
textarea.addEventListener('input', () => {
|
||
try {
|
||
const val = textarea.value.trim();
|
||
if (!val) {
|
||
statsDiv.style.display = 'none';
|
||
return;
|
||
}
|
||
const data = JSON.parse(val);
|
||
const tokens = Array.isArray(data) ? data : [data];
|
||
statsDiv.style.display = 'block';
|
||
tokenCountValue.textContent = tokens.length;
|
||
} catch (e) {
|
||
statsDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// 关闭按钮事件
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 提交按钮事件
|
||
submitBtn.addEventListener('click', async () => {
|
||
let tokens = [];
|
||
try {
|
||
const val = textarea.value.trim();
|
||
const data = JSON.parse(val);
|
||
tokens = Array.isArray(data) ? data : [data];
|
||
} catch (e) {
|
||
showToast(t('common.error'), t('oauth.codex.noTokens'), 'error');
|
||
return;
|
||
}
|
||
|
||
if (tokens.length === 0) {
|
||
showToast(t('common.warning'), t('oauth.codex.noTokens'), 'warning');
|
||
return;
|
||
}
|
||
|
||
// 禁用输入和按钮
|
||
textarea.disabled = true;
|
||
submitBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
progressDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
progressBar.style.width = '0%';
|
||
|
||
// 创建实时结果显示区域
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||
<strong id="codexBatchProgressText">${t('oauth.codex.importingProgress', { current: 0, total: tokens.length })}</strong>
|
||
</div>
|
||
<div id="codexBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||
`;
|
||
|
||
const progressText = resultDiv.querySelector('#codexBatchProgressText');
|
||
const resultsList = resultDiv.querySelector('#codexBatchResultsList');
|
||
|
||
let importSuccess = false; // 标记是否导入成功
|
||
|
||
try {
|
||
const response = await fetch('/api/codex/batch-import-tokens', {
|
||
method: 'POST',
|
||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ tokens })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
let eventType = '';
|
||
let eventData = '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ')) {
|
||
eventData = line.substring(6).trim();
|
||
|
||
if (eventType && eventData) {
|
||
try {
|
||
const data = JSON.parse(eventData);
|
||
|
||
if (eventType === 'progress') {
|
||
const { index, total, current } = data;
|
||
const percentage = Math.round((index / total) * 100);
|
||
progressBar.style.width = `${percentage}%`;
|
||
progressText.textContent = t('oauth.codex.importingProgress', { current: index, total: total });
|
||
|
||
const resultItem = document.createElement('div');
|
||
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
|
||
if (current.success) {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||
} else if (current.error === 'duplicate') {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
|
||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||
} else {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||
}
|
||
resultsList.appendChild(resultItem);
|
||
resultsList.scrollTop = resultsList.scrollHeight;
|
||
} else if (eventType === 'complete') {
|
||
progressBar.style.width = '100%';
|
||
progressDiv.style.display = 'none';
|
||
|
||
const isAllSuccess = data.failedCount === 0;
|
||
const isAllFailed = data.successCount === 0;
|
||
let resultClass, resultIcon, resultMessage;
|
||
|
||
if (isAllSuccess) {
|
||
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
resultIcon = 'fa-check-circle';
|
||
resultMessage = t('oauth.codex.importSuccess', { count: data.successCount });
|
||
} else if (isAllFailed) {
|
||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultIcon = 'fa-times-circle';
|
||
resultMessage = t('oauth.codex.importAllFailed', { count: data.failedCount });
|
||
} else {
|
||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||
resultIcon = 'fa-exclamation-triangle';
|
||
resultMessage = t('oauth.codex.importPartial', { success: data.successCount, failed: data.failedCount });
|
||
}
|
||
|
||
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||
const headerDiv = resultDiv.querySelector('div:first-child');
|
||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||
|
||
if (data.successCount > 0) {
|
||
importSuccess = true;
|
||
loadProviders();
|
||
loadConfigList();
|
||
}
|
||
} else if (eventType === 'error') {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('Failed to parse SSE data:', parseError);
|
||
}
|
||
eventType = '';
|
||
eventData = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[Codex Batch Import] Failed:', error);
|
||
progressDiv.style.display = 'none';
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-times-circle"></i>
|
||
<strong>${t('oauth.codex.importError')}: ${error.message}</strong>
|
||
</div>
|
||
`;
|
||
} finally {
|
||
cancelBtn.disabled = false;
|
||
|
||
if (!importSuccess) {
|
||
textarea.disabled = false;
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.codex.startImport">${t('oauth.codex.startImport')}</span>`;
|
||
} else {
|
||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Kiro OAuth 认证方式选择对话框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
function showKiroAuthMethodSelector(providerType) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 550px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.kiro.selectMethod">${t('oauth.kiro.selectMethod')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="auth-method-options" style="display: flex; flex-direction: column; gap: 12px;">
|
||
<!-- <button class="auth-method-btn" data-method="google" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fab fa-google" style="font-size: 24px; color: #4285f4;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.google">${t('oauth.kiro.google')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.googleDesc">${t('oauth.kiro.googleDesc')}</div>
|
||
</div>
|
||
</button>
|
||
<button class="auth-method-btn" data-method="github" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fab fa-github" style="font-size: 24px; color: #333;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.github">${t('oauth.kiro.github')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.githubDesc">${t('oauth.kiro.githubDesc')}</div>
|
||
</div>
|
||
</button> -->
|
||
<button class="auth-method-btn" data-method="builder-id" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fab fa-aws" style="font-size: 24px; color: #ff9900;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.awsBuilder">${t('oauth.kiro.awsBuilder')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsBuilderDesc">${t('oauth.kiro.awsBuilderDesc')}</div>
|
||
</div>
|
||
</button>
|
||
<button class="auth-method-btn" data-method="aws-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-cloud-upload-alt" style="font-size: 24px; color: #ff9900;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.awsImport">${t('oauth.kiro.awsImport')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsImportDesc">${t('oauth.kiro.awsImportDesc')}</div>
|
||
</div>
|
||
</button>
|
||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.batchImportDesc">${t('oauth.kiro.batchImportDesc')}</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭按钮事件
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 认证方式选择按钮事件
|
||
const methodBtns = modal.querySelectorAll('.auth-method-btn');
|
||
methodBtns.forEach(btn => {
|
||
btn.addEventListener('mouseenter', () => {
|
||
btn.style.borderColor = '#00a67e';
|
||
btn.style.background = '#f8fffe';
|
||
});
|
||
btn.addEventListener('mouseleave', () => {
|
||
btn.style.borderColor = '#e0e0e0';
|
||
btn.style.background = 'white';
|
||
});
|
||
btn.addEventListener('click', async () => {
|
||
const method = btn.dataset.method;
|
||
modal.remove();
|
||
|
||
if (method === 'batch-import') {
|
||
showKiroBatchImportModal();
|
||
} else if (method === 'aws-import') {
|
||
showKiroAwsImportModal();
|
||
} else {
|
||
await executeGenerateAuthUrl(providerType, { method });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Gemini OAuth 认证方式选择对话框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
function showGeminiAuthMethodSelector(providerType) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.gemini.selectMethod">${t('oauth.gemini.selectMethod')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="auth-method-options" style="display: flex; flex-direction: column; gap: 12px;">
|
||
<button class="auth-method-btn" data-method="oauth" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fab fa-google" style="font-size: 24px; color: #4285f4;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.oauth">${t('oauth.gemini.oauth')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.oauthDesc">${t('oauth.gemini.oauthDesc')}</div>
|
||
</div>
|
||
</button>
|
||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||
<div style="text-align: left;">
|
||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.batchImport">${t('oauth.gemini.batchImport')}</div>
|
||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.batchImportDesc">${t('oauth.gemini.batchImportDesc')}</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭按钮事件
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 认证方式选择按钮事件
|
||
const methodBtns = modal.querySelectorAll('.auth-method-btn');
|
||
methodBtns.forEach(btn => {
|
||
btn.addEventListener('mouseenter', () => {
|
||
btn.style.borderColor = '#4285f4';
|
||
btn.style.background = '#f8faff';
|
||
});
|
||
btn.addEventListener('mouseleave', () => {
|
||
btn.style.borderColor = '#e0e0e0';
|
||
btn.style.background = 'white';
|
||
});
|
||
btn.addEventListener('click', async () => {
|
||
const method = btn.dataset.method;
|
||
modal.remove();
|
||
|
||
if (method === 'batch-import') {
|
||
showGeminiBatchImportModal(providerType);
|
||
} else {
|
||
await executeGenerateAuthUrl(providerType, {});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Gemini 批量导入模态框
|
||
* @param {string} providerType - 提供商类型
|
||
*/
|
||
function showGeminiBatchImportModal(providerType) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.gemini.batchImport">${t('oauth.gemini.batchImport')}</span> (${providerType})</h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px;">
|
||
<p style="margin: 0; font-size: 14px; color: #1e40af;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<span data-i18n="oauth.gemini.importInstructions">${t('oauth.gemini.importInstructions')}</span>
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="batchGeminiTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<span data-i18n="oauth.gemini.tokensLabel">${t('oauth.gemini.tokensLabel')}</span>
|
||
</label>
|
||
<textarea
|
||
id="batchGeminiTokens"
|
||
rows="10"
|
||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||
placeholder='${t('oauth.gemini.tokensPlaceholder')}'
|
||
data-i18n-placeholder="oauth.gemini.tokensPlaceholder"
|
||
></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top: 12px; margin-bottom: 16px;">
|
||
<details style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
|
||
<i class="fas fa-code" style="color: #4285f4; margin-right: 8px;"></i>
|
||
<span data-i18n="oauth.gemini.jsonExample">${t('oauth.gemini.jsonExample')}</span>
|
||
</summary>
|
||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||
"access_token": "ya29.a0A...",
|
||
"refresh_token": "1//0...",
|
||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||
"token_type": "Bearer",
|
||
"expiry_date": 1738590000000
|
||
}</pre>
|
||
</div>
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||
{
|
||
"access_token": "ya29.a0A1...",
|
||
"refresh_token": "1//0..."
|
||
},
|
||
{
|
||
"access_token": "ya29.a0A2...",
|
||
"refresh_token": "1//0..."
|
||
}
|
||
]</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="batch-import-stats" id="geminiBatchStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span data-i18n="oauth.gemini.tokenCount">${t('oauth.gemini.tokenCount')}</span>
|
||
<span id="geminiTokenCountValue" style="font-weight: 600;">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-progress" id="geminiBatchProgress" style="display: none; margin-top: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||
<span data-i18n="oauth.gemini.importing">${t('oauth.gemini.importing')}</span>
|
||
</div>
|
||
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||
<div id="geminiImportProgressBar" style="height: 100%; width: 0%; background: #4285f4; transition: width 0.3s;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-result" id="geminiBatchResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="btn btn-primary batch-import-submit" id="geminiBatchSubmit">
|
||
<i class="fas fa-upload"></i>
|
||
<span data-i18n="oauth.gemini.startImport">${t('oauth.gemini.startImport')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const textarea = modal.querySelector('#batchGeminiTokens');
|
||
const statsDiv = modal.querySelector('#geminiBatchStats');
|
||
const tokenCountValue = modal.querySelector('#geminiTokenCountValue');
|
||
const progressDiv = modal.querySelector('#geminiBatchProgress');
|
||
const progressBar = modal.querySelector('#geminiImportProgressBar');
|
||
const resultDiv = modal.querySelector('#geminiBatchResult');
|
||
const submitBtn = modal.querySelector('#geminiBatchSubmit');
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
|
||
// 实时统计 token 数量
|
||
textarea.addEventListener('input', () => {
|
||
try {
|
||
const val = textarea.value.trim();
|
||
if (!val) {
|
||
statsDiv.style.display = 'none';
|
||
return;
|
||
}
|
||
const data = JSON.parse(val);
|
||
const tokens = Array.isArray(data) ? data : [data];
|
||
statsDiv.style.display = 'block';
|
||
tokenCountValue.textContent = tokens.length;
|
||
} catch (e) {
|
||
statsDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// 关闭按钮事件
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 提交按钮事件
|
||
submitBtn.addEventListener('click', async () => {
|
||
let tokens = [];
|
||
try {
|
||
const val = textarea.value.trim();
|
||
const data = JSON.parse(val);
|
||
tokens = Array.isArray(data) ? data : [data];
|
||
} catch (e) {
|
||
showToast(t('common.error'), t('oauth.gemini.noTokens'), 'error');
|
||
return;
|
||
}
|
||
|
||
if (tokens.length === 0) {
|
||
showToast(t('common.warning'), t('oauth.gemini.noTokens'), 'warning');
|
||
return;
|
||
}
|
||
|
||
// 禁用输入和按钮
|
||
textarea.disabled = true;
|
||
submitBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
progressDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
progressBar.style.width = '0%';
|
||
|
||
// 创建实时结果显示区域
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||
<strong id="geminiBatchProgressText">${t('oauth.gemini.importingProgress', { current: 0, total: tokens.length })}</strong>
|
||
</div>
|
||
<div id="geminiBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||
`;
|
||
|
||
const progressText = resultDiv.querySelector('#geminiBatchProgressText');
|
||
const resultsList = resultDiv.querySelector('#geminiBatchResultsList');
|
||
|
||
let importSuccess = false; // 标记是否导入成功
|
||
|
||
try {
|
||
const response = await fetch('/api/gemini/batch-import-tokens', {
|
||
method: 'POST',
|
||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ providerType, tokens })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
let eventType = '';
|
||
let eventData = '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ')) {
|
||
eventData = line.substring(6).trim();
|
||
|
||
if (eventType && eventData) {
|
||
try {
|
||
const data = JSON.parse(eventData);
|
||
|
||
if (eventType === 'progress') {
|
||
const { index, total, current } = data;
|
||
const percentage = Math.round((index / total) * 100);
|
||
progressBar.style.width = `${percentage}%`;
|
||
progressText.textContent = t('oauth.gemini.importingProgress', { current: index, total: total });
|
||
|
||
const resultItem = document.createElement('div');
|
||
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
|
||
if (current.success) {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||
} else if (current.error === 'duplicate') {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
|
||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||
} else {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||
}
|
||
resultsList.appendChild(resultItem);
|
||
resultsList.scrollTop = resultsList.scrollHeight;
|
||
} else if (eventType === 'complete') {
|
||
progressBar.style.width = '100%';
|
||
progressDiv.style.display = 'none';
|
||
|
||
const isAllSuccess = data.failedCount === 0;
|
||
const isAllFailed = data.successCount === 0;
|
||
let resultClass, resultIcon, resultMessage;
|
||
|
||
if (isAllSuccess) {
|
||
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
resultIcon = 'fa-check-circle';
|
||
resultMessage = t('oauth.gemini.importSuccess', { count: data.successCount });
|
||
} else if (isAllFailed) {
|
||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultIcon = 'fa-times-circle';
|
||
resultMessage = t('oauth.gemini.importAllFailed', { count: data.failedCount });
|
||
} else {
|
||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||
resultIcon = 'fa-exclamation-triangle';
|
||
resultMessage = t('oauth.gemini.importPartial', { success: data.successCount, failed: data.failedCount });
|
||
}
|
||
|
||
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||
const headerDiv = resultDiv.querySelector('div:first-child');
|
||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||
|
||
if (data.successCount > 0) {
|
||
importSuccess = true;
|
||
loadProviders();
|
||
loadConfigList();
|
||
}
|
||
} else if (eventType === 'error') {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('Failed to parse SSE data:', parseError);
|
||
}
|
||
eventType = '';
|
||
eventData = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[Gemini Batch Import] Failed:', error);
|
||
progressDiv.style.display = 'none';
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-times-circle"></i>
|
||
<strong>${t('oauth.gemini.importError')}: ${error.message}</strong>
|
||
</div>
|
||
`;
|
||
} finally {
|
||
cancelBtn.disabled = false;
|
||
|
||
if (!importSuccess) {
|
||
textarea.disabled = false;
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.gemini.startImport">${t('oauth.gemini.startImport')}</span>`;
|
||
} else {
|
||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Kiro 批量导入 refreshToken 模态框
|
||
*/
|
||
function showKiroBatchImportModal() {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;">
|
||
<p style="margin: 0; font-size: 14px; color: #166534;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<span data-i18n="oauth.kiro.batchImportInstructions">${t('oauth.kiro.batchImportInstructions')}</span>
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="batchRefreshTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<span data-i18n="oauth.kiro.refreshTokensLabel">${t('oauth.kiro.refreshTokensLabel')}</span>
|
||
</label>
|
||
<textarea
|
||
id="batchRefreshTokens"
|
||
rows="10"
|
||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||
placeholder="${t('oauth.kiro.refreshTokensPlaceholder')}"
|
||
data-i18n-placeholder="oauth.kiro.refreshTokensPlaceholder"
|
||
></textarea>
|
||
</div>
|
||
<div class="batch-import-stats" id="batchImportStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span data-i18n="oauth.kiro.tokenCount">${t('oauth.kiro.tokenCount')}</span>
|
||
<span id="tokenCountValue" style="font-weight: 600;">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-progress" id="batchImportProgress" style="display: none; margin-top: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #10b981;"></i>
|
||
<span data-i18n="oauth.kiro.importing">${t('oauth.kiro.importing')}</span>
|
||
</div>
|
||
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||
<div id="importProgressBar" style="height: 100%; width: 0%; background: #10b981; transition: width 0.3s;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="batch-import-result" id="batchImportResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="btn btn-primary batch-import-submit" id="batchImportSubmit">
|
||
<i class="fas fa-upload"></i>
|
||
<span data-i18n="oauth.kiro.startImport">${t('oauth.kiro.startImport')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const textarea = modal.querySelector('#batchRefreshTokens');
|
||
const statsDiv = modal.querySelector('#batchImportStats');
|
||
const tokenCountValue = modal.querySelector('#tokenCountValue');
|
||
const progressDiv = modal.querySelector('#batchImportProgress');
|
||
const progressBar = modal.querySelector('#importProgressBar');
|
||
const resultDiv = modal.querySelector('#batchImportResult');
|
||
const submitBtn = modal.querySelector('#batchImportSubmit');
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
|
||
// 实时统计 token 数量
|
||
textarea.addEventListener('input', () => {
|
||
const tokens = textarea.value.split('\n').filter(line => line.trim());
|
||
if (tokens.length > 0) {
|
||
statsDiv.style.display = 'block';
|
||
tokenCountValue.textContent = tokens.length;
|
||
} else {
|
||
statsDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// 关闭按钮事件
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 提交按钮事件 - 使用 SSE 流式响应实时显示进度
|
||
submitBtn.addEventListener('click', async () => {
|
||
const tokens = textarea.value.split('\n').filter(line => line.trim());
|
||
|
||
if (tokens.length === 0) {
|
||
showToast(t('common.warning'), t('oauth.kiro.noTokens'), 'warning');
|
||
return;
|
||
}
|
||
|
||
// 禁用输入和按钮
|
||
textarea.disabled = true;
|
||
submitBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
progressDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
progressBar.style.width = '0%';
|
||
|
||
// 创建实时结果显示区域
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #10b981;"></i>
|
||
<strong id="batchProgressText">${t('oauth.kiro.importingProgress', { current: 0, total: tokens.length })}</strong>
|
||
</div>
|
||
<div id="batchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||
`;
|
||
|
||
const progressText = resultDiv.querySelector('#batchProgressText');
|
||
const resultsList = resultDiv.querySelector('#batchResultsList');
|
||
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
const details = [];
|
||
let importSuccess = false; // 标记是否导入成功
|
||
|
||
try {
|
||
// 使用 fetch + SSE 获取流式响应(需要带认证头)
|
||
const response = await fetch('/api/kiro/batch-import-tokens', {
|
||
method: 'POST',
|
||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ refreshTokens: tokens })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// 解析 SSE 事件
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || ''; // 保留最后一个可能不完整的行
|
||
|
||
let eventType = '';
|
||
let eventData = '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ')) {
|
||
eventData = line.substring(6).trim();
|
||
|
||
if (eventType && eventData) {
|
||
try {
|
||
const data = JSON.parse(eventData);
|
||
|
||
if (eventType === 'start') {
|
||
// 开始事件
|
||
console.log(`[Batch Import] Starting import of ${data.total} tokens`);
|
||
} else if (eventType === 'progress') {
|
||
// 进度更新
|
||
const { index, total, current, successCount: sc, failedCount: fc } = data;
|
||
successCount = sc;
|
||
failedCount = fc;
|
||
details.push(current);
|
||
|
||
// 更新进度条
|
||
const percentage = Math.round((index / total) * 100);
|
||
progressBar.style.width = `${percentage}%`;
|
||
|
||
// 更新进度文本
|
||
progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total });
|
||
|
||
// 添加结果项
|
||
const resultItem = document.createElement('div');
|
||
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
|
||
|
||
if (current.success) {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||
} else if (current.error === 'duplicate') {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
|
||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||
} else {
|
||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||
}
|
||
|
||
resultsList.appendChild(resultItem);
|
||
// 自动滚动到底部
|
||
resultsList.scrollTop = resultsList.scrollHeight;
|
||
|
||
} else if (eventType === 'complete') {
|
||
// 完成事件
|
||
progressBar.style.width = '100%';
|
||
progressDiv.style.display = 'none';
|
||
|
||
const isAllSuccess = data.failedCount === 0;
|
||
const isAllFailed = data.successCount === 0;
|
||
|
||
let resultClass, resultIcon, resultMessage;
|
||
if (isAllSuccess) {
|
||
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
resultIcon = 'fa-check-circle';
|
||
resultMessage = t('oauth.kiro.importSuccess', { count: data.successCount });
|
||
} else if (isAllFailed) {
|
||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultIcon = 'fa-times-circle';
|
||
resultMessage = t('oauth.kiro.importAllFailed', { count: data.failedCount });
|
||
} else {
|
||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||
resultIcon = 'fa-exclamation-triangle';
|
||
resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount });
|
||
}
|
||
|
||
// 更新结果区域样式
|
||
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||
|
||
// 更新标题
|
||
const headerDiv = resultDiv.querySelector('div:first-child');
|
||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||
|
||
// 如果有成功的,刷新提供商列表
|
||
if (data.successCount > 0) {
|
||
importSuccess = true;
|
||
loadProviders();
|
||
loadConfigList();
|
||
}
|
||
|
||
} else if (eventType === 'error') {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('Failed to parse SSE data:', parseError);
|
||
}
|
||
|
||
eventType = '';
|
||
eventData = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('[Kiro Batch Import] Failed:', error);
|
||
progressDiv.style.display = 'none';
|
||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-times-circle"></i>
|
||
<strong>${t('oauth.kiro.importError')}: ${error.message}</strong>
|
||
</div>
|
||
`;
|
||
} finally {
|
||
// 重新启用按钮
|
||
cancelBtn.disabled = false;
|
||
if (!importSuccess) {
|
||
textarea.disabled = false;
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.kiro.startImport">${t('oauth.kiro.startImport')}</span>`;
|
||
} else {
|
||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示 Kiro AWS 账号导入模态框
|
||
* 支持从 AWS SSO cache 目录导入凭据文件,或直接粘贴 JSON
|
||
*/
|
||
function showKiroAwsImportModal() {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 700px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-cloud-upload-alt" style="color: #ff9900;"></i> <span data-i18n="oauth.kiro.awsImport">${t('oauth.kiro.awsImport')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="aws-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px;">
|
||
<p style="margin: 0; font-size: 14px; color: #9a3412;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<span data-i18n="oauth.kiro.awsImportInstructions">${t('oauth.kiro.awsImportInstructions')}</span>
|
||
</p>
|
||
<p style="margin: 8px 0 0 0; font-size: 12px; color: #c2410c;">
|
||
<i class="fas fa-folder-open"></i>
|
||
<code style="background: #fed7aa; padding: 2px 6px; border-radius: 4px;">C:\\Users\\{username}\\.aws\\sso\\cache</code>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 输入模式切换 -->
|
||
<div class="input-mode-toggle" style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||
<button class="mode-btn active" data-mode="file" style="flex: 1; padding: 10px 16px; border: 2px solid #ff9900; border-radius: 8px; background: #fff7ed; color: #9a3412; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-file-upload"></i>
|
||
<span data-i18n="oauth.kiro.awsModeFile">${t('oauth.kiro.awsModeFile')}</span>
|
||
</button>
|
||
<button class="mode-btn" data-mode="json" style="flex: 1; padding: 10px 16px; border: 2px solid #d1d5db; border-radius: 8px; background: white; color: #6b7280; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||
<i class="fas fa-code"></i>
|
||
<span data-i18n="oauth.kiro.awsModeJson">${t('oauth.kiro.awsModeJson')}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 文件上传模式 -->
|
||
<div class="file-mode-section" id="fileModeSection">
|
||
<div class="form-group" style="margin-bottom: 16px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<span data-i18n="oauth.kiro.awsUploadFiles">${t('oauth.kiro.awsUploadFiles')}</span>
|
||
</label>
|
||
<div class="aws-file-upload-area" style="border: 2px dashed #d1d5db; border-radius: 8px; padding: 24px; text-align: center; cursor: pointer; transition: all 0.2s;">
|
||
<input type="file" id="awsFilesInput" multiple accept=".json" style="display: none;">
|
||
<i class="fas fa-cloud-upload-alt" style="font-size: 36px; color: #9ca3af; margin-bottom: 8px;"></i>
|
||
<p style="margin: 0; color: #6b7280;" data-i18n="oauth.kiro.awsDragDrop">${t('oauth.kiro.awsDragDrop')}</p>
|
||
<p style="margin: 4px 0 0 0; font-size: 12px; color: #9ca3af;" data-i18n="oauth.kiro.awsClickUpload">${t('oauth.kiro.awsClickUpload')}</p>
|
||
</div>
|
||
<p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
|
||
<i class="fas fa-lightbulb" style="color: #f59e0b;"></i>
|
||
<span data-i18n="oauth.kiro.awsFileHint">${t('oauth.kiro.awsFileHint')}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="aws-files-list" id="awsFilesList" style="display: none; margin-bottom: 16px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<label style="font-weight: 600; color: #374151;" data-i18n="oauth.kiro.awsSelectedFiles">${t('oauth.kiro.awsSelectedFiles')}</label>
|
||
<button id="clearFilesBtn" style="background: none; border: none; color: #ef4444; cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all 0.2s;">
|
||
<i class="fas fa-trash-alt"></i>
|
||
<span data-i18n="oauth.kiro.awsClearFiles">${t('oauth.kiro.awsClearFiles')}</span>
|
||
</button>
|
||
</div>
|
||
<div id="awsFilesContainer" style="background: #f9fafb; border-radius: 8px; padding: 12px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON 输入模式 -->
|
||
<div class="json-mode-section" id="jsonModeSection" style="display: none;">
|
||
<div class="form-group" style="margin-bottom: 16px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<span data-i18n="oauth.kiro.awsJsonInput">${t('oauth.kiro.awsJsonInput')}</span>
|
||
</label>
|
||
<textarea
|
||
id="awsJsonInput"
|
||
rows="12"
|
||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||
placeholder="${t('oauth.kiro.awsJsonPlaceholderSimple')}"
|
||
data-i18n-placeholder="oauth.kiro.awsJsonPlaceholderSimple"
|
||
></textarea>
|
||
<p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
|
||
<i class="fas fa-lightbulb" style="color: #f59e0b;"></i>
|
||
<span data-i18n="oauth.kiro.awsJsonHint">${t('oauth.kiro.awsJsonHint')}</span>
|
||
</p>
|
||
</div>
|
||
<details style="margin-bottom: 16px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
|
||
<i class="fas fa-code" style="color: #ff9900; margin-right: 8px;"></i>
|
||
<span data-i18n="oauth.kiro.awsJsonExample">${t('oauth.kiro.awsJsonExample')}</span>
|
||
</summary>
|
||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-bottom: 12px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||
"clientId": "VYZBSTx3Q7QEq1W3Wn8c5nVzLWVhc3QtMQ",
|
||
"clientSecret": "eyJraWQi...OAMc",
|
||
"expiresAt": "2026-01-09T04:43:18.079944400+00:00",
|
||
"accessToken": "aoaAAAAAGlgghoSqRgQK...2tfhmdNZDA",
|
||
"authMethod": "IdC",
|
||
"provider": "BuilderId",
|
||
"refreshToken": "aorAAAAAGn...uKw+E3",
|
||
"region": "us-east-1"
|
||
}</pre>
|
||
</div>
|
||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||
{
|
||
"clientId": "VYZBSTx3Q7QEq1W3Wn8c5nVzLWVhc3QtMQ",
|
||
"clientSecret": "eyJraWQi...OAMc",
|
||
"accessToken": "aoaAAAAAGlgghoSqRgQK...2tfhmdNZDA",
|
||
"refreshToken": "aorAAAAAGn...uKw+E3",
|
||
"region": "us-east-1"
|
||
},
|
||
{
|
||
"clientId": "AnotherClientId123",
|
||
"clientSecret": "eyJraWQi...xyz",
|
||
"accessToken": "aoaAAAAAGlgghoSqRgQK...abc",
|
||
"refreshToken": "aorAAAAAGn...def",
|
||
"region": "us-west-2",
|
||
"idcRegion": "us-west-2"
|
||
}
|
||
]</pre>
|
||
</div>
|
||
<div style="color: #fbbf24; font-size: 11px; margin-top: 12px; padding: 8px; background: rgba(251, 191, 36, 0.1); border-radius: 4px;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<strong>注意:</strong>AWS企业用户需要额外添加 <code style="background: rgba(0,0,0,0.3); padding: 2px 4px; border-radius: 2px;">idcRegion</code> 字段
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="aws-validation-result" id="awsValidationResult" style="display: none; margin-bottom: 16px; padding: 12px; border-radius: 8px;"></div>
|
||
|
||
<div class="aws-json-preview" id="awsJsonPreview" style="display: none; margin-bottom: 16px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||
<i class="fas fa-eye"></i>
|
||
<span data-i18n="oauth.kiro.awsPreviewJson">${t('oauth.kiro.awsPreviewJson')}</span>
|
||
</label>
|
||
<pre id="awsJsonContent" style="background: #1f2937; color: #10b981; padding: 16px; border-radius: 8px; font-family: monospace; font-size: 12px; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-all;"></pre>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="btn btn-primary aws-import-submit" id="awsImportSubmit" disabled>
|
||
<i class="fas fa-check"></i>
|
||
<span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const fileInput = modal.querySelector('#awsFilesInput');
|
||
const uploadArea = modal.querySelector('.aws-file-upload-area');
|
||
const filesListDiv = modal.querySelector('#awsFilesList');
|
||
const filesContainer = modal.querySelector('#awsFilesContainer');
|
||
const clearFilesBtn = modal.querySelector('#clearFilesBtn');
|
||
const validationResult = modal.querySelector('#awsValidationResult');
|
||
const jsonPreview = modal.querySelector('#awsJsonPreview');
|
||
const jsonContent = modal.querySelector('#awsJsonContent');
|
||
const submitBtn = modal.querySelector('#awsImportSubmit');
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
const modeBtns = modal.querySelectorAll('.mode-btn');
|
||
const fileModeSection = modal.querySelector('#fileModeSection');
|
||
const jsonModeSection = modal.querySelector('#jsonModeSection');
|
||
const jsonInputTextarea = modal.querySelector('#awsJsonInput');
|
||
|
||
let uploadedFiles = [];
|
||
let mergedCredentials = null;
|
||
let currentMode = 'file';
|
||
|
||
// 清空文件按钮事件
|
||
clearFilesBtn.addEventListener('click', () => {
|
||
uploadedFiles = [];
|
||
filesContainer.innerHTML = '';
|
||
filesListDiv.style.display = 'none';
|
||
validationResult.style.display = 'none';
|
||
jsonPreview.style.display = 'none';
|
||
submitBtn.disabled = true;
|
||
mergedCredentials = null;
|
||
// 清空 file input
|
||
fileInput.value = '';
|
||
});
|
||
|
||
// 清空按钮 hover 效果
|
||
clearFilesBtn.addEventListener('mouseenter', () => {
|
||
clearFilesBtn.style.background = '#fef2f2';
|
||
});
|
||
clearFilesBtn.addEventListener('mouseleave', () => {
|
||
clearFilesBtn.style.background = 'none';
|
||
});
|
||
|
||
// 模式切换
|
||
modeBtns.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const mode = btn.dataset.mode;
|
||
if (mode === currentMode) return;
|
||
|
||
currentMode = mode;
|
||
|
||
// 更新按钮样式
|
||
modeBtns.forEach(b => {
|
||
if (b.dataset.mode === mode) {
|
||
b.style.borderColor = '#ff9900';
|
||
b.style.background = '#fff7ed';
|
||
b.style.color = '#9a3412';
|
||
b.classList.add('active');
|
||
} else {
|
||
b.style.borderColor = '#d1d5db';
|
||
b.style.background = 'white';
|
||
b.style.color = '#6b7280';
|
||
b.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// 切换显示区域
|
||
if (mode === 'file') {
|
||
fileModeSection.style.display = 'block';
|
||
jsonModeSection.style.display = 'none';
|
||
// 重新验证文件模式的内容
|
||
validateAndPreview();
|
||
} else {
|
||
fileModeSection.style.display = 'none';
|
||
jsonModeSection.style.display = 'block';
|
||
// 验证 JSON 输入
|
||
validateJsonInput();
|
||
}
|
||
});
|
||
});
|
||
|
||
// JSON 输入实时验证
|
||
jsonInputTextarea.addEventListener('input', () => {
|
||
validateJsonInput();
|
||
});
|
||
|
||
// 验证 JSON 输入
|
||
function validateJsonInput() {
|
||
const inputValue = jsonInputTextarea.value.trim();
|
||
|
||
if (!inputValue) {
|
||
validationResult.style.display = 'none';
|
||
jsonPreview.style.display = 'none';
|
||
submitBtn.disabled = true;
|
||
mergedCredentials = null;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
mergedCredentials = JSON.parse(inputValue);
|
||
validateAndShowResult();
|
||
} catch (error) {
|
||
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<strong data-i18n="oauth.kiro.awsJsonParseError">${t('oauth.kiro.awsJsonParseError')}</strong>
|
||
</div>
|
||
<p style="margin: 8px 0 0 0; font-size: 12px;">${error.message}</p>
|
||
`;
|
||
jsonPreview.style.display = 'none';
|
||
submitBtn.disabled = true;
|
||
mergedCredentials = null;
|
||
}
|
||
}
|
||
|
||
// 文件上传区域交互
|
||
uploadArea.addEventListener('click', () => fileInput.click());
|
||
|
||
uploadArea.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.style.borderColor = '#ff9900';
|
||
uploadArea.style.background = '#fffbeb';
|
||
});
|
||
|
||
uploadArea.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.style.borderColor = '#d1d5db';
|
||
uploadArea.style.background = 'transparent';
|
||
});
|
||
|
||
uploadArea.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.style.borderColor = '#d1d5db';
|
||
uploadArea.style.background = 'transparent';
|
||
|
||
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.json'));
|
||
if (files.length > 0) {
|
||
processFiles(files);
|
||
}
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
const files = Array.from(fileInput.files);
|
||
if (files.length > 0) {
|
||
processFiles(files);
|
||
}
|
||
});
|
||
|
||
// 处理上传的文件(支持追加)
|
||
async function processFiles(files) {
|
||
for (const file of files) {
|
||
// 检查是否已存在同名文件
|
||
const existingIndex = uploadedFiles.findIndex(f => f.name === file.name);
|
||
|
||
try {
|
||
const content = await readFileAsText(file);
|
||
const json = JSON.parse(content);
|
||
|
||
if (existingIndex >= 0) {
|
||
// 替换已存在的同名文件
|
||
uploadedFiles[existingIndex] = {
|
||
name: file.name,
|
||
content: json
|
||
};
|
||
showToast(t('common.info'), t('oauth.kiro.awsFileReplaced', { filename: file.name }), 'info');
|
||
} else {
|
||
// 追加新文件
|
||
uploadedFiles.push({
|
||
name: file.name,
|
||
content: json
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to parse ${file.name}:`, error);
|
||
showToast(t('common.error'), t('oauth.kiro.awsParseError', { filename: file.name }), 'error');
|
||
}
|
||
}
|
||
|
||
// 重新渲染文件列表
|
||
renderFilesList();
|
||
|
||
filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none';
|
||
|
||
// 清空 file input 以便可以再次选择相同文件
|
||
fileInput.value = '';
|
||
|
||
validateAndPreview();
|
||
}
|
||
|
||
// 渲染文件列表
|
||
function renderFilesList() {
|
||
filesContainer.innerHTML = '';
|
||
|
||
for (const file of uploadedFiles) {
|
||
const fileDiv = document.createElement('div');
|
||
fileDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 8px; background: white; border-radius: 4px; margin-bottom: 4px;';
|
||
fileDiv.dataset.filename = file.name;
|
||
|
||
const fields = Object.keys(file.content).slice(0, 5).join(', ');
|
||
const moreFields = Object.keys(file.content).length > 5 ? '...' : '';
|
||
|
||
fileDiv.innerHTML = `
|
||
<div style="flex: 1; min-width: 0;">
|
||
<i class="fas fa-file-code" style="color: #ff9900; margin-right: 8px;"></i>
|
||
<span style="font-weight: 500;">${file.name}</span>
|
||
<div style="font-size: 11px; color: #6b7280; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${fields}${moreFields}</div>
|
||
</div>
|
||
<button class="remove-file-btn" data-filename="${file.name}" style="background: none; border: none; color: #ef4444; cursor: pointer; padding: 4px 8px; margin-left: 8px; flex-shrink: 0;">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
`;
|
||
filesContainer.appendChild(fileDiv);
|
||
}
|
||
|
||
// 添加删除文件按钮事件
|
||
filesContainer.querySelectorAll('.remove-file-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const filename = e.currentTarget.dataset.filename;
|
||
uploadedFiles = uploadedFiles.filter(f => f.name !== filename);
|
||
renderFilesList();
|
||
filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none';
|
||
validateAndPreview();
|
||
});
|
||
});
|
||
}
|
||
|
||
// 验证并预览(文件模式)
|
||
function validateAndPreview() {
|
||
if (currentMode !== 'file') return;
|
||
|
||
if (uploadedFiles.length === 0) {
|
||
validationResult.style.display = 'none';
|
||
jsonPreview.style.display = 'none';
|
||
submitBtn.disabled = true;
|
||
mergedCredentials = null;
|
||
return;
|
||
}
|
||
|
||
// 智能合并所有文件的内容
|
||
// 如果多个文件都有 expiresAt,使用包含 refreshToken 的文件中的 expiresAt
|
||
mergedCredentials = {};
|
||
let expiresAtFromRefreshTokenFile = null;
|
||
|
||
for (const file of uploadedFiles) {
|
||
// 如果这个文件包含 refreshToken,记录它的 expiresAt
|
||
if (file.content.refreshToken && file.content.expiresAt) {
|
||
expiresAtFromRefreshTokenFile = file.content.expiresAt;
|
||
}
|
||
Object.assign(mergedCredentials, file.content);
|
||
}
|
||
|
||
// 如果找到了包含 refreshToken 的文件的 expiresAt,使用它
|
||
if (expiresAtFromRefreshTokenFile) {
|
||
mergedCredentials.expiresAt = expiresAtFromRefreshTokenFile;
|
||
}
|
||
|
||
validateAndShowResult();
|
||
}
|
||
|
||
// 验证并显示结果(通用)
|
||
function validateAndShowResult() {
|
||
if (!mergedCredentials) {
|
||
validationResult.style.display = 'none';
|
||
jsonPreview.style.display = 'none';
|
||
submitBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
// 检查是否为批量导入(数组)
|
||
const isBatchImport = Array.isArray(mergedCredentials);
|
||
|
||
if (isBatchImport) {
|
||
// 批量导入模式:验证数组中的每个对象
|
||
let allValid = true;
|
||
const credentialsValidation = mergedCredentials.map((cred, index) => {
|
||
const hasClientId = !!cred.clientId;
|
||
const hasClientSecret = !!cred.clientSecret;
|
||
const hasAccessToken = !!cred.accessToken;
|
||
const hasRefreshToken = !!cred.refreshToken;
|
||
const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken;
|
||
|
||
if (!isValid) allValid = false;
|
||
|
||
return {
|
||
index: index + 1,
|
||
isValid,
|
||
fields: [
|
||
{ key: 'clientId', has: hasClientId },
|
||
{ key: 'clientSecret', has: hasClientSecret },
|
||
{ key: 'accessToken', has: hasAccessToken },
|
||
{ key: 'refreshToken', has: hasRefreshToken }
|
||
]
|
||
};
|
||
});
|
||
|
||
// 构建批量验证结果HTML
|
||
const credentialsHtml = credentialsValidation.map(cv => {
|
||
const statusIcon = cv.isValid ? '✓' : '✗';
|
||
const statusColor = cv.isValid ? '#166534' : '#991b1b';
|
||
const fieldsHtml = cv.fields.map(f => `
|
||
<span style="margin-right: 8px;">${f.key}: ${f.has
|
||
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓</code>`
|
||
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗</code>`
|
||
}</span>
|
||
`).join('');
|
||
|
||
return `
|
||
<div style="padding: 8px; margin-bottom: 4px; background: ${cv.isValid ? '#f0fdf4' : '#fef2f2'}; border: 1px solid ${cv.isValid ? '#bbf7d0' : '#fecaca'}; border-radius: 4px;">
|
||
<div style="font-weight: 600; color: ${statusColor}; margin-bottom: 4px;">
|
||
${statusIcon} 凭据 ${cv.index}
|
||
</div>
|
||
<div style="font-size: 12px; color: #6b7280;">
|
||
${fieldsHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
if (allValid) {
|
||
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
|
||
<i class="fas fa-check-circle"></i>
|
||
<strong>批量验证通过 (${mergedCredentials.length} 个凭据)</strong>
|
||
</div>
|
||
<div style="max-height: 200px; overflow-y: auto;">
|
||
${credentialsHtml}
|
||
</div>
|
||
`;
|
||
submitBtn.disabled = false;
|
||
} else {
|
||
const validCount = credentialsValidation.filter(cv => cv.isValid).length;
|
||
const invalidCount = credentialsValidation.length - validCount;
|
||
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<strong>批量验证失败</strong>
|
||
<span style="font-weight: normal; font-size: 12px;">(${invalidCount} 个凭据缺少必需字段)</span>
|
||
</div>
|
||
<div style="max-height: 200px; overflow-y: auto;">
|
||
${credentialsHtml}
|
||
</div>
|
||
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
|
||
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
|
||
请确保每个凭据都包含所有必需字段:clientId, clientSecret, accessToken, refreshToken
|
||
</p>
|
||
`;
|
||
submitBtn.disabled = true;
|
||
}
|
||
|
||
// 显示 JSON 预览(批量模式)
|
||
jsonPreview.style.display = 'block';
|
||
const previewData = mergedCredentials.map(cred => {
|
||
const preview = { ...cred };
|
||
if (preview.clientSecret) {
|
||
preview.clientSecret = preview.clientSecret.substring(0, 8) + '...' + preview.clientSecret.slice(-4);
|
||
}
|
||
if (preview.accessToken) {
|
||
preview.accessToken = preview.accessToken.substring(0, 20) + '...' + preview.accessToken.slice(-10);
|
||
}
|
||
if (preview.refreshToken) {
|
||
preview.refreshToken = preview.refreshToken.substring(0, 10) + '...' + preview.refreshToken.slice(-6);
|
||
}
|
||
return preview;
|
||
});
|
||
jsonContent.textContent = JSON.stringify(previewData, null, 2);
|
||
|
||
} else {
|
||
// 单个导入模式:原有逻辑
|
||
const hasClientId = !!mergedCredentials.clientId;
|
||
const hasClientSecret = !!mergedCredentials.clientSecret;
|
||
const hasAccessToken = !!mergedCredentials.accessToken;
|
||
const hasRefreshToken = !!mergedCredentials.refreshToken;
|
||
|
||
// 所有四个字段都必须存在
|
||
const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken;
|
||
|
||
// 构建字段状态列表
|
||
const fieldsList = [
|
||
{ key: 'clientId', has: hasClientId },
|
||
{ key: 'clientSecret', has: hasClientSecret },
|
||
{ key: 'accessToken', has: hasAccessToken },
|
||
{ key: 'refreshToken', has: hasRefreshToken }
|
||
];
|
||
|
||
const fieldsHtml = fieldsList.map(f => `
|
||
<li>${f.key}: ${f.has
|
||
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓ ${t('common.found')}</code>`
|
||
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗ ${t('common.missing')}</code>`
|
||
}</li>
|
||
`).join('');
|
||
|
||
if (isValid) {
|
||
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-check-circle"></i>
|
||
<strong data-i18n="oauth.kiro.awsValidationSuccess">${t('oauth.kiro.awsValidationSuccess')}</strong>
|
||
</div>
|
||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||
${fieldsHtml}
|
||
</ul>
|
||
`;
|
||
submitBtn.disabled = false;
|
||
} else {
|
||
const missingCount = fieldsList.filter(f => !f.has).length;
|
||
validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<strong>${t('oauth.kiro.awsValidationFailed')}</strong>
|
||
<span style="font-weight: normal; font-size: 12px;">(${t('oauth.kiro.awsMissingFields', { count: missingCount })})</span>
|
||
</div>
|
||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||
${fieldsHtml}
|
||
</ul>
|
||
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
|
||
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
|
||
<span data-i18n="oauth.kiro.awsUploadMore">${t('oauth.kiro.awsUploadMore')}</span>
|
||
</p>
|
||
`;
|
||
submitBtn.disabled = true;
|
||
}
|
||
|
||
// 显示 JSON 预览(单个模式)
|
||
jsonPreview.style.display = 'block';
|
||
|
||
// 隐藏敏感信息的部分内容
|
||
const previewData = { ...mergedCredentials };
|
||
if (previewData.clientSecret) {
|
||
previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4);
|
||
}
|
||
if (previewData.accessToken) {
|
||
previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10);
|
||
}
|
||
if (previewData.refreshToken) {
|
||
previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6);
|
||
}
|
||
|
||
jsonContent.textContent = JSON.stringify(previewData, null, 2);
|
||
}
|
||
}
|
||
|
||
// 读取文件内容
|
||
function readFileAsText(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(e.target.result);
|
||
reader.onerror = (e) => reject(e);
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
// 关闭按钮事件
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 提交按钮事件
|
||
submitBtn.addEventListener('click', async () => {
|
||
if (!mergedCredentials) {
|
||
showToast(t('common.warning'), t('oauth.kiro.awsNoCredentials'), 'warning');
|
||
return;
|
||
}
|
||
|
||
// 检查是否为批量导入(数组)
|
||
const isBatchImport = Array.isArray(mergedCredentials);
|
||
|
||
// 禁用按钮和输入
|
||
submitBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
submitBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> <span>${t('oauth.kiro.awsImporting')}</span>`;
|
||
|
||
if (currentMode === 'json') {
|
||
jsonInputTextarea.disabled = true;
|
||
}
|
||
|
||
let importSuccess = false; // 标记是否导入成功
|
||
|
||
try {
|
||
if (isBatchImport) {
|
||
// 批量导入模式 - 使用 SSE 流式响应
|
||
// 确保每个凭据都有 authMethod
|
||
const credentialsToImport = mergedCredentials.map(cred => ({
|
||
...cred,
|
||
authMethod: cred.authMethod || 'builder-id'
|
||
}));
|
||
|
||
// 创建进度显示区域
|
||
validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<i class="fas fa-spinner fa-spin" style="color: #ff9900;"></i>
|
||
<strong id="awsBatchProgressText">${t('oauth.kiro.importingProgress', { current: 0, total: credentialsToImport.length })}</strong>
|
||
</div>
|
||
<div class="progress-bar" style="margin: 8px 0; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||
<div id="awsImportProgressBar" style="height: 100%; width: 0%; background: #ff9900; transition: width 0.3s;"></div>
|
||
</div>
|
||
<div id="awsBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||
`;
|
||
|
||
const progressText = validationResult.querySelector('#awsBatchProgressText');
|
||
const progressBar = validationResult.querySelector('#awsImportProgressBar');
|
||
const resultsList = validationResult.querySelector('#awsBatchResultsList');
|
||
|
||
// 使用 fetch + SSE 获取流式响应
|
||
const response = await fetch('/api/kiro/import-aws-credentials', {
|
||
method: 'POST',
|
||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ credentials: credentialsToImport })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
// 解析 SSE 事件
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
let eventType = '';
|
||
let eventData = '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
eventType = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ')) {
|
||
eventData = line.substring(6).trim();
|
||
|
||
if (eventType && eventData) {
|
||
try {
|
||
const data = JSON.parse(eventData);
|
||
|
||
if (eventType === 'start') {
|
||
console.log(`[AWS Batch Import] Starting import of ${data.total} credentials`);
|
||
} else if (eventType === 'progress') {
|
||
const { index, total, current, successCount: sc, failedCount: fc } = data;
|
||
successCount = sc;
|
||
failedCount = fc;
|
||
|
||
// 更新进度条
|
||
const percentage = Math.round((index / total) * 100);
|
||
progressBar.style.width = `${percentage}%`;
|
||
|
||
// 更新进度文本
|
||
progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total });
|
||
|
||
// 添加结果项
|
||
const resultItem = document.createElement('div');
|
||
resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);';
|
||
|
||
if (current.success) {
|
||
resultItem.innerHTML = `凭据 ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||
} else if (current.error === 'duplicate') {
|
||
resultItem.innerHTML = `凭据 ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateCredentials')}</span>
|
||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||
} else {
|
||
resultItem.innerHTML = `凭据 ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||
}
|
||
|
||
resultsList.appendChild(resultItem);
|
||
resultsList.scrollTop = resultsList.scrollHeight;
|
||
|
||
} else if (eventType === 'complete') {
|
||
progressBar.style.width = '100%';
|
||
|
||
const isAllSuccess = data.failedCount === 0;
|
||
const isAllFailed = data.successCount === 0;
|
||
|
||
let resultClass, resultIcon, resultMessage;
|
||
if (isAllSuccess) {
|
||
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
|
||
resultIcon = 'fa-check-circle';
|
||
resultMessage = t('oauth.kiro.awsImportSuccess') + ` (${data.successCount})`;
|
||
} else if (isAllFailed) {
|
||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
resultIcon = 'fa-times-circle';
|
||
resultMessage = t('oauth.kiro.awsImportAllFailed', { count: data.failedCount });
|
||
} else {
|
||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||
resultIcon = 'fa-exclamation-triangle';
|
||
resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount });
|
||
}
|
||
|
||
validationResult.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||
|
||
const headerDiv = validationResult.querySelector('div:first-child');
|
||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||
|
||
// 如果有成功的,标记为成功并刷新提供商列表
|
||
if (data.successCount > 0) {
|
||
importSuccess = true;
|
||
loadProviders();
|
||
loadConfigList();
|
||
}
|
||
|
||
} else if (eventType === 'error') {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('Failed to parse SSE data:', parseError);
|
||
}
|
||
|
||
eventType = '';
|
||
eventData = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
} else {
|
||
// 单个导入模式
|
||
// 确保 authMethod 为 builder-id(AWS 账号模式)
|
||
if (!mergedCredentials.authMethod) {
|
||
mergedCredentials.authMethod = 'builder-id';
|
||
}
|
||
|
||
const response = await window.apiClient.post('/kiro/import-aws-credentials', {
|
||
credentials: mergedCredentials
|
||
});
|
||
|
||
if (response.success) {
|
||
importSuccess = true;
|
||
showToast(t('common.success'), t('oauth.kiro.awsImportSuccess'), 'success');
|
||
modal.remove();
|
||
|
||
// 刷新提供商列表和配置列表
|
||
loadProviders();
|
||
loadConfigList();
|
||
} else if (response.error === 'duplicate') {
|
||
// 显示重复凭据警告
|
||
const existingPath = response.existingPath || '';
|
||
showToast(t('common.warning'), t('oauth.kiro.duplicateCredentials') + (existingPath ? ` (${existingPath})` : ''), 'warning');
|
||
} else {
|
||
showToast(t('common.error'), response.error || t('oauth.kiro.awsImportFailed'), 'error');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('AWS import failed:', error);
|
||
|
||
// 更新错误显示
|
||
validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||
validationResult.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-times-circle"></i>
|
||
<strong>${t('oauth.kiro.awsImportFailed')}: ${error.message}</strong>
|
||
</div>
|
||
`;
|
||
|
||
showToast(t('common.error'), t('oauth.kiro.awsImportFailed') + ': ' + error.message, 'error');
|
||
} finally {
|
||
// 取消按钮始终可用
|
||
cancelBtn.disabled = false;
|
||
|
||
// 只有在导入失败时才重新启用提交按钮
|
||
if (!importSuccess) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>`;
|
||
|
||
if (currentMode === 'json') {
|
||
jsonInputTextarea.disabled = false;
|
||
}
|
||
} else {
|
||
// 导入成功后,保持提交按钮禁用状态,并显示成功图标
|
||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 执行生成授权链接
|
||
* @param {string} providerType - 提供商类型
|
||
* @param {Object} extraOptions - 额外选项
|
||
*/
|
||
async function executeGenerateAuthUrl(providerType, extraOptions = {}) {
|
||
try {
|
||
showToast(t('common.info'), t('modal.provider.auth.initializing'), 'info');
|
||
|
||
// 使用 fileUploadHandler 中的 getProviderKey 获取目录名称
|
||
const providerDir = fileUploadHandler.getProviderKey(providerType);
|
||
|
||
const response = await window.apiClient.post(
|
||
`/providers/${encodeURIComponent(providerType)}/generate-auth-url`,
|
||
{
|
||
saveToConfigs: true,
|
||
providerDir: providerDir,
|
||
...extraOptions
|
||
}
|
||
);
|
||
|
||
if (response.success && response.authUrl) {
|
||
// 如果提供了 targetInputId,设置成功监听器
|
||
if (extraOptions.targetInputId) {
|
||
const targetInputId = extraOptions.targetInputId;
|
||
const handleSuccess = (e) => {
|
||
const data = e.detail;
|
||
if (data.provider === providerType && data.relativePath) {
|
||
const input = document.getElementById(targetInputId);
|
||
if (input) {
|
||
input.value = data.relativePath;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
showToast(t('common.success'), t('modal.provider.auth.success'), 'success');
|
||
}
|
||
window.removeEventListener('oauth_success_event', handleSuccess);
|
||
}
|
||
};
|
||
window.addEventListener('oauth_success_event', handleSuccess);
|
||
}
|
||
|
||
// 显示授权信息模态框
|
||
showAuthModal(response.authUrl, response.authInfo);
|
||
} else {
|
||
showToast(t('common.error'), t('modal.provider.auth.failed'), 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('生成授权链接失败:', error);
|
||
showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取提供商的授权文件路径
|
||
* @param {string} provider - 提供商类型
|
||
* @returns {string} 授权文件路径
|
||
*/
|
||
function getAuthFilePath(provider) {
|
||
const authFilePaths = {
|
||
'gemini-cli-oauth': '~/.gemini/oauth_creds.json',
|
||
'gemini-antigravity': '~/.antigravity/oauth_creds.json',
|
||
'openai-qwen-oauth': '~/.qwen/oauth_creds.json',
|
||
'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.json',
|
||
'openai-iflow': '~/.iflow/oauth_creds.json'
|
||
};
|
||
return authFilePaths[provider] || (getCurrentLanguage() === 'en-US' ? 'Unknown Path' : '未知路径');
|
||
}
|
||
|
||
/**
|
||
* 显示授权信息模态框
|
||
* @param {string} authUrl - 授权URL
|
||
* @param {Object} authInfo - 授权信息
|
||
*/
|
||
function showAuthModal(authUrl, authInfo) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
|
||
// 获取授权文件路径
|
||
const authFilePath = getAuthFilePath(authInfo.provider);
|
||
|
||
// 获取需要开放的端口号(从 authInfo 或当前页面 URL)
|
||
const requiredPort = authInfo.callbackPort || authInfo.port || window.location.port || '3000';
|
||
const isDeviceFlow = authInfo.provider === 'openai-qwen-oauth' || (authInfo.provider === 'claude-kiro-oauth' && authInfo.authMethod === 'builder-id');
|
||
|
||
let instructionsHtml = '';
|
||
if (authInfo.provider === 'openai-qwen-oauth') {
|
||
instructionsHtml = `
|
||
<div class="auth-instructions">
|
||
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
|
||
<ol>
|
||
<li data-i18n="oauth.modal.step1">${t('oauth.modal.step1')}</li>
|
||
<li data-i18n="oauth.modal.step2.qwen">${t('oauth.modal.step2.qwen')}</li>
|
||
<li data-i18n="oauth.modal.step3">${t('oauth.modal.step3')}</li>
|
||
<li data-i18n="oauth.modal.step4.qwen" data-i18n-params='{"min":"${Math.floor(authInfo.expiresIn / 60)}"}'>${t('oauth.modal.step4.qwen', { min: Math.floor(authInfo.expiresIn / 60) })}</li>
|
||
</ol>
|
||
</div>
|
||
`;
|
||
} else if (authInfo.provider === 'claude-kiro-oauth') {
|
||
const methodDisplay = authInfo.authMethod === 'builder-id' ? 'AWS Builder ID' : `Social (${authInfo.socialProvider || 'Google'})`;
|
||
const methodAccount = authInfo.authMethod === 'builder-id' ? 'AWS Builder ID' : authInfo.socialProvider || 'Google';
|
||
instructionsHtml = `
|
||
<div class="auth-instructions">
|
||
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
|
||
<p><strong data-i18n="oauth.kiro.authMethodLabel">${t('oauth.kiro.authMethodLabel')}</strong> ${methodDisplay}</p>
|
||
<ol>
|
||
<li data-i18n="oauth.kiro.step1">${t('oauth.kiro.step1')}</li>
|
||
<li data-i18n="oauth.kiro.step2" data-i18n-params='{"method":"${methodAccount}"}'>${t('oauth.kiro.step2', { method: methodAccount })}</li>
|
||
<li data-i18n="oauth.kiro.step3">${t('oauth.kiro.step3')}</li>
|
||
<li data-i18n="oauth.kiro.step4">${t('oauth.kiro.step4')}</li>
|
||
</ol>
|
||
</div>
|
||
`;
|
||
} else if (authInfo.provider === 'openai-iflow') {
|
||
instructionsHtml = `
|
||
<div class="auth-instructions">
|
||
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
|
||
<ol>
|
||
<li data-i18n="oauth.iflow.step1">${t('oauth.iflow.step1')}</li>
|
||
<li data-i18n="oauth.iflow.step2">${t('oauth.iflow.step2')}</li>
|
||
<li data-i18n="oauth.iflow.step3">${t('oauth.iflow.step3')}</li>
|
||
<li data-i18n="oauth.iflow.step4">${t('oauth.iflow.step4')}</li>
|
||
</ol>
|
||
</div>
|
||
`;
|
||
} else {
|
||
instructionsHtml = `
|
||
<div class="auth-instructions">
|
||
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
|
||
<ol>
|
||
<li data-i18n="oauth.modal.step1">${t('oauth.modal.step1')}</li>
|
||
<li data-i18n="oauth.modal.step2.google">${t('oauth.modal.step2.google')}</li>
|
||
<li data-i18n="oauth.modal.step4.google">${t('oauth.modal.step4.google')}</li>
|
||
<li data-i18n="oauth.modal.step3">${t('oauth.modal.step3')}</li>
|
||
</ol>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.modal.title">${t('oauth.modal.title')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="auth-info">
|
||
<p><strong data-i18n="oauth.modal.provider">${t('oauth.modal.provider')}</strong> ${authInfo.provider}</p>
|
||
<div class="port-info-section" style="margin: 12px 0; padding: 12px; background: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px; position: relative;">
|
||
${(authInfo.provider === 'claude-kiro-oauth' && authInfo.authMethod === 'builder-id') ? `
|
||
<button class="regenerate-builder-id-btn" title="${t('common.generate')}" style="position: absolute; top: 12px; right: 12px; background: none; border: 1px solid #d97706; border-radius: 4px; cursor: pointer; color: #d97706; padding: 4px 8px;">
|
||
<i class="fas fa-sync-alt"></i>
|
||
</button>
|
||
` : ''}
|
||
<div style="margin: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||
<i class="fas fa-network-wired" style="color: #d97706;"></i>
|
||
<strong data-i18n="oauth.modal.requiredPort">${t('oauth.modal.requiredPort')}</strong>
|
||
${isDeviceFlow ?
|
||
`<code style="background: #fff; padding: 2px 8px; border-radius: 4px; font-weight: bold; color: #d97706;">${requiredPort}</code>` :
|
||
`<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="number" class="auth-port-input" value="${requiredPort}" style="width: 80px; padding: 2px 8px; border: 1px solid #d97706; border-radius: 4px; font-weight: bold; color: #d97706; background: white;">
|
||
<button class="regenerate-port-btn" title="${t('common.generate')}" style="background: none; border: 1px solid #d97706; border-radius: 4px; cursor: pointer; color: #d97706; padding: 2px 6px;">
|
||
<i class="fas fa-sync-alt"></i>
|
||
</button>
|
||
</div>`
|
||
}
|
||
</div>
|
||
<p style="margin: 8px 0 0 0; font-size: 0.85rem; color: #92400e;" data-i18n="oauth.modal.portNote">${t('oauth.modal.portNote')}</p>
|
||
${(authInfo.provider === 'claude-kiro-oauth' && authInfo.authMethod === 'builder-id') ? `
|
||
<div class="builder-id-url-section" style="margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fcd34d;">
|
||
<label style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px; font-size: 13px; font-weight: 600; color: #92400e;">
|
||
<i class="fas fa-link"></i>
|
||
<span data-i18n="oauth.kiro.builderIDStartURL">${t('oauth.kiro.builderIDStartURL') || 'Builder ID Start URL'}</span>
|
||
<span style="font-weight: normal; color: #b45309;">(${t('common.optional') || '可选'})</span>
|
||
</label>
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="text" class="builder-id-start-url-input"
|
||
value="${authInfo.builderIDStartURL || 'https://view.awsapps.com/start'}"
|
||
placeholder="https://view.awsapps.com/start"
|
||
style="flex: 1; padding: 6px 10px; border: 1px solid #fcd34d; border-radius: 4px; font-size: 13px; color: #92400e; background: white;"
|
||
/>
|
||
</div>
|
||
<p style="margin: 6px 0 0 0; font-size: 0.75rem; color: #b45309;">
|
||
<i class="fas fa-info-circle"></i>
|
||
<span data-i18n="oauth.kiro.builderIDStartURLHint">${t('oauth.kiro.builderIDStartURLHint') || '如果您使用 AWS IAM Identity Center,请输入您的 Start URL'}</span>
|
||
</p>
|
||
</div>
|
||
<div class="builder-id-region-section" style="margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fcd34d;">
|
||
<label style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px; font-size: 13px; font-weight: 600; color: #92400e;">
|
||
<i class="fas fa-globe"></i>
|
||
<span>AWS Region</span>
|
||
</label>
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="text" class="builder-id-region-input"
|
||
value="${authInfo.region || 'us-east-1'}"
|
||
placeholder="us-east-1"
|
||
style="flex: 1; padding: 6px 10px; border: 1px solid #fcd34d; border-radius: 4px; font-size: 13px; color: #92400e; background: white;"
|
||
/>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
${instructionsHtml}
|
||
<div class="auth-url-section">
|
||
<label data-i18n="oauth.modal.urlLabel">${t('oauth.modal.urlLabel')}</label>
|
||
<div class="auth-url-container">
|
||
<input type="text" readonly value="${authUrl}" class="auth-url-input">
|
||
<button class="copy-btn" data-i18n="oauth.modal.copyTitle" title="复制链接">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="open-auth-btn">
|
||
<i class="fas fa-external-link-alt"></i>
|
||
<span data-i18n="oauth.modal.openInBrowser">${t('oauth.modal.openInBrowser')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭按钮事件
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
[closeBtn, cancelBtn].forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
});
|
||
|
||
// 重新生成按钮事件
|
||
const regenerateBtn = modal.querySelector('.regenerate-port-btn');
|
||
if (regenerateBtn) {
|
||
regenerateBtn.onclick = async () => {
|
||
const newPort = modal.querySelector('.auth-port-input').value;
|
||
if (newPort && newPort !== requiredPort) {
|
||
modal.remove();
|
||
// 构造重新请求的参数
|
||
const options = { ...authInfo, port: newPort };
|
||
// 移除不需要传递回后端的字段
|
||
delete options.provider;
|
||
delete options.redirectUri;
|
||
delete options.callbackPort;
|
||
|
||
await executeGenerateAuthUrl(authInfo.provider, options);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Builder ID Start URL 重新生成按钮事件
|
||
const regenerateBuilderIdBtn = modal.querySelector('.regenerate-builder-id-btn');
|
||
if (regenerateBuilderIdBtn) {
|
||
regenerateBuilderIdBtn.onclick = async () => {
|
||
const builderIdStartUrl = modal.querySelector('.builder-id-start-url-input').value.trim();
|
||
const region = modal.querySelector('.builder-id-region-input').value.trim();
|
||
modal.remove();
|
||
// 构造重新请求的参数
|
||
const options = {
|
||
...authInfo,
|
||
builderIDStartURL: builderIdStartUrl || 'https://view.awsapps.com/start',
|
||
region: region || 'us-east-1'
|
||
};
|
||
// 移除不需要传递回后端的字段
|
||
delete options.provider;
|
||
delete options.redirectUri;
|
||
delete options.callbackPort;
|
||
|
||
await executeGenerateAuthUrl(authInfo.provider, options);
|
||
};
|
||
}
|
||
|
||
// 复制链接按钮
|
||
const copyBtn = modal.querySelector('.copy-btn');
|
||
copyBtn.addEventListener('click', () => {
|
||
const input = modal.querySelector('.auth-url-input');
|
||
input.select();
|
||
document.execCommand('copy');
|
||
showToast(t('common.success'), t('oauth.success.msg'), 'success');
|
||
});
|
||
|
||
// 在浏览器中打开按钮
|
||
const openBtn = modal.querySelector('.open-auth-btn');
|
||
openBtn.addEventListener('click', () => {
|
||
// 使用子窗口打开,以便监听 URL 变化
|
||
const width = 600;
|
||
const height = 700;
|
||
const left = (window.screen.width - width) / 2 + 600;
|
||
const top = (window.screen.height - height) / 2;
|
||
|
||
const authWindow = window.open(
|
||
authUrl,
|
||
'OAuthAuthWindow',
|
||
`width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes`
|
||
);
|
||
|
||
let pollTimer = null;
|
||
const cleanupAuthListeners = () => {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
window.removeEventListener('oauth_success_event', handleOAuthSuccess);
|
||
window.removeEventListener('message', handlePopupMessage);
|
||
};
|
||
|
||
// 监听 OAuth 成功事件,自动关闭窗口和模态框
|
||
const handleOAuthSuccess = () => {
|
||
if (authWindow && !authWindow.closed) {
|
||
authWindow.close();
|
||
}
|
||
modal.remove();
|
||
cleanupAuthListeners();
|
||
|
||
// 授权成功后刷新配置和提供商列表
|
||
loadProviders();
|
||
loadConfigList();
|
||
};
|
||
|
||
// 回调页主动 postMessage 时,优先使用父页面关闭子窗口
|
||
const handlePopupMessage = (event) => {
|
||
if (event.origin !== window.location.origin) {
|
||
return;
|
||
}
|
||
|
||
const data = event.data;
|
||
if (!data || data.type !== 'oauth-popup-complete') {
|
||
return;
|
||
}
|
||
|
||
if (data.provider && data.provider !== authInfo.provider) {
|
||
return;
|
||
}
|
||
|
||
handleOAuthSuccess();
|
||
};
|
||
|
||
window.addEventListener('oauth_success_event', handleOAuthSuccess);
|
||
window.addEventListener('message', handlePopupMessage);
|
||
|
||
if (authWindow) {
|
||
showToast(t('common.info'), t('oauth.window.opened'), 'info');
|
||
|
||
// 添加手动输入回调 URL 的 UI
|
||
const urlSection = modal.querySelector('.auth-url-section');
|
||
if (urlSection && !modal.querySelector('.manual-callback-section')) {
|
||
const manualInputHtml = `
|
||
<div class="manual-callback-section" style="margin-top: 20px; padding: 15px; background: #fffbeb; border: 1px solid #fef3c7; border-radius: 8px;">
|
||
<h4 style="color: #92400e; margin-bottom: 8px;"><i class="fas fa-exclamation-circle"></i> <span data-i18n="oauth.manual.title">${t('oauth.manual.title')}</span></h4>
|
||
<p style="font-size: 0.875rem; color: #b45309; margin-bottom: 10px;" data-i18n-html="oauth.manual.desc">${t('oauth.manual.desc')}</p>
|
||
<div class="auth-url-container" style="display: flex; gap: 5px;">
|
||
<input type="text" class="manual-callback-input" data-i18n="oauth.manual.placeholder" placeholder="粘贴回调 URL (包含 code=...)" style="flex: 1; padding: 8px; border: 1px solid #fcd34d; border-radius: 4px; background: white; color: black;">
|
||
<button class="btn btn-success apply-callback-btn" style="padding: 8px 15px; white-space: nowrap; background: #059669; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||
<i class="fas fa-check"></i> <span data-i18n="oauth.manual.submit">${t('oauth.manual.submit')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
urlSection.insertAdjacentHTML('afterend', manualInputHtml);
|
||
}
|
||
|
||
const manualInput = modal.querySelector('.manual-callback-input');
|
||
const applyBtn = modal.querySelector('.apply-callback-btn');
|
||
|
||
// 处理回调 URL 的核心逻辑
|
||
const processCallback = (urlStr, isManualInput = false) => {
|
||
try {
|
||
// 尝试清理 URL(有些用户可能会复制多余的文字)
|
||
const cleanUrlStr = urlStr.trim().match(/https?:\/\/[^\s]+/)?.[0] || urlStr.trim();
|
||
const url = new URL(cleanUrlStr);
|
||
|
||
if (url.searchParams.has('code') || url.searchParams.has('token')) {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
// 构造本地可处理的 URL,只修改 hostname,保持原始 URL 的端口号不变
|
||
const localUrl = new URL(url.href);
|
||
localUrl.hostname = window.location.hostname;
|
||
localUrl.protocol = window.location.protocol;
|
||
|
||
showToast(t('common.info'), t('oauth.processing'), 'info');
|
||
|
||
// 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口
|
||
if (isManualInput) {
|
||
// 通过服务端API处理手动输入的回调URL
|
||
window.apiClient.post('/oauth/manual-callback', {
|
||
provider: authInfo.provider,
|
||
callbackUrl: url.href, //使用localhost访问
|
||
authMethod: authInfo.authMethod
|
||
})
|
||
.then(response => {
|
||
if (response.success) {
|
||
console.log('OAuth 回调处理成功');
|
||
handleOAuthSuccess();
|
||
showToast(t('common.success'), t('oauth.success.msg'), 'success');
|
||
} else {
|
||
console.error('OAuth 回调处理失败:', response.error);
|
||
showToast(t('common.error'), response.error || t('oauth.error.process'), 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('OAuth 回调请求失败:', err);
|
||
showToast(t('common.error'), t('oauth.error.process'), 'error');
|
||
});
|
||
} else {
|
||
// 自动监听模式:优先在子窗口中跳转(如果没关)
|
||
if (authWindow && !authWindow.closed) {
|
||
authWindow.location.href = localUrl.href;
|
||
} else {
|
||
// 备选方案:通过 fetch 请求
|
||
// 通过 fetch 请求本地服务器处理回调
|
||
fetch(localUrl.href)
|
||
.then(response => {
|
||
if (response.ok) {
|
||
console.log('OAuth 回调处理成功');
|
||
} else {
|
||
console.error('OAuth 回调处理失败:', response.status);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('OAuth 回调请求失败:', err);
|
||
});
|
||
}
|
||
}
|
||
|
||
} else {
|
||
showToast(t('common.warning'), t('oauth.invalid.url'), 'warning');
|
||
}
|
||
} catch (err) {
|
||
console.error('处理回调失败:', err);
|
||
showToast(t('common.error'), t('oauth.error.format'), 'error');
|
||
}
|
||
};
|
||
|
||
applyBtn.addEventListener('click', () => {
|
||
processCallback(manualInput.value, true);
|
||
});
|
||
|
||
// 启动定时器轮询子窗口 URL
|
||
pollTimer = setInterval(() => {
|
||
try {
|
||
if (authWindow.closed) {
|
||
cleanupAuthListeners();
|
||
return;
|
||
}
|
||
// 如果能读到说明回到了同域
|
||
const currentUrl = authWindow.location.href;
|
||
if (currentUrl && (currentUrl.includes('code=') || currentUrl.includes('token='))) {
|
||
processCallback(currentUrl);
|
||
}
|
||
} catch (e) {
|
||
// 跨域受限是正常的
|
||
}
|
||
}, 1000);
|
||
} else {
|
||
showToast(t('common.error'), t('oauth.window.blocked'), 'error');
|
||
}
|
||
});
|
||
|
||
}
|
||
|
||
/**
|
||
* 显示需要重启的提示模态框
|
||
* @param {string} version - 更新到的版本号
|
||
*/
|
||
function showRestartRequiredModal(version) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay restart-required-modal';
|
||
modal.style.display = 'flex';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content restart-modal-content" style="max-width: 420px;">
|
||
<div class="modal-header restart-modal-header">
|
||
<h3><i class="fas fa-check-circle" style="color: #10b981;"></i> <span data-i18n="dashboard.update.restartTitle">${t('dashboard.update.restartTitle')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body" style="text-align: center; padding: 20px;">
|
||
<p style="font-size: 1rem; color: #374151; margin: 0;" data-i18n="dashboard.update.restartMsg" data-i18n-params='{"version":"${version}"}'>${t('dashboard.update.restartMsg', { version })}</p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn restart-confirm-btn">
|
||
<i class="fas fa-check"></i>
|
||
<span data-i18n="common.confirm">${t('common.confirm')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭按钮事件
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const confirmBtn = modal.querySelector('.restart-confirm-btn');
|
||
|
||
const closeModal = () => {
|
||
modal.remove();
|
||
};
|
||
|
||
closeBtn.addEventListener('click', closeModal);
|
||
confirmBtn.addEventListener('click', closeModal);
|
||
|
||
// 点击遮罩层关闭
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
closeModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 检查更新
|
||
* @param {boolean} silent - 是否静默检查(不显示 Toast)
|
||
*/
|
||
async function checkUpdate(silent = false) {
|
||
const checkBtn = document.getElementById('checkUpdateBtn');
|
||
const updateBtn = document.getElementById('performUpdateBtn');
|
||
const updateBadge = document.getElementById('updateBadge');
|
||
const latestVersionText = document.getElementById('latestVersionText');
|
||
const versionSelectWrapper = document.getElementById('versionSelectWrapper');
|
||
const versionSelect = document.getElementById('versionSelect');
|
||
const checkBtnIcon = checkBtn?.querySelector('i');
|
||
const checkBtnText = checkBtn?.querySelector('span');
|
||
|
||
try {
|
||
if (!silent && checkBtn) {
|
||
checkBtn.disabled = true;
|
||
if (checkBtnIcon) checkBtnIcon.className = 'fas fa-spinner fa-spin';
|
||
if (checkBtnText) checkBtnText.textContent = t('dashboard.update.checking');
|
||
}
|
||
|
||
const data = await window.apiClient.get('/check-update');
|
||
|
||
// 处理版本列表
|
||
if (versionSelect && data.availableVersions && data.availableVersions.length > 0) {
|
||
versionSelect.innerHTML = '';
|
||
data.availableVersions.forEach(version => {
|
||
const option = document.createElement('option');
|
||
option.value = version;
|
||
option.textContent = version;
|
||
// 如果是最新版本,增加标识
|
||
if (version === data.latestVersion) {
|
||
option.textContent += ` (${t('dashboard.update.latest') || 'Latest'})`;
|
||
}
|
||
// 如果是当前版本,增加标识
|
||
if (version === data.localVersion || version === `v${data.localVersion}`) {
|
||
option.textContent += ` (${t('dashboard.update.current') || 'Current'})`;
|
||
option.selected = true;
|
||
}
|
||
versionSelect.appendChild(option);
|
||
});
|
||
|
||
if (versionSelectWrapper) versionSelectWrapper.style.display = 'block';
|
||
if (updateBtn) {
|
||
updateBtn.style.display = 'inline-flex';
|
||
// 如果是回退,修改按钮文字
|
||
updateBtn.querySelector('span').textContent = t('dashboard.update.perform');
|
||
}
|
||
}
|
||
|
||
if (data.hasUpdate) {
|
||
if (updateBadge) updateBadge.style.display = 'inline-flex';
|
||
if (latestVersionText) latestVersionText.textContent = data.latestVersion;
|
||
|
||
// 如果有新版本且未选择特定版本,默认选中最新
|
||
if (versionSelect && data.latestVersion) {
|
||
versionSelect.value = data.latestVersion;
|
||
}
|
||
|
||
if (!silent) {
|
||
showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info');
|
||
}
|
||
} else {
|
||
if (updateBadge) updateBadge.style.display = 'none';
|
||
if (!silent) {
|
||
showToast(t('common.info'), t('dashboard.update.upToDate'), 'success');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Check update failed:', error);
|
||
if (!silent) {
|
||
showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error');
|
||
}
|
||
} finally {
|
||
if (checkBtn) {
|
||
checkBtn.disabled = false;
|
||
if (checkBtnIcon) checkBtnIcon.className = 'fas fa-sync-alt';
|
||
if (checkBtnText) checkBtnText.textContent = t('dashboard.update.check');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行更新
|
||
*/
|
||
async function performUpdate() {
|
||
const updateBtn = document.getElementById('performUpdateBtn');
|
||
const versionSelect = document.getElementById('versionSelect');
|
||
const selectedVersion = versionSelect?.value || '';
|
||
|
||
if (!confirm(t('dashboard.update.confirmMsg', { version: selectedVersion }))) {
|
||
return;
|
||
}
|
||
|
||
const updateBtnIcon = updateBtn?.querySelector('i');
|
||
const updateBtnText = updateBtn?.querySelector('span');
|
||
|
||
try {
|
||
if (updateBtn) {
|
||
updateBtn.disabled = true;
|
||
if (updateBtnIcon) updateBtnIcon.className = 'fas fa-spinner fa-spin';
|
||
if (updateBtnText) updateBtnText.textContent = t('dashboard.update.updating');
|
||
}
|
||
|
||
showToast(t('common.info'), t('dashboard.update.updating'), 'info');
|
||
|
||
const data = await window.apiClient.post('/update', { version: selectedVersion });
|
||
|
||
if (data.success) {
|
||
if (data.updated) {
|
||
// 代码已更新,直接调用重启服务
|
||
showToast(t('common.success'), t('dashboard.update.success'), 'success');
|
||
|
||
// 自动重启服务
|
||
await restartServiceAfterUpdate();
|
||
} else {
|
||
// 已是目标版本
|
||
showToast(t('common.info'), data.message || t('dashboard.update.upToDate'), 'info');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Update failed:', error);
|
||
showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error');
|
||
} finally {
|
||
if (updateBtn) {
|
||
updateBtn.disabled = false;
|
||
if (updateBtnIcon) updateBtnIcon.className = 'fas fa-download';
|
||
if (updateBtnText) updateBtnText.textContent = t('dashboard.update.perform');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新后自动重启服务
|
||
*/
|
||
async function restartServiceAfterUpdate() {
|
||
try {
|
||
showToast(t('common.info'), t('header.restart.requesting'), 'info');
|
||
|
||
const token = localStorage.getItem('authToken');
|
||
const response = await fetch('/api/restart-service', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': token ? `Bearer ${token}` : ''
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
showToast(t('common.success'), result.message || t('header.restart.success'), 'success');
|
||
|
||
// 如果是 worker 模式,服务会自动重启,等待几秒后刷新页面
|
||
if (result.mode === 'worker') {
|
||
setTimeout(() => {
|
||
showToast(t('common.info'), t('header.restart.reconnecting'), 'info');
|
||
// 等待服务重启后刷新页面
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 3000);
|
||
}, 2000);
|
||
}
|
||
} else {
|
||
// 显示错误信息
|
||
const errorMsg = result.message || result.error?.message || t('header.restart.failed');
|
||
showToast(t('common.error'), errorMsg, 'error');
|
||
|
||
// 如果是独立模式,显示提示
|
||
if (result.mode === 'standalone') {
|
||
showToast(t('common.info'), result.hint, 'warning');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Restart after update failed:', error);
|
||
showToast(t('common.error'), t('header.restart.failed') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示添加提供商组模态框
|
||
* @param {string} defaultBaseType - 默认的基础类型
|
||
*/
|
||
function showAddProviderGroupModal(defaultBaseType = null) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.display = 'flex';
|
||
modal.style.zIndex = '2000';
|
||
|
||
// 获取所有基础母版配置,并过滤掉当前已经存在的“自定义组”
|
||
// 确保下拉菜单只显示纯净的基础类型(如 openai-custom),而不显示已有的带后缀组
|
||
const allBaseConfigs = getBaseProviderConfigs();
|
||
const baseTypes = allBaseConfigs.filter(config => {
|
||
// 1. 必须在后端支持的列表中
|
||
const isSupported = cachedSupportedProviders.includes(config.id);
|
||
|
||
// 2. 限制只能添加特定类型的配置组 (Claude Custom, OpenAI Custom, OpenAI Responses)
|
||
const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom'];
|
||
const isAllowed = allowedTypes.includes(config.id);
|
||
|
||
return isSupported && isAllowed;
|
||
});
|
||
|
||
let optionsHtml = baseTypes.map(type => {
|
||
const selected = (defaultBaseType && type.id === defaultBaseType) ? 'selected' : '';
|
||
return `<option value="${type.id}" ${selected}>${type.name}</option>`;
|
||
}).join('');
|
||
|
||
const selectedConfig = allBaseConfigs.find(c => c.id === defaultBaseType);
|
||
const baseTypeSectionHtml = defaultBaseType ? `
|
||
<div class="form-group" style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.baseType">${t('providers.addGroup.baseType')}</label>
|
||
<div style="padding: 10px 12px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas ${selectedConfig?.icon || 'fa-robot'}" style="color: #6b7280;"></i>
|
||
<span style="font-weight: 500; color: #374151;">${selectedConfig?.name || defaultBaseType}</span>
|
||
</div>
|
||
<input type="hidden" id="groupBaseType" value="${defaultBaseType}">
|
||
</div>
|
||
` : `
|
||
<div class="form-group" style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.baseType">${t('providers.addGroup.baseType')}</label>
|
||
<select id="groupBaseType" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||
${optionsHtml}
|
||
</select>
|
||
</div>
|
||
`;
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 450px;">
|
||
<div class="modal-header">
|
||
<h3><i class="fas fa-folder-plus"></i> <span data-i18n="providers.addGroup.title">${t('providers.addGroup.title')}</span></h3>
|
||
<button class="modal-close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
${baseTypeSectionHtml}
|
||
<div class="form-group">
|
||
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.suffix">${t('providers.addGroup.suffix')}</label>
|
||
<input type="text" id="groupSuffix" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
|
||
placeholder="${t('providers.addGroup.suffixPlaceholder')}" data-i18n-placeholder="providers.addGroup.suffixPlaceholder">
|
||
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
|
||
示例: ${selectedConfig?.id || 'openai-custom'} + prod -> ${selectedConfig?.id || 'openai-custom'}-prod
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
|
||
<button class="btn btn-secondary modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||
<button class="btn btn-primary modal-submit">
|
||
<i class="fas fa-check"></i> <span data-i18n="common.confirm">${t('common.confirm')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const closeBtn = modal.querySelector('.modal-close');
|
||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||
const submitBtn = modal.querySelector('.modal-submit');
|
||
const suffixInput = modal.querySelector('#groupSuffix');
|
||
const baseTypeSelect = modal.querySelector('#groupBaseType');
|
||
|
||
const closeModal = () => modal.remove();
|
||
|
||
[closeBtn, cancelBtn].forEach(btn => btn.addEventListener('click', closeModal));
|
||
|
||
submitBtn.addEventListener('click', async () => {
|
||
const baseType = baseTypeSelect.value;
|
||
const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||
|
||
if (!suffix) {
|
||
showToast(t('common.warning'), t('common.invalidSuffix'), 'warning');
|
||
return;
|
||
}
|
||
|
||
const newProviderType = `${baseType}-${suffix}`;
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||
|
||
try {
|
||
// 创建一个带后缀的新提供商组,并添加一个初始的空配置(或者让用户在随后的模态框中添加)
|
||
// 这里我们先创建一个临时的空配置,这样组就会在 dashboard 中显示出来
|
||
const response = await window.apiClient.post('/providers', {
|
||
providerType: newProviderType,
|
||
providerConfig: {
|
||
customName: suffix.toUpperCase(),
|
||
isHealthy: true,
|
||
isDisabled: false,
|
||
usageCount: 0,
|
||
errorCount: 0
|
||
}
|
||
});
|
||
|
||
if (response.success) {
|
||
showToast(t('common.success'), t('providers.addGroup.success'), 'success');
|
||
closeModal();
|
||
// 重新加载提供商列表,强制刷新支持的类型
|
||
await loadProviders(true);
|
||
// 自动打开新创建的组的管理界面
|
||
setTimeout(() => openProviderManager(newProviderType), 500);
|
||
} else {
|
||
throw new Error(response.error?.message || 'Unknown error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to add provider group:', error);
|
||
showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error');
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span>${t('common.confirm')}</span>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
export {
|
||
loadSystemInfo,
|
||
updateTimeDisplay,
|
||
loadProviders,
|
||
openProviderManager,
|
||
showAuthModal,
|
||
executeGenerateAuthUrl,
|
||
handleGenerateAuthUrl,
|
||
checkUpdate,
|
||
performUpdate,
|
||
showAddProviderGroupModal
|
||
};
|