feat(upload-config): 添加配置文件下载功能并显示节点状态
- 新增配置文件下载 API 端点,支持安全下载 configs 目录下的文件 - 在用量管理界面为每个实例添加下载按钮,可直接下载关联的授权文件 - 在配置文件管理界面添加下载按钮,支持单独下载配置文件 - 为关联节点显示健康状态标签(正常/异常/禁用),使用不同颜色区分 - 更新 Claude Kiro 模型映射,修正 sonnet-4-5 模型名称 - 添加相关国际化翻译和样式支持
This commit is contained in:
parent
7d2704b14e
commit
0bef99ef4f
10 changed files with 357 additions and 20 deletions
|
|
@ -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 中存在的模型映射
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除特定配置文件
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持用量查询的提供商列表
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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 - 文件路径
|
||||
|
|
|
|||
|
|
@ -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 - 数字
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue