feat(upload-config): 添加配置文件下载功能并显示节点状态

- 新增配置文件下载 API 端点,支持安全下载 configs 目录下的文件
- 在用量管理界面为每个实例添加下载按钮,可直接下载关联的授权文件
- 在配置文件管理界面添加下载按钮,支持单独下载配置文件
- 为关联节点显示健康状态标签(正常/异常/禁用),使用不同颜色区分
- 更新 Claude Kiro 模型映射,修正 sonnet-4-5 模型名称
- 添加相关国际化翻译和样式支持
This commit is contained in:
hex2077 2026-03-11 11:29:13 +08:00
parent 7d2704b14e
commit 0bef99ef4f
10 changed files with 357 additions and 20 deletions

View file

@ -57,8 +57,8 @@ const FULL_MODEL_MAPPING = {
"claude-sonnet-4-6":"claude-sonnet-4.6",
"claude-opus-4-5":"claude-opus-4.5",
"claude-opus-4-5-20251101":"claude-opus-4.5",
"claude-sonnet-4-5": "CLAUDE_SONNET_4_5_20250929_V1_0",
"claude-sonnet-4-5-20250929": "CLAUDE_SONNET_4_5_20250929_V1_0"
"claude-sonnet-4-5": "claude-sonnet-4.5",
"claude-sonnet-4-5-20250929": "claude-sonnet-4.5"
};
// 只保留 KIRO_MODELS 中存在的模型映射

View file

@ -246,6 +246,13 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
return await uploadConfigApi.handleViewConfigFile(req, res, filePath);
}
// Download specific configuration file
const downloadConfigMatch = pathParam.match(/^\/api\/upload-configs\/download\/(.+)$/);
if (method === 'GET' && downloadConfigMatch) {
const filePath = decodeURIComponent(downloadConfigMatch[1]);
return await uploadConfigApi.handleDownloadConfigFile(req, res, filePath);
}
// Delete specific configuration file
const deleteConfigMatch = pathParam.match(/^\/api\/upload-configs\/delete\/(.+)$/);
if (method === 'DELETE' && deleteConfigMatch) {

View file

@ -294,6 +294,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH'
});
}
@ -308,6 +310,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'KIRO_OAUTH_CREDS_FILE_PATH'
});
}
@ -322,6 +326,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'QWEN_OAUTH_CREDS_FILE_PATH'
});
}
@ -336,6 +342,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH'
});
}
@ -350,6 +358,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'IFLOW_TOKEN_FILE_PATH'
});
}
@ -364,6 +374,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
providerIndex: index,
nodeName: provider.customName,
uuid: provider.uuid,
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
configKey: 'CODEX_OAUTH_CREDS_FILE_PATH'
});
}

View file

@ -83,6 +83,60 @@ export async function handleViewConfigFile(req, res, filePath) {
}
}
/**
* 下载特定配置文件
*/
export async function handleDownloadConfigFile(req, res, filePath) {
try {
const fullPath = path.join(process.cwd(), filePath);
// 安全检查:确保文件路径在允许的目录内
const allowedDirs = ['configs'];
const relativePath = path.relative(process.cwd(), fullPath);
const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir);
if (!isAllowed) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
message: 'Access denied: can only download files in configs directory'
}
}));
return true;
}
if (!existsSync(fullPath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
message: 'File does not exist'
}
}));
return true;
}
const content = await fs.readFile(fullPath);
const fileName = path.basename(fullPath);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${fileName}"`,
'Content-Length': content.length
});
res.end(content);
return true;
} catch (error) {
logger.error('[UI API] Failed to download config file:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
message: 'Failed to download config file: ' + error.message
}
}));
return true;
}
}
/**
* 删除特定配置文件
*/

View file

@ -3,6 +3,7 @@ import logger from '../utils/logger.js';
import { serviceInstances, getServiceAdapter } from '../providers/adapter.js';
import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage, formatGrokUsage } from '../services/usage-service.js';
import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js';
import { PROVIDER_MAPPINGS } from '../utils/provider-utils.js';
import path from 'path';
const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom'];
@ -82,6 +83,7 @@ async function getProviderTypeUsage(providerType, currentConfig, providerPoolMan
const instanceResult = {
uuid: provider.uuid || 'unknown',
name: getProviderDisplayName(provider, providerType),
configFilePath: getProviderConfigFilePath(provider, providerType),
isHealthy: provider.isHealthy !== false,
isDisabled: provider.isDisabled === true,
success: false,
@ -209,14 +211,8 @@ function getProviderDisplayName(provider, providerType) {
}
// 尝试从凭据文件路径提取名称
const credPathKey = {
'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH',
'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH',
'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH',
'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH',
'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH',
'openai-iflow': 'IFLOW_TOKEN_FILE_PATH'
}[providerType];
const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType);
const credPathKey = mapping ? mapping.credPathKey : null;
if (credPathKey && provider[credPathKey]) {
const filePath = provider[credPathKey];
@ -228,6 +224,19 @@ function getProviderDisplayName(provider, providerType) {
return 'Unnamed';
}
/**
* 获取提供商配置文件路径
* @param {Object} provider - 提供商配置
* @param {string} providerType - 提供商类型
* @returns {string|null} 配置文件路径
*/
function getProviderConfigFilePath(provider, providerType) {
const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType);
const credPathKey = mapping ? mapping.credPathKey : null;
return (credPathKey && provider[credPathKey]) ? provider[credPathKey] : null;
}
/**
* 获取支持用量查询的提供商列表
*/

View file

@ -397,6 +397,7 @@ const translations = {
'upload.detail.modified': '最后修改',
'upload.detail.status': '关联状态',
'upload.action.view': '查看',
'upload.action.download': '下载',
'upload.action.delete': '删除',
'upload.usage.title': '关联详情 ({type})',
'upload.usage.mainConfig': '主要配置',
@ -585,6 +586,9 @@ const translations = {
'usage.card.status.unhealthy': '异常',
'usage.card.totalUsage': '总用量',
'usage.card.resetAt': '将在 {time} 重置',
'usage.card.downloadConfig': '下载授权文件',
'usage.card.downloadSuccess': '授权文件下载成功',
'usage.card.downloadFailed': '授权文件下载失败',
'usage.card.freeTrial': '免费试用',
'usage.card.bonus': '奖励',
'usage.card.expires': '到期: {time}',
@ -1236,6 +1240,7 @@ const translations = {
'upload.detail.modified': 'Last Modified',
'upload.detail.status': 'Status',
'upload.action.view': 'View',
'upload.action.download': 'Download',
'upload.action.delete': 'Delete',
'upload.usage.title': 'Association Details ({type})',
'upload.usage.mainConfig': 'Main Config',
@ -1424,6 +1429,9 @@ const translations = {
'usage.card.status.unhealthy': 'Abnormal',
'usage.card.totalUsage': 'Total Usage',
'usage.card.resetAt': 'Resets at {time}',
'usage.card.downloadConfig': 'Download Config',
'usage.card.downloadSuccess': 'Config file downloaded successfully',
'usage.card.downloadFailed': 'Failed to download config file',
'usage.card.freeTrial': 'Free Trial',
'usage.card.bonus': 'Bonus',
'usage.card.expires': 'Expires: {time}',

View file

@ -113,22 +113,61 @@ function createConfigItemElement(config, index) {
let linkedNodesInfo = '';
if (config.isUsed && config.usageInfo && config.usageInfo.usageDetails) {
const details = config.usageInfo.usageDetails;
const infoParts = details.map(d => {
// 收集节点信息及其状态
const nodes = details.map(d => {
let name = '';
let isPool = false;
if (d.type === 'Provider Pool' || d.type === '提供商池') {
// 严格按照优先级:自定义名称 > UUID (简短) > 默认位置描述
if (d.nodeName) return d.nodeName;
if (d.uuid) return d.uuid.substring(0, 8);
return d.location;
isPool = true;
if (d.nodeName) name = d.nodeName;
else if (d.uuid) name = d.uuid.substring(0, 8);
else name = d.location;
} else if (d.type === 'Main Config' || d.type === '主要配置') {
return t('upload.usage.mainConfig');
name = t('upload.usage.mainConfig');
}
return null;
if (!name) return null;
return {
name,
isPool,
isHealthy: d.isHealthy,
isDisabled: d.isDisabled
};
}).filter(Boolean);
if (infoParts.length > 0) {
const uniqueParts = [...new Set(infoParts)];
if (nodes.length > 0) {
// 去重,但保留状态信息(如果有多个相同名称的节点,状态可能不同,这里按名称去重以节省空间,取第一个)
const uniqueNodes = [];
const seenNames = new Set();
for (const node of nodes) {
if (!seenNames.has(node.name)) {
uniqueNodes.push(node);
seenNames.add(node.name);
}
}
linkedNodesInfo = `<div class="linked-nodes-tags">
${uniqueParts.map(name => `<span class="node-tag" title="${name}"><i class="fas fa-link"></i> ${name}</span>`).join('')}
${uniqueNodes.map(node => {
let statusClass = '';
let statusIcon = 'fa-link';
if (node.isPool) {
if (node.isDisabled) {
statusClass = 'status-disabled';
statusIcon = 'fa-ban';
} else if (!node.isHealthy) {
statusClass = 'status-unhealthy';
statusIcon = 'fa-exclamation-circle';
} else {
statusClass = 'status-healthy';
statusIcon = 'fa-check-circle';
}
}
return `<span class="node-tag ${statusClass}" title="${node.name}"><i class="fas ${statusIcon}"></i> ${node.name}</span>`;
}).join('')}
</div>`;
}
}
@ -207,6 +246,9 @@ function createConfigItemElement(config, index) {
<button class="btn-small btn-view" data-path="${config.path}">
<i class="fas fa-eye"></i> <span data-i18n="upload.action.view">${t('upload.action.view')}</span>
</button>
<button class="btn-small btn-download" data-path="${config.path}">
<i class="fas fa-download"></i> <span data-i18n="upload.action.download">${t('upload.action.download')}</span>
</button>
<button class="btn-small btn-delete-small" data-path="${config.path}">
<i class="fas fa-trash"></i> <span data-i18n="upload.action.delete">${t('upload.action.delete')}</span>
</button>
@ -216,6 +258,7 @@ function createConfigItemElement(config, index) {
// 添加按钮事件监听器
const viewBtn = item.querySelector('.btn-view');
const downloadBtn = item.querySelector('.btn-download');
const deleteBtn = item.querySelector('.btn-delete-small');
if (viewBtn) {
@ -224,6 +267,13 @@ function createConfigItemElement(config, index) {
viewConfig(config.path);
});
}
if (downloadBtn) {
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
downloadSingleConfig(config.path);
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
@ -351,6 +401,18 @@ function generateUsageInfoHtml(config) {
subtitle = detail.providerType || '';
}
// 生成节点状态标签
let statusTag = '';
if (detail.type === 'Provider Pool' || detail.type === '提供商池') {
if (detail.isDisabled) {
statusTag = `<span class="node-status-tag disabled" data-i18n="modal.provider.status.disabled">${t('modal.provider.status.disabled')}</span>`;
} else if (!detail.isHealthy) {
statusTag = `<span class="node-status-tag unhealthy" data-i18n="modal.provider.status.unhealthy">${t('modal.provider.status.unhealthy')}</span>`;
} else {
statusTag = `<span class="node-status-tag healthy" data-i18n="modal.provider.status.healthy">${t('modal.provider.status.healthy')}</span>`;
}
}
detailsHtml += `
<div class="usage-detail-item" data-usage-type="${usageTypeKey}">
<i class="fas ${icon}"></i>
@ -358,6 +420,7 @@ function generateUsageInfoHtml(config) {
<div class="usage-detail-top">
<span class="usage-detail-type">${detail.type}</span>
<span class="usage-detail-location">${displayTitle}</span>
${statusTag}
</div>
${subtitle ? `<div class="usage-detail-subtitle">${subtitle}</div>` : ''}
</div>
@ -503,6 +566,47 @@ async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter
}
}
/**
* 下载单个配置文件
* @param {string} filePath - 文件路径
*/
async function downloadSingleConfig(filePath) {
if (!filePath) return;
try {
const fileName = filePath.split(/[/\\]/).pop();
const token = localStorage.getItem('authToken');
const headers = {
'Authorization': token ? `Bearer ${token}` : ''
};
const response = await fetch(`/api/upload-configs/download/${encodeURIComponent(filePath)}`, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(t('common.success'), t('usage.card.downloadSuccess') || '文件下载成功', 'success');
} catch (error) {
console.error('下载配置文件失败:', error);
showToast(t('common.error'), (t('usage.card.downloadFailed') || '下载配置文件失败') + ': ' + error.message, 'error');
}
}
/**
* 查看配置
* @param {string} path - 文件路径

View file

@ -526,6 +526,13 @@ function createInstanceUsageCard(instance, providerType) {
? `<span class="badge badge-healthy" data-i18n="usage.card.status.healthy">${t('usage.card.status.healthy')}</span>`
: `<span class="badge badge-unhealthy" data-i18n="usage.card.status.unhealthy">${t('usage.card.status.unhealthy')}</span>`);
// 下载按钮
const downloadBtnHTML = instance.configFilePath ? `
<button class="btn-download-config" title="${t('usage.card.downloadConfig') || '下载授权文件'}" data-path="${instance.configFilePath}">
<i class="fas fa-download"></i>
</button>
` : '';
// 获取用户邮箱和订阅信息
const userEmail = instance.usage?.user?.email || '';
const subscriptionTitle = instance.usage?.subscription?.title || '';
@ -545,6 +552,7 @@ function createInstanceUsageCard(instance, providerType) {
<span>${providerDisplayName}</span>
</div>
<div class="instance-status-badges">
${downloadBtnHTML}
${statusIcon}
${healthBadge}
</div>
@ -554,6 +562,18 @@ function createInstanceUsageCard(instance, providerType) {
</div>
${userInfoHTML}
`;
// 添加下载按钮点击事件
if (instance.configFilePath) {
const downloadBtn = header.querySelector('.btn-download-config');
if (downloadBtn) {
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
downloadConfigFile(instance.configFilePath);
});
}
}
expandedContent.appendChild(header);
// 实例内容 - 只显示用量和到期时间
@ -910,6 +930,41 @@ function getProviderIcon(providerType) {
}
/**
* 下载配置文件
* @param {string} filePath - 文件路径
*/
async function downloadConfigFile(filePath) {
if (!filePath) return;
try {
const fileName = filePath.split(/[/\\]/).pop();
const response = await fetch(`/api/upload-configs/download/${encodeURIComponent(filePath)}`, {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(t('common.success'), t('usage.card.downloadSuccess') || '文件下载成功', 'success');
} catch (error) {
console.error('下载配置文件失败:', error);
showToast(t('common.error'), (t('usage.card.downloadFailed') || '下载配置文件失败') + ': ' + error.message, 'error');
}
}
/**
* 格式化数字向上取整保留两位小数
* @param {number} num - 数字

View file

@ -314,6 +314,36 @@
font-weight: 500;
}
.node-tag.status-healthy {
color: #10b981;
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.15);
}
.node-tag.status-healthy i {
color: #10b981;
}
.node-tag.status-unhealthy {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.15);
}
.node-tag.status-unhealthy i {
color: #ef4444;
}
.node-tag.status-disabled {
color: #6b7280;
background: rgba(107, 114, 128, 0.08);
border-color: rgba(107, 114, 128, 0.15);
}
.node-tag.status-disabled i {
color: #6b7280;
}
.node-tag i {
font-size: 0.6rem;
color: #3b82f6;
@ -454,6 +484,8 @@
.btn-view { background: var(--primary-color); color: white; }
.btn-view:hover { background: var(--btn-primary-hover); }
.btn-download { background: #10b981; color: white; }
.btn-download:hover { background: #059669; }
.btn-delete-small { background: var(--danger-color); color: white; }
.config-item-manager.expanded {
@ -570,6 +602,32 @@
flex-wrap: wrap;
}
.node-status-tag {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.node-status-tag.healthy {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.node-status-tag.unhealthy {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.node-status-tag.disabled {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
border: 1px solid rgba(107, 114, 128, 0.2);
}
.usage-detail-subtitle {
font-size: 0.75rem;
color: var(--text-tertiary);

View file

@ -271,6 +271,36 @@
color: white; border-radius: 9999px; font-weight: 500;
}
.instance-status-badges {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-download-config {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: var(--transition);
}
.btn-download-config:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.badge {
font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-weight: 500;
}