From fa8150701f81f2253012f81883c682e95b1203de Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 20 Dec 2025 17:27:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=E5=B9=B6=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9B=BD=E9=99=85=E5=8C=96=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现中英文双语支持,包括: 1. 添加i18n.js核心模块处理语言切换和翻译 2. 创建语言切换器组件 3. 更新所有UI文本使用翻译键 4. 添加I18N_GUIDE.md文档说明使用方法 5. 修改样式适配语言切换器 6. 添加adm-zip依赖支持配置文件打包下载 7. 更新登录页面支持多语言 8. 重构toast消息显示支持多语言标题 --- package-lock.json | 10 + package.json | 1 + src/ui-manager.js | 55 ++ static/app/I18N_GUIDE.md | 157 ++++++ static/app/app.js | 4 +- static/app/config-manager.js | 11 +- static/app/event-handlers.js | 20 +- static/app/event-stream.js | 7 +- static/app/file-upload.js | 9 +- static/app/i18n.js | 838 ++++++++++++++++++++++++++++ static/app/language-switcher.js | 105 ++++ static/app/modal.js | 139 ++--- static/app/provider-manager.js | 77 +-- static/app/routing-examples.js | 35 +- static/app/styles.css | 132 ++++- static/app/upload-config-manager.js | 197 ++++--- static/app/usage-manager.js | 47 +- static/app/utils.js | 74 ++- static/index.html | 441 ++++++++------- static/login.html | 79 ++- 20 files changed, 1939 insertions(+), 499 deletions(-) create mode 100644 static/app/I18N_GUIDE.md create mode 100644 static/app/i18n.js create mode 100644 static/app/language-switcher.js diff --git a/package-lock.json b/package-lock.json index 4c13297..80ddcb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", + "adm-zip": "^0.5.16", "axios": "^1.10.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.5", @@ -2364,6 +2365,15 @@ "dev": true, "license": "ISC" }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/package.json b/package.json index 57c009b..4fe12f9 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "type": "module", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", + "adm-zip": "^0.5.16", "axios": "^1.10.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.5", diff --git a/src/ui-manager.js b/src/ui-manager.js index 57336ca..87a98cd 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import multer from 'multer'; import crypto from 'crypto'; +import AdmZip from 'adm-zip'; import { getRequestBody } from './common.js'; import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; @@ -1567,6 +1568,60 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Download all configs as zip + if (method === 'GET' && pathParam === '/api/upload-configs/download-all') { + try { + const configsPath = path.join(process.cwd(), 'configs'); + if (!existsSync(configsPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'configs目录不存在' } })); + return true; + } + + const zip = new AdmZip(); + + // 递归添加目录函数 + const addDirectoryToZip = async (dirPath, zipPath = '') => { + const items = await fs.readdir(dirPath, { withFileTypes: true }); + for (const item of items) { + const fullPath = path.join(dirPath, item.name); + const itemZipPath = zipPath ? path.join(zipPath, item.name) : item.name; + + if (item.isFile()) { + const content = await fs.readFile(fullPath); + zip.addFile(itemZipPath.replace(/\\/g, '/'), content); + } else if (item.isDirectory()) { + await addDirectoryToZip(fullPath, itemZipPath); + } + } + }; + + await addDirectoryToZip(configsPath); + + const zipBuffer = zip.toBuffer(); + const filename = `configs_backup_${new Date().toISOString().replace(/[:.]/g, '-')}.zip`; + + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': zipBuffer.length + }); + res.end(zipBuffer); + + console.log(`[UI API] All configs downloaded as zip: ${filename}`); + return true; + } catch (error) { + console.error('[UI API] Failed to download all configs:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '打包下载失败: ' + error.message + } + })); + return true; + } + } + // Quick link config to corresponding provider based on directory if (method === 'POST' && pathParam === '/api/quick-link-provider') { try { diff --git a/static/app/I18N_GUIDE.md b/static/app/I18N_GUIDE.md new file mode 100644 index 0000000..6595649 --- /dev/null +++ b/static/app/I18N_GUIDE.md @@ -0,0 +1,157 @@ +# 多语言支持使用指南 + +## 概述 + +本项目已实现中英文双语支持,可以通过页面右上角的语言切换按钮在简体中文和英文之间切换。 + +## 文件结构 + +``` +static/app/ +├── i18n.js # 多语言配置文件(包含所有翻译) +├── language-switcher.js # 语言切换组件 +└── I18N_GUIDE.md # 本指南 +``` + +## 如何使用 + +### 1. 在 HTML 中添加多语言支持 + +使用 `data-i18n` 属性标记需要翻译的元素: + +```html + +

AIClient2API 管理控制台

+ + + + + + + + +共 10 个配置文件 +``` + +### 2. 在 JavaScript 中使用翻译 + +```javascript +import { t } from './i18n.js'; + +// 简单翻译 +const title = t('header.title'); + +// 带参数的翻译 +const message = t('upload.count', { count: 10 }); + +// 在 showToast 中使用 +showToast(t('common.success'), t('config.saved'), 'success'); +``` + +### 3. 添加新的翻译 + +在 `i18n.js` 文件中的 `translations` 对象中添加: + +```javascript +const translations = { + 'zh-CN': { + 'your.key': '你的中文翻译', + // ... + }, + 'en-US': { + 'your.key': 'Your English translation', + // ... + } +}; +``` + +### 4. 动态内容的翻译 + +对于动态生成的内容,在创建 DOM 元素时添加 `data-i18n` 属性: + +```javascript +const element = document.createElement('div'); +element.setAttribute('data-i18n', 'your.translation.key'); +element.textContent = t('your.translation.key'); +``` + +## 翻译键命名规范 + +使用点号分隔的层级结构: + +- `header.*` - 页头相关 +- `nav.*` - 导航相关 +- `dashboard.*` - 仪表盘相关 +- `config.*` - 配置相关 +- `providers.*` - 提供商相关 +- `upload.*` - 上传配置相关 +- `usage.*` - 用量查询相关 +- `logs.*` - 日志相关 +- `common.*` - 通用文本 + +## 已实现的功能 + +✅ 自动检测并保存用户语言偏好 +✅ 页面刷新后保持语言选择 +✅ 动态添加的元素自动翻译 +✅ 支持带参数的翻译 +✅ 语言切换时实时更新所有文本 + +## 待完善的部分 + +由于页面内容较多,以下部分需要继续添加 `data-i18n` 属性: + +1. 配置管理页面的表单标签和提示 +2. 提供商池管理的详细信息 +3. 上传配置管理的列表项 +4. 用量查询的统计信息 +5. 实时日志的控制按钮 + +## 示例:完整的多语言表单 + +```html +
+ + +
+``` + +对应的翻译配置: + +```javascript +'zh-CN': { + 'config.apiKey': 'API密钥', + 'config.apiKeyPlaceholder': '请输入API密钥' +}, +'en-US': { + 'config.apiKey': 'API Key', + 'config.apiKeyPlaceholder': 'Please enter API key' +} +``` + +## 注意事项 + +1. 所有翻译键必须在两种语言中都存在 +2. 参数化翻译使用 `{paramName}` 格式 +3. HTML 内容使用 `data-i18n-html` 属性 +4. 语言切换会触发 `languageChanged` 事件 +5. 新添加的 DOM 元素会自动被翻译系统检测 + +## 调试 + +在浏览器控制台中: + +```javascript +// 获取当前语言 +import { getCurrentLanguage } from './app/i18n.js'; +console.log(getCurrentLanguage()); + +// 手动切换语言 +import { setLanguage } from './app/i18n.js'; +setLanguage('en-US'); \ No newline at end of file diff --git a/static/app/app.js b/static/app/app.js index 567b35a..68df82c 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -11,6 +11,8 @@ import { getProviderStats } from './utils.js'; +import { t } from './i18n.js'; + import { initFileUpload, fileUploadHandler @@ -106,7 +108,7 @@ function initApp() { loadInitialData(); // 显示欢迎消息 - showToast('欢迎使用AIClent2API管理控制台!', 'success'); + showToast(t('common.success'), t('common.welcome'), 'success'); // 每5秒更新服务器时间和运行时间显示 setInterval(() => { diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 0e13cc9..55c4f69 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -3,6 +3,7 @@ import { showToast, formatUptime } from './utils.js'; import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js'; import { loadProviders } from './provider-manager.js'; +import { t } from './i18n.js'; /** * 加载配置 @@ -233,26 +234,26 @@ async function saveConfiguration() { // 清空密码输入框 const adminPasswordEl = document.getElementById('adminPassword'); if (adminPasswordEl) adminPasswordEl.value = ''; - showToast('后台密码已更新,下次登录生效', 'success'); + showToast(t('common.success'), t('common.passwordUpdated'), 'success'); } catch (pwdError) { console.error('Failed to save admin password:', pwdError); - showToast('保存后台密码失败: ' + pwdError.message, 'error'); + showToast(t('common.error'), t('common.error') + ': ' + pwdError.message, 'error'); } } await window.apiClient.post('/reload-config'); - showToast('配置已保存', 'success'); + showToast(t('common.success'), t('common.configSaved'), 'success'); // 检查当前是否在提供商池管理页面,如果是则刷新数据 const providersSection = document.getElementById('providers'); if (providersSection && providersSection.classList.contains('active')) { // 当前在提供商池页面,刷新数据 await loadProviders(); - showToast('提供商池数据已刷新', 'success'); + showToast(t('common.success'), t('common.providerPoolRefreshed'), 'success'); } } catch (error) { console.error('Failed to save configuration:', error); - showToast('保存配置失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); } } diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index b608e88..c49490d 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -3,6 +3,7 @@ import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; import { showToast } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; +import { t } from './i18n.js'; /** * 初始化所有事件监听器 @@ -20,7 +21,7 @@ function initEventListeners() { if (elements.logsContainer) { elements.logsContainer.innerHTML = ''; } - showToast('日志已清空', 'success'); + showToast(t('common.success'), t('common.refresh.success'), 'success'); }); } @@ -30,9 +31,10 @@ function initEventListeners() { const newAutoScroll = !autoScroll; setAutoScroll(newAutoScroll); elements.toggleAutoScrollBtn.dataset.enabled = newAutoScroll; + const statusText = newAutoScroll ? t('logs.autoScroll.on') : t('logs.autoScroll.off'); elements.toggleAutoScrollBtn.innerHTML = ` - 自动滚动: ${newAutoScroll ? '开' : '关'} + ${statusText} `; }); } @@ -89,7 +91,7 @@ function initEventListeners() { elements.toggleAutoScrollBtn.dataset.enabled = false; elements.toggleAutoScrollBtn.innerHTML = ` - 自动滚动: 关 + ${t('logs.autoScroll.off')} `; } } @@ -189,7 +191,7 @@ async function handleGenerateCreds(event) { const targetInputId = button.getAttribute('data-target'); try { - showToast('正在初始化凭据生成...', 'info'); + showToast(t('common.info'), t('modal.provider.auth.initializing'), 'info'); // 使用 fileUploadHandler 中的 getProviderKey 获取目录名称 const providerDir = fileUploadHandler.getProviderKey(providerType); @@ -211,7 +213,7 @@ async function handleGenerateCreds(event) { if (input) { input.value = data.relativePath; input.dispatchEvent(new Event('input', { bubbles: true })); - showToast('凭据已生成并自动填充路径', 'success'); + showToast(t('common.success'), t('modal.provider.auth.success'), 'success'); } window.removeEventListener('oauth_success_event', handleSuccess); } @@ -225,14 +227,14 @@ async function handleGenerateCreds(event) { } else { // 降级处理:如果在 app.js 中没导出,尝试直接打开 window.open(response.authUrl, '_blank'); - showToast('请在打开的窗口中完成授权', 'info'); + showToast(t('common.info'), t('modal.provider.auth.window'), 'info'); } } else { - showToast('初始化凭据生成失败', 'error'); + showToast(t('common.error'), t('modal.provider.auth.failed'), 'error'); } } catch (error) { console.error('生成凭据失败:', error); - showToast(`生成凭据失败: ${error.message}`, 'error'); + showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error'); } } @@ -305,7 +307,7 @@ async function handleRefresh() { } } catch (error) { console.error('刷新失败:', error); - showToast('刷新失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); } } diff --git a/static/app/event-stream.js b/static/app/event-stream.js index 11ea0f3..78c0bec 100644 --- a/static/app/event-stream.js +++ b/static/app/event-stream.js @@ -1,6 +1,7 @@ // Server-Sent Events处理模块 import { eventSource, setEventSource, elements, addLog, autoScroll } from './constants.js'; +import { t } from './i18n.js'; /** * Server-Sent Events初始化 @@ -35,7 +36,7 @@ function initEventStream() { newEventSource.addEventListener('oauth_success', (event) => { const data = JSON.parse(event.data); - showToast(`授权成功 (${data.provider})`, 'success'); + showToast(t('common.success'), `${t('common.success')} (${data.provider})`, 'success'); // 发送自定义事件,以便其他模块(如生成凭据逻辑)可以接收到详细信息 window.dispatchEvent(new CustomEvent('oauth_success_event', { detail: data })); @@ -102,11 +103,11 @@ function updateServerStatus(connected) { if (connected) { statusBadge.classList.remove('error'); icon.style.color = 'var(--success-color)'; - statusBadge.innerHTML = ' 已连接'; + statusBadge.innerHTML = ` ${t('header.status.connected')}`; } else { statusBadge.classList.add('error'); icon.style.color = 'var(--danger-color)'; - statusBadge.innerHTML = ' 连接断开'; + statusBadge.innerHTML = ` ${t('header.status.disconnected')}`; } } diff --git a/static/app/file-upload.js b/static/app/file-upload.js index 75d38e7..d440869 100644 --- a/static/app/file-upload.js +++ b/static/app/file-upload.js @@ -1,6 +1,7 @@ // 文件上传功能模块 import { showToast } from './utils.js'; +import { t } from './i18n.js'; /** * 文件上传处理器类 @@ -113,14 +114,14 @@ class FileUploadHandler { try { // 验证文件类型 if (!this.validateFileType(file)) { - showToast('不支持的文件类型,请选择 JSON、TXT、KEY、PEM、P12 或 PFX 文件', 'error'); + showToast(t('common.error'), t('common.fileType'), 'error'); this.setButtonLoading(button, false); return; } // 验证文件大小 (5MB 限制) if (file.size > 5 * 1024 * 1024) { - showToast('文件大小不能超过 5MB', 'error'); + showToast(t('common.error'), t('common.fileSize'), 'error'); this.setButtonLoading(button, false); return; } @@ -139,11 +140,11 @@ class FileUploadHandler { // 成功上传,设置文件路径到输入框 this.setFilePathToInput(targetInputId, result.filePath); - showToast('文件上传成功', 'success'); + showToast(t('common.success'), t('common.uploadSuccess'), 'success'); } catch (error) { console.error('文件上传错误:', error); - showToast('文件上传失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.uploadFailed') + ': ' + error.message, 'error'); } finally { this.setButtonLoading(button, false); } diff --git a/static/app/i18n.js b/static/app/i18n.js new file mode 100644 index 0000000..3b20c80 --- /dev/null +++ b/static/app/i18n.js @@ -0,0 +1,838 @@ +// 多语言配置 +const translations = { + 'zh-CN': { + // Header + 'header.title': 'AIClient2API 管理控制台', + 'header.status.connecting': '连接中...', + 'header.status.connected': '已连接', + 'header.status.disconnected': '连接断开', + 'header.logout': '登出', + 'header.refresh': '重载', + + // Navigation + 'nav.main': '主导航', + 'nav.dashboard': '仪表盘', + 'nav.config': '配置管理', + 'nav.providers': '提供商池管理', + 'nav.upload': '上传配置管理', + 'nav.usage': '用量查询', + 'nav.logs': '实时日志', + + // Dashboard + 'dashboard.title': '系统概览', + 'dashboard.uptime': '运行时间', + 'dashboard.systemInfo': '系统信息', + 'dashboard.nodeVersion': 'Node.js版本', + 'dashboard.serverTime': '服务器时间', + 'dashboard.memoryUsage': '内存使用', + 'dashboard.routing.title': '路径路由调用示例', + 'dashboard.routing.description': '通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换', + 'dashboard.routing.oauth': '突破限制', + 'dashboard.routing.official': '官方API/三方', + 'dashboard.routing.experimental': '突破限制/实验性', + 'dashboard.routing.free': '突破限制/免费使用', + 'dashboard.routing.endpoint': '端点路径:', + 'dashboard.routing.example': '使用示例', + 'dashboard.routing.exampleOpenAI': '使用示例 (OpenAI格式):', + 'dashboard.routing.exampleClaude': '使用示例 (Claude格式):', + 'dashboard.routing.openai': 'OpenAI协议', + 'dashboard.routing.claude': 'Claude协议', + 'dashboard.routing.tips': '使用提示', + 'dashboard.routing.tip1': '即时切换: 通过修改URL路径即可切换不同的AI模型提供商', + 'dashboard.routing.tip2': '客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径', + 'dashboard.routing.tip3': '跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型', + 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', + 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', + 'dashboard.routing.nodeName.claude': 'Claude Custom', + 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', + 'dashboard.routing.nodeName.openai': 'OpenAI Custom', + 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', + 'dashboard.contact.title': '联系与赞助', + 'dashboard.contact.wechat': '扫码进群,注明来意', + 'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流', + 'dashboard.contact.sponsor': '扫码赞助', + 'dashboard.contact.sponsorDesc': '您的赞助是项目持续发展的动力', + + // OAuth + 'oauth.modal.title': 'OAuth 授权', + 'oauth.modal.provider': '提供商:', + 'oauth.modal.steps': '授权步骤:', + 'oauth.modal.step1': '点击下方按钮在浏览器中打开授权页面', + 'oauth.modal.step2.qwen': '完成授权后,系统会自动获取凭据文件', + 'oauth.modal.step2.google': '使用您的Google账号登录并授权', + 'oauth.modal.step3': '凭据文件可在上传配置管理中查看和管理', + 'oauth.modal.step4.qwen': '授权有效期: {min} 分钟', + 'oauth.modal.step4.google': '授权完成后,凭据文件会自动保存', + 'oauth.modal.urlLabel': '授权链接:', + 'oauth.modal.copyTitle': '复制链接', + 'oauth.modal.openInBrowser': '在浏览器中打开', + 'oauth.manual.title': '自动监听受阻?', + 'oauth.manual.desc': '如果授权窗口重定向后显示“无法访问”,请将该窗口地址栏的 完整 URL 粘贴到下方:', + 'oauth.manual.placeholder': '粘贴回调 URL (包含 code=...)', + 'oauth.manual.submit': '提交', + 'oauth.success.msg': '授权链接已复制到剪贴板', + 'oauth.window.blocked': '授权窗口被浏览器拦截,请允许弹出窗口', + 'oauth.window.opened': '已打开授权窗口,请在窗口中完成操作', + 'oauth.processing': '正在完成授权...', + 'oauth.invalid.url': '该 URL 似乎不包含有效的授权代码', + 'oauth.error.format': '无效的 URL 格式', + + // Config + 'config.title': '配置管理', + 'config.apiKey': 'API密钥', + 'config.apiKeyPlaceholder': '请输入API密钥', + 'config.host': '监听地址', + 'config.port': '端口', + 'config.modelProvider': '模型提供商', + 'config.optional': '(选填)', + 'config.gemini.baseUrl': 'Gemini Base URL', + 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', + 'config.gemini.projectId': '项目ID', + 'config.gemini.projectIdPlaceholder': 'Google Cloud项目ID', + 'config.gemini.oauthCreds': 'OAuth凭据', + 'config.gemini.credsType.file': '文件路径', + 'config.gemini.credsType.base64': 'Base64编码', + 'config.gemini.credsBase64': 'OAuth凭据 (Base64)', + 'config.gemini.credsBase64Placeholder': '请输入Base64编码的OAuth凭据', + 'config.gemini.credsFilePath': 'OAuth凭据文件路径', + 'config.gemini.credsFilePathPlaceholder': '例如: ~/.gemini/oauth_creds.json', + 'config.antigravity.dailyUrl': 'Daily Base URL', + 'config.antigravity.dailyUrlPlaceholder': 'https://daily-cloudcode-pa.sandbox.googleapis.com', + 'config.antigravity.autopushUrl': 'Autopush Base URL', + 'config.antigravity.autopushUrlPlaceholder': 'https://autopush-cloudcode-pa.sandbox.googleapis.com', + 'config.antigravity.credsFilePath': 'OAuth凭据文件路径', + 'config.antigravity.credsFilePathPlaceholder': '例如: ~/.antigravity/oauth_creds.json', + 'config.antigravity.note': 'Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径', + 'config.openai.apiKey': 'OpenAI API Key', + 'config.openai.apiKeyPlaceholder': 'sk-...', + 'config.openai.baseUrl': 'OpenAI Base URL', + 'config.openai.baseUrlPlaceholder': '例如: https://api.openai.com/v1', + 'config.claude.apiKey': 'Claude API Key', + 'config.claude.apiKeyPlaceholder': 'sk-ant-...', + 'config.claude.baseUrl': 'Claude Base URL', + 'config.claude.baseUrlPlaceholder': '例如: https://api.anthropic.com', + 'config.kiro.baseUrl': 'Base URL', + 'config.kiro.baseUrlPlaceholder': 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse', + 'config.kiro.refreshUrl': 'Refresh URL', + 'config.kiro.refreshUrlPlaceholder': 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', + 'config.kiro.refreshIdcUrl': 'Refresh IDC URL', + 'config.kiro.refreshIdcUrlPlaceholder': 'https://oidc.{{region}}.amazonaws.com/token', + 'config.kiro.credsFilePath': 'OAuth凭据文件路径', + 'config.kiro.credsFilePathPlaceholder': '例如: ~/.aws/sso/cache/kiro-auth-token.json', + 'config.kiro.note': '使用 AWS 登录方式时,请确保授权文件中包含 clientId 和 clientSecret 字段', + 'config.qwen.baseUrl': 'Qwen Base URL', + 'config.qwen.baseUrlPlaceholder': 'https://portal.qwen.ai/v1', + 'config.qwen.oauthBaseUrl': 'OAuth Base URL', + 'config.qwen.oauthBaseUrlPlaceholder': 'https://chat.qwen.ai', + 'config.qwen.credsFilePath': 'OAuth凭据文件路径', + 'config.qwen.credsFilePathPlaceholder': '例如: ~/.qwen/oauth_creds.json', + 'config.advanced.title': '高级配置', + 'config.advanced.systemPromptFile': '系统提示文件路径', + 'config.advanced.systemPromptFilePlaceholder': '例如: input_system_prompt.txt', + 'config.advanced.systemPromptMode': '系统提示模式', + 'config.advanced.systemPromptMode.append': '追加 (append)', + 'config.advanced.systemPromptMode.overwrite': '覆盖 (overwrite)', + 'config.advanced.promptLogBaseName': '提示日志基础名称', + 'config.advanced.promptLogBaseNamePlaceholder': '例如: prompt_log', + 'config.advanced.promptLogMode': '提示日志模式', + 'config.advanced.promptLogMode.none': '无 (none)', + 'config.advanced.promptLogMode.console': '控制台 (console)', + 'config.advanced.promptLogMode.file': '文件 (file)', + 'config.advanced.maxRetries': '最大重试次数', + 'config.advanced.baseDelay': '重试基础延迟(毫秒)', + 'config.advanced.cronInterval': 'OAuth令牌刷新间隔(分钟)', + 'config.advanced.cronEnabled': '启用OAuth令牌自动刷新(需重启服务)', + 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', + 'config.advanced.poolFilePathPlaceholder': '默认: provider_pools.json', + 'config.advanced.poolNote': '配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置', + 'config.advanced.maxErrorCount': '提供商最大错误次数', + 'config.advanced.maxErrorCountPlaceholder': '默认: 3', + 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 3 次', + 'config.advanced.systemPrompt': '系统提示', + 'config.advanced.systemPromptPlaceholder': '输入系统提示...', + 'config.advanced.adminPassword': '后台登录密码', + 'config.advanced.adminPasswordPlaceholder': '设置后台登录密码(留空则不修改)', + 'config.advanced.adminPasswordNote': '用于保护管理控制台的访问,修改后需要重新登录', + 'config.save': '保存配置', + 'config.reset': '重置', + 'config.placeholder.nodeName': '例如: 我的节点1', + 'config.placeholder.model': '例如: gpt-3.5-turbo', + + // Upload Config + 'upload.title': '上传配置管理', + 'upload.search': '搜索配置', + 'upload.searchPlaceholder': '输入文件名', + 'upload.statusFilter': '关联状态', + 'upload.statusFilter.all': '全部状态', + 'upload.statusFilter.used': '已关联', + 'upload.statusFilter.unused': '未关联', + 'upload.refresh': '刷新', + 'upload.downloadAll': '打包下载', + 'upload.listTitle': '配置文件列表', + 'upload.count': '共 {count} 个配置文件', + 'upload.usedCount': '已关联: {count}', + 'upload.unusedCount': '未关联: {count}', + 'upload.batchLink': '自动关联oauth', + 'upload.noConfigs': '未找到匹配的配置文件', + 'upload.detail.path': '文件路径', + 'upload.detail.size': '文件大小', + 'upload.detail.modified': '最后修改', + 'upload.detail.status': '关联状态', + 'upload.action.view': '查看', + 'upload.action.delete': '删除', + 'upload.usage.title': '关联详情 ({type})', + 'upload.usage.mainConfig': '主要配置', + 'upload.usage.providerPool': '提供商池', + 'upload.usage.multiple': '多种用途', + 'upload.delete.confirmTitle': '删除配置文件', + 'upload.delete.confirmTitleUsed': '删除已关联配置', + 'upload.delete.warningUsedTitle': '⚠️ 此配置已被系统使用', + 'upload.delete.warningUsedDesc': '删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。', + 'upload.delete.warningUnusedTitle': '🗑️ 确认删除配置文件', + 'upload.delete.warningUnusedDesc': '此操作将永久删除配置文件,且无法撤销。', + 'upload.delete.fileName': '文件名:', + 'upload.delete.usageAlertTitle': '关联详情', + 'upload.delete.usageAlertDesc': '此配置文件正在被系统使用,删除后可能会导致:', + 'upload.delete.usageAlertItem1': '相关的AI服务无法正常工作', + 'upload.delete.usageAlertItem2': '配置管理中的设置失效', + 'upload.delete.usageAlertItem3': '提供商池配置丢失', + 'upload.delete.usageAlertAdvice': '建议:请先在配置管理中解除文件引用后再删除。', + 'upload.delete.forceDelete': '强制删除', + 'upload.delete.confirmDelete': '确认删除', + 'upload.batchLink.confirm': '确定要批量关联 {count} 个配置吗?\n\n{summary}', + 'upload.refresh.success': '刷新成功', + 'upload.action.view.failed': '查看失败', + 'upload.action.delete.failed': '删除失败', + 'upload.config.notExist': '配置文件不存在', + 'upload.link.identifying': '正在识别提供商类型...', + 'upload.link.failed.identify': '无法识别配置文件对应的提供商类型', + 'upload.link.processing': '正在关联配置到 {name}...', + 'upload.link.success': '配置关联成功', + 'upload.link.failed': '关联失败', + 'upload.batchLink.none': '没有需要关联的配置文件', + 'upload.batchLink.processing': '正在批量关联 {count} 个配置...', + 'upload.batchLink.success': '成功关联 {count} 个配置', + 'upload.batchLink.partial': '关联完成: 成功 {success} 个, 失败 {fail} 个', + + // Providers + 'providers.title': '提供商池管理', + 'providers.note': '配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置', + 'providers.activeConnections': '活动连接', + 'providers.activeProviders': '活跃提供商', + 'providers.healthyProviders': '健康提供商', + 'providers.status.healthy': '{healthy}/{total} 健康', + 'providers.status.empty': '0/0 节点', + 'providers.stat.totalAccounts': '总账户', + 'providers.stat.healthyAccounts': '健康账户', + 'providers.stat.usageCount': '使用次数', + 'providers.stat.errorCount': '错误次数', + 'providers.auth.generate': '生成授权', + + // Modal Provider Manager + 'modal.provider.manage': '管理 {type} 提供商配置', + 'modal.provider.totalAccounts': '总账户数:', + 'modal.provider.healthyAccounts': '健康账户:', + 'modal.provider.add': '添加新提供商', + 'modal.provider.resetHealth': '重置为健康', + 'modal.provider.healthCheck': '健康检测', + 'modal.provider.resetHealthConfirm': '确定要将 {type} 的所有节点重置为健康状态吗?\n\n这将清除所有节点的错误计数和错误时间。', + 'modal.provider.healthCheckConfirm': '确定要对 {type} 的所有节点执行健康检测吗?\n\n这将向每个节点发送测试请求来验证其可用性。', + 'modal.provider.deleteConfirm': '确定要删除这个提供商配置吗?此操作不可恢复。', + 'modal.provider.disableConfirm': '确定要禁用这个提供商配置吗?禁用后该提供商将不会被选中使用。', + 'modal.provider.enableConfirm': '确定要启用这个提供商配置吗?', + 'modal.provider.edit': '编辑', + 'modal.provider.delete': '删除', + 'modal.provider.save': '保存', + 'modal.provider.cancel': '取消', + 'modal.provider.status.healthy': '正常', + 'modal.provider.status.unhealthy': '异常', + 'modal.provider.status.disabled': '已禁用', + 'modal.provider.status.enabled': '已启用', + 'modal.provider.lastError': '最后错误:', + 'modal.provider.lastUsed': '最后使用:', + 'modal.provider.lastCheck': '最后检测:', + 'modal.provider.checkModel': '检测模型:', + 'modal.provider.usageCount': '使用次数:', + 'modal.provider.errorCount': '失败次数:', + 'modal.provider.neverUsed': '从未使用', + 'modal.provider.neverChecked': '从未检测', + 'modal.provider.noModels': '该提供商类型暂无可用模型列表', + 'modal.provider.loadingModels': '加载模型列表...', + 'modal.provider.unsupportedModels': '不支持的模型', + 'modal.provider.unsupportedModelsHelp': '选择此提供商不支持的模型,系统会自动排除这些模型', + 'modal.provider.addTitle': '添加新提供商配置', + 'modal.provider.customName': '自定义名称', + 'modal.provider.checkModelName': '检查模型名称', + 'modal.provider.healthCheckLabel': '健康检查', + 'modal.provider.enabled': '启用', + 'modal.provider.disabled': '禁用', + 'modal.provider.noProviderType': '不支持的提供商类型', + + 'modal.provider.load.failed': '加载提供商详情失败', + 'modal.provider.auth.initializing': '正在初始化凭据生成...', + 'modal.provider.auth.success': '凭据已生成并自动填充路径', + 'modal.provider.auth.window': '请在打开的窗口中完成授权', + 'modal.provider.auth.failed': '初始化凭据生成失败', + 'modal.provider.save.success': '保存成功', + 'modal.provider.save.failed': '保存失败', + 'modal.provider.delete.success': '删除成功', + 'modal.provider.delete.failed': '删除失败', + 'modal.provider.add.success': '添加成功', + 'modal.provider.add.failed': '添加失败', + 'modal.provider.resetHealth.success': '成功重置 {count} 个节点的健康状态', + 'modal.provider.resetHealth.failed': '重置健康状态失败', + + // Pagination + 'pagination.showing': '显示 {start}-{end} / 共 {total} 条', + 'pagination.jumpTo': '跳转到', + 'pagination.page': '页', + + // Usage + 'usage.title': '用量查询', + 'usage.refresh': '刷新用量', + 'usage.lastUpdate': '上次更新: {time}', + 'usage.lastUpdateCache': '缓存时间: {time}', + 'usage.loading': '正在加载用量数据...', + 'usage.empty': '点击"刷新用量"按钮获取授权文件用量信息', + 'usage.noData': '暂无用量数据', + 'usage.noInstances': '暂无已初始化的服务实例', + 'usage.group.instances': '{count} 个实例', + 'usage.group.success': '{count}/{total} 成功', + 'usage.card.status.disabled': '已禁用', + 'usage.card.status.healthy': '健康', + 'usage.card.status.unhealthy': '异常', + 'usage.card.totalUsage': '总用量', + 'usage.card.freeTrial': '免费试用', + 'usage.card.bonus': '奖励', + 'usage.card.expires': '到期: {time}', + + // Logs + 'logs.title': '实时日志', + 'logs.clear': '清空日志', + 'logs.autoScroll': '自动滚动', + 'logs.autoScroll.on': '自动滚动: 开', + 'logs.autoScroll.off': '自动滚动: 关', + + // Common + 'common.confirm': '确定', + 'common.cancel': '取消', + 'common.success': '成功', + 'common.error': '错误', + 'common.warning': '警告', + 'common.info': '信息', + 'common.loading': '加载中...', + 'common.upload': '上传', + 'common.generate': '生成', + 'common.search': '搜索', + 'common.welcome': '欢迎使用AIClient2API管理控制台!', + 'common.fileType': '不支持的文件类型,请选择 JSON、TXT、KEY、PEM、P12 或 PFX 文件', + 'common.fileSize': '文件大小不能超过 5MB', + 'common.uploadSuccess': '文件上传成功', + 'common.uploadFailed': '文件上传失败', + 'common.passwordUpdated': '后台密码已更新,下次登录生效', + 'common.configSaved': '配置已保存', + 'common.providerPoolRefreshed': '提供商池数据已刷新', + 'common.togglePassword': '显示/隐藏密码', + 'common.copy.success': '内容已复制到剪贴板', + 'common.copy.failed': '复制失败,请手动复制', + 'common.refresh.success': '刷新成功', + 'common.refresh.failed': '刷新失败', + + // Login + 'login.title': '登录 - AIClient2API', + 'login.heading': '请登录以继续', + 'login.password': '密码', + 'login.passwordPlaceholder': '请输入密码', + 'login.error.empty': '请输入密码', + 'login.error.incorrect': '密码错误,请重试', + 'login.error.failed': '登录失败,请检查网络连接', + 'login.button': '登录', + 'login.loggingIn': '登录中...', + }, + 'en-US': { + // Header + 'header.title': 'AIClient2API Management Console', + 'header.status.connecting': 'Connecting...', + 'header.status.connected': 'Connected', + 'header.status.disconnected': 'Disconnected', + 'header.logout': 'Logout', + 'header.refresh': 'Reload', + + // Navigation + 'nav.main': 'Main Navigation', + 'nav.dashboard': 'Dashboard', + 'nav.config': 'Configuration', + 'nav.providers': 'Provider Pools', + 'nav.upload': 'Upload Config', + 'nav.usage': 'Usage Query', + 'nav.logs': 'Real-time Logs', + + // Dashboard + 'dashboard.title': 'System Overview', + 'dashboard.uptime': 'Uptime', + 'dashboard.systemInfo': 'System Information', + 'dashboard.nodeVersion': 'Node.js Version', + 'dashboard.serverTime': 'Server Time', + 'dashboard.memoryUsage': 'Memory Usage', + 'dashboard.routing.title': 'Path Routing Examples', + 'dashboard.routing.description': 'Access different AI model providers through different path routes, supporting flexible model switching', + 'dashboard.routing.oauth': 'Limit Breakthrough', + 'dashboard.routing.official': 'Official/Third-party API', + 'dashboard.routing.experimental': 'Limit Breakthrough/Experimental', + 'dashboard.routing.free': 'Limit Breakthrough/Free', + 'dashboard.routing.endpoint': 'Endpoint Path:', + 'dashboard.routing.example': 'Usage Example', + 'dashboard.routing.exampleOpenAI': 'Usage Example (OpenAI):', + 'dashboard.routing.exampleClaude': 'Usage Example (Claude):', + 'dashboard.routing.openai': 'OpenAI Protocol', + 'dashboard.routing.claude': 'Claude Protocol', + 'dashboard.routing.tips': 'Usage Tips', + 'dashboard.routing.tip1': 'Instant Switch: Switch between different AI model providers by modifying the URL path', + 'dashboard.routing.tip2': 'Client Configuration: Set API endpoint to corresponding path in clients like Cherry-Studio, NextChat, Cline', + 'dashboard.routing.tip3': 'Cross-protocol Calls: Support calling Claude models with OpenAI protocol, or OpenAI models with Claude protocol', + 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', + 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', + 'dashboard.routing.nodeName.claude': 'Claude Custom', + 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', + 'dashboard.routing.nodeName.openai': 'OpenAI Custom', + 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', + 'dashboard.contact.title': 'Contact & Sponsor', + 'dashboard.contact.wechat': 'Scan to Join Group', + 'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication', + 'dashboard.contact.sponsor': 'Scan to Sponsor', + 'dashboard.contact.sponsorDesc': 'Your sponsorship is the driving force for the project\'s continuous development', + + // OAuth + 'oauth.modal.title': 'OAuth Authorization', + 'oauth.modal.provider': 'Provider:', + 'oauth.modal.steps': 'Authorization Steps:', + 'oauth.modal.step1': 'Click the button below to open the authorization page in your browser', + 'oauth.modal.step2.qwen': 'After authorization, the system will automatically fetch the credentials file', + 'oauth.modal.step2.google': 'Log in with your Google account and authorize', + 'oauth.modal.step3': 'Credentials files can be viewed and managed in Upload Config', + 'oauth.modal.step4.qwen': 'Authorization valid for: {min} minutes', + 'oauth.modal.step4.google': 'After authorization, the credentials file will be saved automatically', + 'oauth.modal.urlLabel': 'Auth URL:', + 'oauth.modal.copyTitle': 'Copy Link', + 'oauth.modal.openInBrowser': 'Open in Browser', + 'oauth.manual.title': 'Auto-listener blocked?', + 'oauth.manual.desc': 'If the auth window shows "Cannot access" after redirect, please paste the Full URL from that window\'s address bar below:', + 'oauth.manual.placeholder': 'Paste callback URL (contains code=...)', + 'oauth.manual.submit': 'Submit', + 'oauth.success.msg': 'Authorization link copied to clipboard', + 'oauth.window.blocked': 'Auth window was blocked by the browser, please allow pop-ups', + 'oauth.window.opened': 'Auth window opened, please complete the process there', + 'oauth.processing': 'Completing authorization...', + 'oauth.invalid.url': 'This URL does not seem to contain a valid auth code', + 'oauth.error.format': 'Invalid URL format', + + // Config + 'config.title': 'Configuration Management', + 'config.apiKey': 'API Key', + 'config.apiKeyPlaceholder': 'Please enter API key', + 'config.host': 'Listen Address', + 'config.port': 'Port', + 'config.modelProvider': 'Model Provider', + 'config.optional': '(Optional)', + 'config.gemini.baseUrl': 'Gemini Base URL', + 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', + 'config.gemini.projectId': 'Project ID', + 'config.gemini.projectIdPlaceholder': 'Google Cloud Project ID', + 'config.gemini.oauthCreds': 'OAuth Credentials', + 'config.gemini.credsType.file': 'File Path', + 'config.gemini.credsType.base64': 'Base64 Encoded', + 'config.gemini.credsBase64': 'OAuth Credentials (Base64)', + 'config.gemini.credsBase64Placeholder': 'Please enter Base64 encoded OAuth credentials', + 'config.gemini.credsFilePath': 'OAuth Credentials File Path', + 'config.gemini.credsFilePathPlaceholder': 'e.g.: ~/.gemini/oauth_creds.json', + 'config.antigravity.dailyUrl': 'Daily Base URL', + 'config.antigravity.dailyUrlPlaceholder': 'https://daily-cloudcode-pa.sandbox.googleapis.com', + 'config.antigravity.autopushUrl': 'Autopush Base URL', + 'config.antigravity.autopushUrlPlaceholder': 'https://autopush-cloudcode-pa.sandbox.googleapis.com', + 'config.antigravity.credsFilePath': 'OAuth Credentials File Path', + 'config.antigravity.credsFilePathPlaceholder': 'e.g.: ~/.antigravity/oauth_creds.json', + 'config.antigravity.note': 'Antigravity uses Google OAuth authentication, requires credentials file path', + 'config.openai.apiKey': 'OpenAI API Key', + 'config.openai.apiKeyPlaceholder': 'sk-...', + 'config.openai.baseUrl': 'OpenAI Base URL', + 'config.openai.baseUrlPlaceholder': 'e.g.: https://api.openai.com/v1', + 'config.claude.apiKey': 'Claude API Key', + 'config.claude.apiKeyPlaceholder': 'sk-ant-...', + 'config.claude.baseUrl': 'Claude Base URL', + 'config.claude.baseUrlPlaceholder': 'e.g.: https://api.anthropic.com', + 'config.kiro.baseUrl': 'Base URL', + 'config.kiro.baseUrlPlaceholder': 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse', + 'config.kiro.refreshUrl': 'Refresh URL', + 'config.kiro.refreshUrlPlaceholder': 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', + 'config.kiro.refreshIdcUrl': 'Refresh IDC URL', + 'config.kiro.refreshIdcUrlPlaceholder': 'https://oidc.{{region}}.amazonaws.com/token', + 'config.kiro.credsFilePath': 'OAuth Credentials File Path', + 'config.kiro.credsFilePathPlaceholder': 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json', + 'config.kiro.note': 'When using AWS login method, ensure the authorization file contains clientId and clientSecret fields', + 'config.qwen.baseUrl': 'Qwen Base URL', + 'config.qwen.baseUrlPlaceholder': 'https://portal.qwen.ai/v1', + 'config.qwen.oauthBaseUrl': 'OAuth Base URL', + 'config.qwen.oauthBaseUrlPlaceholder': 'https://chat.qwen.ai', + 'config.qwen.credsFilePath': 'OAuth Credentials File Path', + 'config.qwen.credsFilePathPlaceholder': 'e.g.: ~/.qwen/oauth_creds.json', + 'config.advanced.title': 'Advanced Configuration', + 'config.advanced.systemPromptFile': 'System Prompt File Path', + 'config.advanced.systemPromptFilePlaceholder': 'e.g.: input_system_prompt.txt', + 'config.advanced.systemPromptMode': 'System Prompt Mode', + 'config.advanced.systemPromptMode.append': 'Append', + 'config.advanced.systemPromptMode.overwrite': 'Overwrite', + 'config.advanced.promptLogBaseName': 'Prompt Log Base Name', + 'config.advanced.promptLogBaseNamePlaceholder': 'e.g.: prompt_log', + 'config.advanced.promptLogMode': 'Prompt Log Mode', + 'config.advanced.promptLogMode.none': 'None', + 'config.advanced.promptLogMode.console': 'Console', + 'config.advanced.promptLogMode.file': 'File', + 'config.advanced.maxRetries': 'Max Retries', + 'config.advanced.baseDelay': 'Base Retry Delay (ms)', + 'config.advanced.cronInterval': 'OAuth Token Refresh Interval (minutes)', + 'config.advanced.cronEnabled': 'Enable OAuth Token Auto Refresh (requires restart)', + 'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)', + 'config.advanced.poolFilePathPlaceholder': 'Default: provider_pools.json', + 'config.advanced.poolNote': 'When provider pool is configured, it will be used by default. Falls back to default config if pool config fails', + 'config.advanced.maxErrorCount': 'Provider Max Error Count', + 'config.advanced.maxErrorCountPlaceholder': 'Default: 3', + 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 3', + 'config.advanced.systemPrompt': 'System Prompt', + 'config.advanced.systemPromptPlaceholder': 'Enter system prompt...', + 'config.advanced.adminPassword': 'Admin Password', + 'config.advanced.adminPasswordPlaceholder': 'Set admin password (leave empty to keep unchanged)', + 'config.advanced.adminPasswordNote': 'Used to protect management console access, requires re-login after modification', + 'config.save': 'Save Configuration', + 'config.reset': 'Reset', + 'config.placeholder.nodeName': 'e.g.: My Node 1', + 'config.placeholder.model': 'e.g.: gpt-3.5-turbo', + + // Upload Config + 'upload.title': 'Upload Configuration Management', + 'upload.search': 'Search Config', + 'upload.searchPlaceholder': 'Enter filename', + 'upload.statusFilter': 'Association Status', + 'upload.statusFilter.all': 'All Status', + 'upload.statusFilter.used': 'Associated', + 'upload.statusFilter.unused': 'Not Associated', + 'upload.refresh': 'Refresh', + 'upload.downloadAll': 'Download All (ZIP)', + 'upload.listTitle': 'Configuration File List', + 'upload.count': 'Total {count} config files', + 'upload.usedCount': 'Associated: {count}', + 'upload.unusedCount': 'Not Associated: {count}', + 'upload.batchLink': 'Auto Link OAuth', + 'upload.noConfigs': 'No matching configuration files found', + 'upload.detail.path': 'File Path', + 'upload.detail.size': 'File Size', + 'upload.detail.modified': 'Last Modified', + 'upload.detail.status': 'Status', + 'upload.action.view': 'View', + 'upload.action.delete': 'Delete', + 'upload.usage.title': 'Association Details ({type})', + 'upload.usage.mainConfig': 'Main Config', + 'upload.usage.providerPool': 'Provider Pool', + 'upload.usage.multiple': 'Multiple Purposes', + 'upload.delete.confirmTitle': 'Delete Config File', + 'upload.delete.confirmTitleUsed': 'Delete Associated Config', + 'upload.delete.warningUsedTitle': '⚠️ This config is currently in use', + 'upload.delete.warningUsedDesc': 'Deleting an associated config file may affect system stability. Please ensure you understand the consequences.', + 'upload.delete.warningUnusedTitle': '🗑️ Confirm deletion', + 'upload.delete.warningUnusedDesc': 'This operation will permanently delete the config file and cannot be undone.', + 'upload.delete.fileName': 'File Name:', + 'upload.delete.usageAlertTitle': 'Association Details', + 'upload.delete.usageAlertDesc': 'This file is being used by the system. Deletion may cause:', + 'upload.delete.usageAlertItem1': 'Related AI services to stop working', + 'upload.delete.usageAlertItem2': 'Settings in Config Management to become invalid', + 'upload.delete.usageAlertItem3': 'Provider pool configurations to be lost', + 'upload.delete.usageAlertAdvice': 'Advice: Please remove file references in Config Management before deleting.', + 'upload.delete.forceDelete': 'Force Delete', + 'upload.delete.confirmDelete': 'Confirm Delete', + 'upload.batchLink.confirm': 'Are you sure you want to link {count} config files?\n\n{summary}', + 'upload.refresh.success': 'Refresh successful', + 'upload.action.view.failed': 'View failed', + 'upload.action.delete.failed': 'Delete failed', + 'upload.config.notExist': 'Configuration file does not exist', + 'upload.link.identifying': 'Identifying provider type...', + 'upload.link.failed.identify': 'Unable to identify provider type for the config file', + 'upload.link.processing': 'Linking configuration to {name}...', + 'upload.link.success': 'Configuration linked successfully', + 'upload.link.failed': 'Link failed', + 'upload.batchLink.none': 'No configuration files to link', + 'upload.batchLink.processing': 'Batch linking {count} configurations...', + 'upload.batchLink.success': 'Successfully linked {count} configurations', + 'upload.batchLink.partial': 'Linking completed: {success} succeeded, {fail} failed', + + // Providers + 'providers.title': 'Provider Pool Management', + 'providers.note': 'When provider pool is configured, it will be used by default. Falls back to default config if pool config fails', + 'providers.activeConnections': 'Active Connections', + 'providers.activeProviders': 'Active Providers', + 'providers.healthyProviders': 'Healthy Providers', + 'providers.status.healthy': '{healthy}/{total} Healthy', + 'providers.status.empty': '0/0 Nodes', + 'providers.stat.totalAccounts': 'Total Accounts', + 'providers.stat.healthyAccounts': 'Healthy Accounts', + 'providers.stat.usageCount': 'Usage Count', + 'providers.stat.errorCount': 'Error Count', + 'providers.auth.generate': 'Gen Auth', + + // Modal Provider Manager + 'modal.provider.manage': 'Manage {type} Provider Config', + 'modal.provider.totalAccounts': 'Total Accounts:', + 'modal.provider.healthyAccounts': 'Healthy Accounts:', + 'modal.provider.add': 'Add Provider', + 'modal.provider.resetHealth': 'Reset Health', + 'modal.provider.healthCheck': 'Health Check', + 'modal.provider.resetHealthConfirm': 'Are you sure you want to reset all {type} nodes to healthy status?\n\nThis will clear error counts and timestamps for all nodes.', + 'modal.provider.healthCheckConfirm': 'Are you sure you want to perform a health check on all {type} nodes?\n\nThis will send test requests to each node to verify availability.', + 'modal.provider.deleteConfirm': 'Are you sure you want to delete this provider config? This cannot be undone.', + 'modal.provider.disableConfirm': 'Are you sure you want to disable this provider? It will no longer be selected for use.', + 'modal.provider.enableConfirm': 'Are you sure you want to enable this provider?', + 'modal.provider.edit': 'Edit', + 'modal.provider.delete': 'Delete', + 'modal.provider.save': 'Save', + 'modal.provider.cancel': 'Cancel', + 'modal.provider.status.healthy': 'Normal', + 'modal.provider.status.unhealthy': 'Abnormal', + 'modal.provider.status.disabled': 'Disabled', + 'modal.provider.status.enabled': 'Enabled', + 'modal.provider.lastError': 'Last Error:', + 'modal.provider.lastUsed': 'Last Used:', + 'modal.provider.lastCheck': 'Last Check:', + 'modal.provider.checkModel': 'Check Model:', + 'modal.provider.usageCount': 'Usage Count:', + 'modal.provider.errorCount': 'Error Count:', + 'modal.provider.neverUsed': 'Never Used', + 'modal.provider.neverChecked': 'Never Checked', + 'modal.provider.noModels': 'No models available for this provider type', + 'modal.provider.loadingModels': 'Loading models...', + 'modal.provider.unsupportedModels': 'Unsupported Models', + 'modal.provider.unsupportedModelsHelp': 'Select models not supported by this provider; they will be excluded automatically', + 'modal.provider.addTitle': 'Add New Provider Config', + 'modal.provider.customName': 'Custom Name', + 'modal.provider.checkModelName': 'Check Model Name', + 'modal.provider.healthCheckLabel': 'Health Check', + 'modal.provider.enabled': 'Enabled', + 'modal.provider.disabled': 'Disabled', + 'modal.provider.noProviderType': 'Unsupported provider type', + + 'modal.provider.load.failed': 'Failed to load provider details', + 'modal.provider.auth.initializing': 'Initializing credential generation...', + 'modal.provider.auth.success': 'Credentials generated and path auto-filled', + 'modal.provider.auth.window': 'Please complete authorization in the opened window', + 'modal.provider.auth.failed': 'Failed to initialize credential generation', + 'modal.provider.save.success': 'Save successful', + 'modal.provider.save.failed': 'Save failed', + 'modal.provider.delete.success': 'Delete successful', + 'modal.provider.delete.failed': 'Delete failed', + 'modal.provider.add.success': 'Add successful', + 'modal.provider.add.failed': 'Add failed', + 'modal.provider.resetHealth.success': 'Successfully reset health status for {count} nodes', + 'modal.provider.resetHealth.failed': 'Failed to reset health status', + + // Pagination + 'pagination.showing': 'Showing {start}-{end} of {total}', + 'pagination.jumpTo': 'Jump to', + 'pagination.page': 'Page', + + // Usage + 'usage.title': 'Usage Query', + 'usage.refresh': 'Refresh Usage', + 'usage.lastUpdate': 'Last Update: {time}', + 'usage.lastUpdateCache': 'Cache Time: {time}', + 'usage.loading': 'Loading usage data...', + 'usage.empty': 'Click "Refresh Usage" button to get authorization file usage information', + 'usage.noData': 'No usage data available', + 'usage.noInstances': 'No initialized service instances', + 'usage.group.instances': '{count} instances', + 'usage.group.success': '{count}/{total} Success', + 'usage.card.status.disabled': 'Disabled', + 'usage.card.status.healthy': 'Healthy', + 'usage.card.status.unhealthy': 'Abnormal', + 'usage.card.totalUsage': 'Total Usage', + 'usage.card.freeTrial': 'Free Trial', + 'usage.card.bonus': 'Bonus', + 'usage.card.expires': 'Expires: {time}', + + // Logs + 'logs.title': 'Real-time Logs', + 'logs.clear': 'Clear Logs', + 'logs.autoScroll': 'Auto Scroll', + 'logs.autoScroll.on': 'Auto Scroll: On', + 'logs.autoScroll.off': 'Auto Scroll: Off', + + // Common + 'common.togglePassword': 'Show/Hide Password', + 'common.confirm': 'Confirm', + 'common.cancel': 'Cancel', + 'common.success': 'Success', + 'common.error': 'Error', + 'common.warning': 'Warning', + 'common.info': 'Info', + 'common.loading': 'Loading...', + 'common.upload': 'Upload', + 'common.generate': 'Generate', + 'common.search': 'Search', + 'common.welcome': 'Welcome to AIClient2API Management Console!', + 'common.fileType': 'Unsupported file type. Please select JSON, TXT, KEY, PEM, P12, or PFX.', + 'common.fileSize': 'File size cannot exceed 5MB.', + 'common.uploadSuccess': 'File uploaded successfully', + 'common.uploadFailed': 'File upload failed', + 'common.passwordUpdated': 'Admin password updated, takes effect next login', + 'common.configSaved': 'Configuration saved', + 'common.providerPoolRefreshed': 'Provider pool data refreshed', + 'common.copy.success': 'Content copied to clipboard', + 'common.copy.failed': 'Copy failed, please copy manually', + 'common.refresh.success': 'Refresh successful', + 'common.refresh.failed': 'Refresh failed', + + // Login + 'login.title': 'Login - AIClient2API', + 'login.heading': 'Please login to continue', + 'login.password': 'Password', + 'login.passwordPlaceholder': 'Please enter password', + 'login.error.empty': 'Please enter password', + 'login.error.incorrect': 'Incorrect password, please try again', + 'login.error.failed': 'Login failed, please check your network connection', + 'login.button': 'Login', + 'login.loggingIn': 'Logging in...', + } +}; + +// 当前语言 +let currentLanguage = localStorage.getItem('language') || 'zh-CN'; + +// 获取翻译文本 +export function t(key, params = {}) { + let text = translations[currentLanguage]?.[key] || translations['zh-CN']?.[key] || key; + + // 替换参数 + Object.keys(params).forEach(param => { + text = text.replace(`{${param}}`, params[param]); + }); + + return text; +} + +// 切换语言 +export function setLanguage(lang) { + if (translations[lang]) { + currentLanguage = lang; + localStorage.setItem('language', lang); + updatePageLanguage(); + // 触发语言切换事件 + window.dispatchEvent(new CustomEvent('languageChanged', { detail: { language: lang } })); + } +} + +// 获取当前语言 +export function getCurrentLanguage() { + return currentLanguage; +} + +// 更新页面语言 +function updatePageLanguage() { + // 更新 HTML lang 属性 + document.documentElement.lang = currentLanguage; + + // 更新所有带 data-i18n 或 data-i18n-xxx 属性的元素 + document.querySelectorAll('[data-i18n], [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]').forEach(element => { + // 1. 处理属性翻译 (placeholder, title, aria-label) + const attributes = ['placeholder', 'title', 'aria-label']; + attributes.forEach(attr => { + const attrKey = element.getAttribute(`data-i18n-${attr}`); + if (attrKey) { + const params = element.getAttribute(`data-i18n-${attr}-params`); + const parsedParams = params ? JSON.parse(params) : {}; + if (attr === 'aria-label') { + element.setAttribute('aria-label', t(attrKey, parsedParams)); + } else { + element[attr] = t(attrKey, parsedParams); + } + } + }); + + // 2. 处理主文本翻译 (data-i18n) + const key = element.getAttribute('data-i18n'); + if (key) { + const params = element.getAttribute('data-i18n-params'); + const parsedParams = params ? JSON.parse(params) : {}; + + if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { + // 如果没有显式的 data-i18n-placeholder,则 data-i18n 作用于 placeholder + if (!element.hasAttribute('data-i18n-placeholder')) { + element.placeholder = t(key, parsedParams); + } + } else { + element.textContent = t(key, parsedParams); + } + } + }); + + // 更新所有带 data-i18n-html 属性的元素(支持 HTML 内容) + document.querySelectorAll('[data-i18n-html]').forEach(element => { + const key = element.getAttribute('data-i18n-html'); + const params = element.getAttribute('data-i18n-params'); + const parsedParams = params ? JSON.parse(params) : {}; + element.innerHTML = t(key, parsedParams); + }); +} + +// 初始化多语言 +export function initI18n() { + // 设置初始语言 + updatePageLanguage(); + + // 监听 DOM 变化,自动翻译新添加的元素 + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === 1) { // 元素节点 + // 翻译新添加的元素 + if (node.hasAttribute('data-i18n')) { + const key = node.getAttribute('data-i18n'); + const params = node.getAttribute('data-i18n-params'); + const parsedParams = params ? JSON.parse(params) : {}; + + if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') { + if (node.placeholder !== undefined) { + node.placeholder = t(key, parsedParams); + } + } else { + node.textContent = t(key, parsedParams); + } + } + + // 翻译子元素 + node.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + const params = element.getAttribute('data-i18n-params'); + const parsedParams = params ? JSON.parse(params) : {}; + + if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { + if (element.placeholder !== undefined) { + element.placeholder = t(key, parsedParams); + } + } else { + element.textContent = t(key, parsedParams); + } + }); + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); +} + +// 导出所有函数 +export default { + t, + setLanguage, + getCurrentLanguage, + initI18n +}; \ No newline at end of file diff --git a/static/app/language-switcher.js b/static/app/language-switcher.js new file mode 100644 index 0000000..baf3744 --- /dev/null +++ b/static/app/language-switcher.js @@ -0,0 +1,105 @@ +// 语言切换器组件 +import { setLanguage, getCurrentLanguage, t } from './i18n.js'; + +// 创建语言切换器 HTML +export function createLanguageSwitcher() { + const currentLang = getCurrentLanguage(); + + const switcher = document.createElement('div'); + switcher.className = 'language-switcher'; + switcher.innerHTML = ` + +
+ + +
+ `; + + return switcher; +} + +// 初始化语言切换器 +export function initLanguageSwitcher() { + // 创建并添加语言切换器到 header + const headerControls = document.querySelector('.header-controls'); + if (headerControls) { + const switcher = createLanguageSwitcher(); + // 插入到第一个位置 + headerControls.insertBefore(switcher, headerControls.firstChild); + + // 绑定事件 + bindLanguageSwitcherEvents(); + } +} + +// 绑定语言切换器事件 +function bindLanguageSwitcherEvents() { + const languageBtn = document.getElementById('languageBtn'); + const languageDropdown = document.getElementById('languageDropdown'); + const languageOptions = document.querySelectorAll('.language-option'); + + if (!languageBtn || !languageDropdown) return; + + // 切换下拉菜单显示/隐藏 + languageBtn.addEventListener('click', (e) => { + e.stopPropagation(); + languageDropdown.classList.toggle('show'); + }); + + // 点击语言选项 + languageOptions.forEach(option => { + option.addEventListener('click', (e) => { + e.stopPropagation(); + const lang = option.getAttribute('data-lang'); + + // 切换语言 + setLanguage(lang); + + // 更新按钮文本 + const currentLangSpan = languageBtn.querySelector('.current-lang'); + if (currentLangSpan) { + currentLangSpan.textContent = lang === 'zh-CN' ? '中文' : 'EN'; + } + + // 更新选中状态 + languageOptions.forEach(opt => opt.classList.remove('active')); + option.classList.add('active'); + + // 隐藏下拉菜单 + languageDropdown.classList.remove('show'); + + // 显示提示 + showToast(t('common.success'), lang === 'zh-CN' ? '已切换到简体中文' : 'Switched to English', 'success'); + }); + }); + + // 点击页面其他地方关闭下拉菜单 + document.addEventListener('click', () => { + languageDropdown.classList.remove('show'); + }); +} + +// 显示提示消息(使用现有的 toast 系统) +function showToast(title, message, type = 'info') { + // 检查是否有 showToast 函数 + if (typeof window.showToast === 'function') { + window.showToast(title, message, type); + } else { + // 如果没有,使用简单的 alert + console.log(`${title}: ${message}`); + } +} + +export default { + createLanguageSwitcher, + initLanguageSwitcher +}; \ No newline at end of file diff --git a/static/app/modal.js b/static/app/modal.js index a3d1d5f..90217b9 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -2,6 +2,7 @@ import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; import { handleProviderPasswordToggle } from './event-handlers.js'; +import { t } from './i18n.js'; // 分页配置 const PROVIDERS_PER_PAGE = 5; @@ -42,7 +43,7 @@ function showProviderManagerModal(data) { modal.innerHTML = `
-

管理 ${providerType} 提供商配置

+

管理 ${providerType} 提供商配置

@@ -50,21 +51,21 @@ function showProviderManagerModal(data) {
- 总账户数: + 总账户数: ${totalCount}
- 健康账户: + 健康账户: ${healthyCount}
- -
@@ -135,7 +136,7 @@ function renderPagination(page, totalPages, totalItems, position = 'top') { return `
- 显示 ${startItem}-${endItem} / 共 ${totalItems} 条 + 显示 ${startItem}-${endItem} / 共 ${totalItems} 条
- 跳转到 - 跳转到 + - +
`; @@ -250,7 +251,7 @@ async function loadModelsForProviderType(providerType, providers) { providers.forEach(provider => { const container = document.querySelector(`.not-supported-models-container[data-uuid="${provider.uuid}"]`); if (container) { - container.innerHTML = '
加载模型列表失败
'; + container.innerHTML = `
${t('common.error')}: 加载模型列表失败
`; } }); } @@ -351,16 +352,16 @@ function renderProviderList(providers) { return providers.map(provider => { const isHealthy = provider.isHealthy; const isDisabled = provider.isDisabled || false; - const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用'; - const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : '从未检测'; + const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : t('modal.provider.neverUsed'); + const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : t('modal.provider.neverChecked'); const lastHealthCheckModel = provider.lastHealthCheckModel || '-'; const healthClass = isHealthy ? 'healthy' : 'unhealthy'; const disabledClass = isDisabled ? 'disabled' : ''; const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning'; - const healthText = isHealthy ? '正常' : '异常'; - const disabledText = isDisabled ? '已禁用' : '已启用'; + const healthText = isHealthy ? t('modal.provider.status.healthy') : t('modal.provider.status.unhealthy'); + const disabledText = isDisabled ? t('modal.provider.status.disabled') : t('modal.provider.status.enabled'); const disabledIcon = isDisabled ? 'fas fa-ban text-muted' : 'fas fa-play text-success'; - const toggleButtonText = isDisabled ? '启用' : '禁用'; + const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; @@ -371,7 +372,7 @@ function renderProviderList(providers) { errorInfoHtml = `
- 最后错误: + 最后错误: ${escapedErrorMsg}
`; @@ -385,24 +386,24 @@ function renderProviderList(providers) {
- 健康状态: ${healthText} + 健康状态: ${healthText} | - 状态: ${disabledText} + 状态: ${disabledText} | - 使用次数: ${provider.usageCount || 0} | - 失败次数: ${provider.errorCount || 0} | - 最后使用: ${lastUsed} + 使用次数: ${provider.usageCount || 0} | + 失败次数: ${provider.errorCount || 0} | + 最后使用: ${lastUsed}
- 最后检测: ${lastHealthCheckTime} + 最后检测: ${lastHealthCheckTime} | - 检测模型: ${lastHealthCheckModel} + 检测模型: ${lastHealthCheckModel}
${errorInfoHtml} @@ -412,10 +413,10 @@ function renderProviderList(providers) { ${toggleButtonText}
@@ -478,8 +479,8 @@ function renderProviderConfig(provider) { data-config-key="${fieldKey}" data-config-value="${actualValue}" disabled> - - + +
`; @@ -633,12 +634,12 @@ function renderProviderConfig(provider) { html += `
- 加载模型列表... + 加载模型列表...
@@ -790,10 +791,10 @@ function editProvider(uuid, event) { ${toggleButtonText} `; }, 100); @@ -857,10 +858,10 @@ function cancelEdit(uuid, event) { ${toggleButtonText} `; } @@ -900,12 +901,12 @@ async function saveProvider(uuid, event) { try { await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig }); await window.apiClient.post('/reload-config'); - showToast('提供商配置更新成功', 'success'); + showToast(t('common.success'), t('modal.provider.save.success'), 'success'); // 重新获取该提供商类型的最新配置 await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to update provider:', error); - showToast('更新失败: ' + error.message, 'error'); + showToast(t('common.error'), t('modal.provider.save.failed') + ': ' + error.message, 'error'); } } @@ -917,7 +918,7 @@ async function saveProvider(uuid, event) { async function deleteProvider(uuid, event) { event.stopPropagation(); - if (!confirm('确定要删除这个提供商配置吗?此操作不可恢复。')) { + if (!confirm(t('modal.provider.deleteConfirm'))) { return; } @@ -927,12 +928,12 @@ async function deleteProvider(uuid, event) { try { await window.apiClient.delete(`/providers/${encodeURIComponent(providerType)}/${uuid}`); await window.apiClient.post('/reload-config'); - showToast('提供商配置删除成功', 'success'); + showToast(t('common.success'), t('modal.provider.delete.success'), 'success'); // 重新获取最新配置 await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to delete provider:', error); - showToast('删除失败: ' + error.message, 'error'); + showToast(t('common.error'), t('modal.provider.delete.failed') + ': ' + error.message, 'error'); } } @@ -1031,21 +1032,21 @@ function showAddProviderForm(providerType) { const form = document.createElement('div'); form.className = 'add-provider-form'; form.innerHTML = ` -

添加新提供商配置

+

添加新提供商配置

- - + +
- - + +
- +
@@ -1054,10 +1055,10 @@ function showAddProviderForm(providerType) {
`; @@ -1184,7 +1185,7 @@ function addDynamicConfigFields(form, providerType) { fields += ''; } } else { - fields = '

不支持的提供商类型

'; + fields = `

${t('modal.provider.noProviderType')}

`; } configFields.innerHTML = fields; @@ -1245,7 +1246,7 @@ async function addProvider(providerType) { providerConfig }); await window.apiClient.post('/reload-config'); - showToast('提供商配置添加成功', 'success'); + showToast(t('common.success'), t('modal.provider.add.success'), 'success'); // 移除添加表单 const form = document.querySelector('.add-provider-form'); if (form) { @@ -1255,7 +1256,7 @@ async function addProvider(providerType) { await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to add provider:', error); - showToast('添加失败: ' + error.message, 'error'); + showToast(t('common.error'), t('modal.provider.add.failed') + ': ' + error.message, 'error'); } } @@ -1275,8 +1276,8 @@ async function toggleProviderStatus(uuid, event) { const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); const action = isCurrentlyDisabled ? 'enable' : 'disable'; const confirmMessage = isCurrentlyDisabled ? - `确定要启用这个提供商配置吗?` : - `确定要禁用这个提供商配置吗?禁用后该提供商将不会被选中使用。`; + t('modal.provider.enableConfirm') : + t('modal.provider.disableConfirm'); if (!confirm(confirmMessage)) { return; @@ -1285,12 +1286,12 @@ async function toggleProviderStatus(uuid, event) { try { await window.apiClient.post(`/providers/${encodeURIComponent(providerType)}/${uuid}/${action}`, { action }); await window.apiClient.post('/reload-config'); - showToast(`提供商${isCurrentlyDisabled ? '启用' : '禁用'}成功`, 'success'); + showToast(t('common.success'), t('common.success'), 'success'); // 重新获取该提供商类型的最新配置 await refreshProviderConfig(providerType); } catch (error) { console.error('Failed to toggle provider status:', error); - showToast(`操作失败: ${error.message}`, 'error'); + showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); } } @@ -1299,12 +1300,12 @@ async function toggleProviderStatus(uuid, event) { * @param {string} providerType - 提供商类型 */ async function resetAllProvidersHealth(providerType) { - if (!confirm(`确定要将 ${providerType} 的所有节点重置为健康状态吗?\n\n这将清除所有节点的错误计数和错误时间。`)) { + if (!confirm(t('modal.provider.resetHealthConfirm', {type: providerType}))) { return; } try { - showToast('正在重置健康状态...', 'info'); + showToast(t('common.info'), t('modal.provider.resetHealth') + '...', 'info'); const response = await window.apiClient.post( `/providers/${encodeURIComponent(providerType)}/reset-health`, @@ -1312,7 +1313,7 @@ async function resetAllProvidersHealth(providerType) { ); if (response.success) { - showToast(`成功重置 ${response.resetCount} 个节点的健康状态`, 'success'); + showToast(t('common.success'), t('modal.provider.resetHealth.success', { count: response.resetCount }), 'success'); // 重新加载配置 await window.apiClient.post('/reload-config'); @@ -1320,11 +1321,11 @@ async function resetAllProvidersHealth(providerType) { // 刷新提供商配置显示 await refreshProviderConfig(providerType); } else { - showToast('重置健康状态失败', 'error'); + showToast(t('common.error'), t('modal.provider.resetHealth.failed'), 'error'); } } catch (error) { console.error('重置健康状态失败:', error); - showToast(`重置健康状态失败: ${error.message}`, 'error'); + showToast(t('common.error'), t('modal.provider.resetHealth.failed') + ': ' + error.message, 'error'); } } @@ -1333,12 +1334,12 @@ async function resetAllProvidersHealth(providerType) { * @param {string} providerType - 提供商类型 */ async function performHealthCheck(providerType) { - if (!confirm(`确定要对 ${providerType} 的所有节点执行健康检测吗?\n\n这将向每个节点发送测试请求来验证其可用性。`)) { + if (!confirm(t('modal.provider.healthCheckConfirm', {type: providerType}))) { return; } try { - showToast('正在执行健康检测,请稍候...', 'info'); + showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info'); const response = await window.apiClient.post( `/providers/${encodeURIComponent(providerType)}/health-check`, @@ -1351,11 +1352,11 @@ async function performHealthCheck(providerType) { // 统计跳过的数量(checkHealth 未启用的) const skippedCount = results ? results.filter(r => r.success === null).length : 0; - let message = `健康检测完成: ${successCount} 健康`; + let message = `${t('modal.provider.healthCheck')}完成: ${successCount} 健康`; if (failCount > 0) message += `, ${failCount} 异常`; if (skippedCount > 0) message += `, ${skippedCount} 跳过(未启用)`; - showToast(message, failCount > 0 ? 'warning' : 'success'); + showToast(t('common.info'), message, failCount > 0 ? 'warning' : 'success'); // 重新加载配置 await window.apiClient.post('/reload-config'); @@ -1363,11 +1364,11 @@ async function performHealthCheck(providerType) { // 刷新提供商配置显示 await refreshProviderConfig(providerType); } else { - showToast('健康检测失败', 'error'); + showToast(t('common.error'), t('modal.provider.healthCheck') + ' ' + t('common.error'), 'error'); } } catch (error) { console.error('健康检测失败:', error); - showToast(`健康检测失败: ${error.message}`, 'error'); + showToast(t('common.error'), t('modal.provider.healthCheck') + ' ' + t('common.error') + ': ' + error.message, 'error'); } } @@ -1382,7 +1383,7 @@ function renderNotSupportedModelsSelector(uuid, models, notSupportedModels = []) if (!container) return; if (models.length === 0) { - container.innerHTML = '
该提供商类型暂无可用模型列表
'; + container.innerHTML = `
${t('modal.provider.noModels')}
`; return; } diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 8c0ad37..1ee7618 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -3,6 +3,7 @@ import { providerStats, updateProviderStats } from './constants.js'; import { showToast, formatUptime } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; +import { t, getCurrentLanguage } from './i18n.js'; // 保存初始服务器时间和运行时间 let initialServerTime = null; @@ -57,7 +58,7 @@ function updateTimeDisplay() { // 更新服务器时间 if (serverTimeEl) { const currentServerTime = new Date(initialServerTime.getTime() + elapsedSeconds * 1000); - serverTimeEl.textContent = currentServerTime.toLocaleString('zh-CN', { + serverTimeEl.textContent = currentServerTime.toLocaleString(getCurrentLanguage(), { year: 'numeric', month: '2-digit', day: '2-digit', @@ -170,7 +171,7 @@ function renderProviders(providers) { 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 ? '0/0 节点' : `${healthyCount}/${totalCount} 健康`; + const statusText = isEmptyState ? t('providers.status.empty') : t('providers.status.healthy', { healthy: healthyCount, total: totalCount }); providerDiv.innerHTML = `
@@ -187,19 +188,19 @@ function renderProviders(providers) {
- 总账户 + ${t('providers.stat.totalAccounts')} ${totalCount}
- 健康账户 + ${t('providers.stat.healthyAccounts')} ${healthyCount}
- 使用次数 + ${t('providers.stat.usageCount')} ${usageCount}
- 错误次数 + ${t('providers.stat.errorCount')} ${errorCount}
@@ -310,7 +311,7 @@ async function openProviderManager(providerType) { showProviderManagerModal(data); } catch (error) { console.error('Failed to load provider details:', error); - showToast('加载提供商详情失败', 'error'); + showToast(t('common.error'), t('modal.provider.load.failed'), 'error'); } } @@ -330,7 +331,7 @@ function generateAuthButton(providerType) { return ` `; } @@ -341,7 +342,7 @@ function generateAuthButton(providerType) { */ async function handleGenerateAuthUrl(providerType) { try { - showToast('正在生成授权链接...', 'info'); + showToast(t('common.info'), t('modal.provider.auth.initializing'), 'info'); // 使用 fileUploadHandler 中的 getProviderKey 获取目录名称 const providerDir = fileUploadHandler.getProviderKey(providerType); @@ -358,11 +359,11 @@ async function handleGenerateAuthUrl(providerType) { // 显示授权信息模态框 showAuthModal(response.authUrl, response.authInfo); } else { - showToast('生成授权链接失败', 'error'); + showToast(t('common.error'), t('modal.provider.auth.failed'), 'error'); } } catch (error) { console.error('生成授权链接失败:', error); - showToast(`生成授权链接失败: ${error.message}`, 'error'); + showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error'); } } @@ -378,7 +379,7 @@ function getAuthFilePath(provider) { 'openai-qwen-oauth': '~/.qwen/oauth_creds.json', 'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.json' }; - return authFilePaths[provider] || '未知路径'; + return authFilePaths[provider] || (getCurrentLanguage() === 'en-US' ? 'Unknown Path' : '未知路径'); } /** @@ -398,24 +399,24 @@ function showAuthModal(authUrl, authInfo) { if (authInfo.provider === 'openai-qwen-oauth') { instructionsHtml = `
-

授权步骤:

+

${t('oauth.modal.steps')}

    -
  1. 点击下方按钮在浏览器中打开授权页面
  2. -
  3. 完成授权后,系统会自动获取凭据文件
  4. -
  5. 凭据文件可在上传配置管理中查看和管理
  6. -
  7. 授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟
  8. +
  9. ${t('oauth.modal.step1')}
  10. +
  11. ${t('oauth.modal.step2.qwen')}
  12. +
  13. ${t('oauth.modal.step3')}
  14. +
  15. ${t('oauth.modal.step4.qwen', { min: Math.floor(authInfo.expiresIn / 60) })}
`; } else { instructionsHtml = `
-

授权步骤:

+

${t('oauth.modal.steps')}

    -
  1. 点击下方按钮在浏览器中打开授权页面
  2. -
  3. 使用您的Google账号登录并授权
  4. -
  5. 授权完成后,凭据文件会自动保存
  6. -
  7. 凭据文件可在上传配置管理中查看和管理
  8. +
  9. ${t('oauth.modal.step1')}
  10. +
  11. ${t('oauth.modal.step2.google')}
  12. +
  13. ${t('oauth.modal.step4.google')}
  14. +
  15. ${t('oauth.modal.step3')}
`; @@ -424,18 +425,18 @@ function showAuthModal(authUrl, authInfo) { modal.innerHTML = `
-
文件路径
+
文件路径
${config.path}
-
文件大小
+
文件大小
${formatFileSize(config.size)}
-
最后修改
+
最后修改
${formatDate(config.modified)}
-
关联状态
-
${statusText}
+
关联状态
+
${statusText}
${usageInfoHtml}
@@ -186,17 +187,18 @@ function generateUsageInfoHtml(config) { } const typeLabels = { - 'main_config': '主要配置', - 'provider_pool': '提供商池', - 'multiple': '多种用途' + 'main_config': t('upload.usage.mainConfig'), + 'provider_pool': t('upload.usage.providerPool'), + 'multiple': t('upload.usage.multiple') }; - const typeLabel = typeLabels[usageType] || '未知用途'; + const typeLabel = typeLabels[usageType] || (t('common.info') === 'Info' ? 'Unknown' : '未知用途'); let detailsHtml = ''; usageDetails.forEach(detail => { - const icon = detail.type === '主要配置' ? 'fa-cog' : 'fa-network-wired'; - const usageTypeKey = detail.type === '主要配置' ? 'main_config' : 'provider_pool'; + const isMain = detail.type === '主要配置' || detail.type === 'Main Config'; + const icon = isMain ? 'fa-cog' : 'fa-network-wired'; + const usageTypeKey = isMain ? 'main_config' : 'provider_pool'; detailsHtml += `
@@ -210,7 +212,7 @@ function generateUsageInfoHtml(config) {
- 关联详情 (${typeLabel}) + 关联详情 (${typeLabel})
${detailsHtml} @@ -260,9 +262,18 @@ function updateStats() { const usedEl = document.getElementById('usedConfigCount'); const unusedEl = document.getElementById('unusedConfigCount'); - if (totalEl) totalEl.textContent = `共 ${totalCount} 个配置文件`; - if (usedEl) usedEl.textContent = `已关联: ${usedCount}`; - if (unusedEl) unusedEl.textContent = `未关联: ${unusedCount}`; + if (totalEl) { + totalEl.textContent = t('upload.count', { count: totalCount }); + totalEl.setAttribute('data-i18n-params', JSON.stringify({ count: totalCount.toString() })); + } + if (usedEl) { + usedEl.textContent = t('upload.usedCount', { count: usedCount }); + usedEl.setAttribute('data-i18n-params', JSON.stringify({ count: usedCount.toString() })); + } + if (unusedEl) { + unusedEl.textContent = t('upload.unusedCount', { count: unusedCount }); + unusedEl.setAttribute('data-i18n-params', JSON.stringify({ count: unusedCount.toString() })); + } } /** @@ -285,10 +296,10 @@ async function loadConfigList() { renderConfigList(); updateStats(); console.log('配置列表加载成功,共', allConfigs.length, '个项目'); - // showToast('配置文件列表已刷新', 'success'); + // showToast(t('common.success'), t('upload.refresh') + '成功', 'success'); } catch (error) { console.error('加载配置列表失败:', error); - showToast('加载配置列表失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); // 使用模拟数据作为示例 allConfigs = generateMockConfigData(); @@ -375,7 +386,7 @@ async function viewConfig(path) { showConfigModal(fileData); } catch (error) { console.error('查看配置失败:', error); - showToast('查看配置失败: ' + error.message, 'error'); + showToast(t('common.error'), t('upload.action.view.failed') + ': ' + error.message, 'error'); } } @@ -390,7 +401,7 @@ function showConfigModal(fileData) { modal.innerHTML = `
-

配置文件: ${fileData.name}

+

${t('nav.config')}: ${fileData.name}

@@ -398,27 +409,27 @@ function showConfigModal(fileData) {
- 文件路径: + ${t('upload.detail.path')}: ${fileData.path}
- 文件大小: + ${t('upload.detail.size')}: ${formatFileSize(fileData.size)}
- 最后修改: + ${t('upload.detail.modified')}: ${formatDate(fileData.modified)}
- +
${escapeHtml(fileData.content)}
@@ -477,7 +488,7 @@ async function copyConfigContent(path) { // 尝试使用现代 Clipboard API if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(fileData.content); - showToast('内容已复制到剪贴板', 'success'); + showToast(t('common.success'), t('oauth.success.msg'), 'success'); } else { // 降级方案:使用传统的 document.execCommand const textarea = document.createElement('textarea'); @@ -490,20 +501,20 @@ async function copyConfigContent(path) { try { const successful = document.execCommand('copy'); if (successful) { - showToast('内容已复制到剪贴板', 'success'); + showToast(t('common.copy.success'), 'success'); } else { - showToast('复制失败,请手动复制', 'error'); + showToast(t('common.copy.failed'), 'error'); } } catch (err) { console.error('复制失败:', err); - showToast('复制失败,请手动复制', 'error'); + showToast(t('common.copy.failed'), 'error'); } finally { document.body.removeChild(textarea); } } } catch (error) { console.error('复制失败:', error); - showToast('复制失败: ' + error.message, 'error'); + showToast(t('common.copy.failed') + ': ' + error.message, 'error'); } } @@ -525,7 +536,7 @@ function escapeHtml(text) { function showDeleteConfirmModal(config) { const isUsed = config.isUsed; const modalClass = isUsed ? 'delete-confirm-modal used' : 'delete-confirm-modal unused'; - const title = isUsed ? '删除已关联配置' : '删除配置文件'; + const title = isUsed ? t('upload.delete.confirmTitleUsed') : t('upload.delete.confirmTitle'); const icon = isUsed ? 'fas fa-exclamation-triangle' : 'fas fa-trash'; const buttonClass = isUsed ? 'btn btn-danger' : 'btn btn-warning'; @@ -535,7 +546,7 @@ function showDeleteConfirmModal(config) { modal.innerHTML = `
-

${title}

+

${title}

@@ -547,29 +558,29 @@ function showDeleteConfirmModal(config) {
${isUsed ? - '

⚠️ 此配置已被系统使用

删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。

' : - '

🗑️ 确认删除配置文件

此操作将永久删除配置文件,且无法撤销。

' + `

${t('upload.delete.warningUsedTitle')}

${t('upload.delete.warningUsedDesc')}

` : + `

${t('upload.delete.warningUnusedTitle')}

${t('upload.delete.warningUnusedDesc')}

` }
- 文件名: + 文件名: ${config.name}
- 文件路径: + 文件路径: ${config.path}
- 文件大小: + 文件大小: ${formatFileSize(config.size)}
- 关联状态: - - ${isUsed ? '已关联' : '未关联'} + 关联状态: + + ${isUsed ? t('upload.statusFilter.used') : t('upload.statusFilter.unused')}
@@ -580,23 +591,23 @@ function showDeleteConfirmModal(config) {
-
关联详情
-

此配置文件正在被系统使用,删除后可能会导致:

+
${t('upload.delete.usageAlertTitle')}
+

${t('upload.delete.usageAlertDesc')}

    -
  • 相关的AI服务无法正常工作
  • -
  • 配置管理中的设置失效
  • -
  • 提供商池配置丢失
  • +
  • ${t('upload.delete.usageAlertItem1')}
  • +
  • ${t('upload.delete.usageAlertItem2')}
  • +
  • ${t('upload.delete.usageAlertItem3')}
-

建议:请先在配置管理中解除文件引用后再删除。

+

${t('upload.delete.usageAlertAdvice')}

` : ''}
@@ -658,7 +669,7 @@ function showDeleteConfirmModal(config) { async function performDelete(path) { try { const result = await window.apiClient.delete(`/upload-configs/delete/${encodeURIComponent(path)}`); - showToast(result.message, 'success'); + showToast(t('common.success'), result.message, 'success'); // 从本地列表中移除 allConfigs = allConfigs.filter(c => c.path !== path); @@ -667,7 +678,7 @@ async function performDelete(path) { updateStats(); } catch (error) { console.error('删除配置失败:', error); - showToast('删除配置失败: ' + error.message, 'error'); + showToast(t('common.error'), t('upload.action.delete.failed') + ': ' + error.message, 'error'); } } @@ -678,7 +689,7 @@ async function performDelete(path) { async function deleteConfig(path) { const config = filteredConfigs.find(c => c.path === path) || allConfigs.find(c => c.path === path); if (!config) { - showToast('配置文件不存在', 'error'); + showToast(t('common.error'), t('upload.config.notExist'), 'error'); return; } @@ -695,6 +706,7 @@ function initUploadConfigManager() { const searchBtn = document.getElementById('searchConfigBtn'); const statusFilter = document.getElementById('configStatusFilter'); const refreshBtn = document.getElementById('refreshConfigList'); + const downloadAllBtn = document.getElementById('downloadAllConfigs'); if (searchInput) { searchInput.addEventListener('input', debounce(() => { @@ -724,6 +736,10 @@ function initUploadConfigManager() { refreshBtn.addEventListener('click', loadConfigList); } + if (downloadAllBtn) { + downloadAllBtn.addEventListener('click', downloadAllConfigs); + } + // 批量关联配置按钮 const batchLinkBtn = document.getElementById('batchLinkKiroBtn') || document.getElementById('batchLinkProviderBtn'); if (batchLinkBtn) { @@ -746,7 +762,7 @@ async function reloadConfig() { try { const result = await window.apiClient.post('/reload-config'); - showToast(result.message, 'success'); + showToast(t('common.success'), result.message, 'success'); // 重新加载配置列表以反映最新的关联状态 await loadConfigList(); @@ -758,7 +774,7 @@ async function reloadConfig() { } catch (error) { console.error('重载配置失败:', error); - showToast('重载配置失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); } } @@ -822,23 +838,23 @@ async function quickLinkProviderConfig(filePath) { try { const providerInfo = detectProviderFromPath(filePath); if (!providerInfo) { - showToast('无法识别配置文件对应的提供商类型', 'error'); + showToast(t('common.error'), t('upload.link.failed.identify'), 'error'); return; } - showToast(`正在关联配置到 ${providerInfo.displayName}...`, 'info'); + showToast(t('common.info'), t('upload.link.processing', { name: providerInfo.displayName }), 'info'); const result = await window.apiClient.post('/quick-link-provider', { filePath: filePath }); - showToast(result.message || '配置关联成功', 'success'); + showToast(t('common.success'), result.message || t('upload.link.success'), 'success'); // 刷新配置列表 await loadConfigList(); } catch (error) { console.error('一键关联失败:', error); - showToast('关联失败: ' + error.message, 'error'); + showToast(t('common.error'), t('upload.link.failed') + ': ' + error.message, 'error'); } } @@ -854,7 +870,7 @@ async function batchLinkProviderConfigs() { }); if (unlinkedConfigs.length === 0) { - showToast('没有需要关联的配置文件', 'info'); + showToast(t('common.info'), t('upload.batchLink.none'), 'info'); return; } @@ -874,12 +890,12 @@ async function batchLinkProviderConfigs() { .map(([name, count]) => `${name}: ${count}个`) .join(', '); - const confirmMsg = `确定要批量关联 ${unlinkedConfigs.length} 个配置吗?\n\n${providerSummary}`; + const confirmMsg = t('upload.batchLink.confirm', { count: unlinkedConfigs.length, summary: providerSummary }); if (!confirm(confirmMsg)) { return; } - showToast(`正在批量关联 ${unlinkedConfigs.length} 个配置...`, 'info'); + showToast(t('common.info'), t('upload.batchLink.processing', { count: unlinkedConfigs.length }), 'info'); let successCount = 0; let failCount = 0; @@ -900,9 +916,9 @@ async function batchLinkProviderConfigs() { await loadConfigList(); if (failCount === 0) { - showToast(`成功关联 ${successCount} 个配置`, 'success'); + showToast(t('common.success'), t('upload.batchLink.success', { count: successCount }), 'success'); } else { - showToast(`关联完成: 成功 ${successCount} 个, 失败 ${failCount} 个`, 'warning'); + showToast(t('common.warning'), t('upload.batchLink.partial', { success: successCount, fail: failCount }), 'warning'); } } @@ -924,6 +940,53 @@ function debounce(func, wait) { }; } +/** + * 打包下载所有配置文件 + */ +async function downloadAllConfigs() { + try { + showToast(t('common.info'), t('common.loading'), 'info'); + + // 使用 window.apiClient.get 获取 Blob 数据 + // 由于 apiClient 默认可能是处理 JSON 的,我们需要直接调用 fetch 或者确保 apiClient 支持返回原始响应 + const token = localStorage.getItem('authToken'); + const headers = { + 'Authorization': token ? `Bearer ${token}` : '' + }; + + const response = await fetch('/api/upload-configs/download-all', { headers }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || '下载失败'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + + // 从 Content-Disposition 中提取文件名,或者使用默认名 + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = `configs_backup_${new Date().toISOString().slice(0, 10)}.zip`; + if (contentDisposition && contentDisposition.indexOf('filename=') !== -1) { + const matches = /filename="([^"]+)"/.exec(contentDisposition); + if (matches && matches[1]) filename = matches[1]; + } + + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast(t('common.success'), t('common.success'), 'success'); + } catch (error) { + console.error('打包下载失败:', error); + showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); + } +} + // 导出函数 export { initUploadConfigManager, diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index f7d4783..195a58b 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -2,6 +2,7 @@ import { showToast } from './utils.js'; import { getAuthHeaders } from './auth.js'; +import { t, getCurrentLanguage } from './i18n.js'; /** * 初始化用量管理功能 @@ -52,10 +53,15 @@ export async function loadUsage() { // 更新最后更新时间 if (lastUpdateEl) { + const timeStr = new Date(data.timestamp || Date.now()).toLocaleString(getCurrentLanguage()); if (data.fromCache && data.timestamp) { - lastUpdateEl.textContent = `缓存时间: ${new Date(data.timestamp).toLocaleString()}`; + lastUpdateEl.textContent = t('usage.lastUpdateCache', { time: timeStr }); + lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdateCache'); + lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); } else { - lastUpdateEl.textContent = `上次更新: ${new Date().toLocaleString()}`; + lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr }); + lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate'); + lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); } } } catch (error) { @@ -66,7 +72,7 @@ export async function loadUsage() { errorEl.style.display = 'block'; const errorMsgEl = document.getElementById('usageErrorMessage'); if (errorMsgEl) { - errorMsgEl.textContent = error.message || '获取用量数据失败'; + errorMsgEl.textContent = error.message || t('usage.title') + '失败'; } } } @@ -110,10 +116,13 @@ export async function refreshUsage() { // 更新最后更新时间 if (lastUpdateEl) { - lastUpdateEl.textContent = `上次更新: ${new Date().toLocaleString()}`; + const timeStr = new Date().toLocaleString(getCurrentLanguage()); + lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr }); + lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate'); + lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); } - showToast('用量数据已刷新', 'success'); + showToast(t('common.success'), t('common.refresh.success'), 'success'); } catch (error) { console.error('获取用量数据失败:', error); @@ -122,11 +131,11 @@ export async function refreshUsage() { errorEl.style.display = 'block'; const errorMsgEl = document.getElementById('usageErrorMessage'); if (errorMsgEl) { - errorMsgEl.textContent = error.message || '获取用量数据失败'; + errorMsgEl.textContent = error.message || t('usage.title') + '失败'; } } - showToast('获取用量数据失败: ' + error.message, 'error'); + showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); } finally { if (refreshBtn) refreshBtn.disabled = false; } @@ -147,7 +156,7 @@ function renderUsageData(data, container) { container.innerHTML = `
-

暂无用量数据

+

${t('usage.noData')}

`; return; @@ -180,7 +189,7 @@ function renderUsageData(data, container) { container.innerHTML = `
-

暂无已初始化的服务实例

+

${t('usage.noInstances')}

`; return; @@ -216,8 +225,8 @@ function createProviderGroup(providerType, instances) { ${providerDisplayName} - ${instanceCount} 个实例 - ${successCount}/${instanceCount} 成功 + ${t('usage.group.instances', { count: instanceCount })} + ${t('usage.group.success', { count: successCount, total: instanceCount })} `; @@ -268,10 +277,10 @@ function createInstanceUsageCard(instance, providerType) { : ''; const healthBadge = instance.isDisabled - ? '已禁用' + ? `${t('usage.card.status.disabled')}` : (instance.isHealthy - ? '健康' - : '异常'); + ? `${t('usage.card.status.healthy')}` + : `${t('usage.card.status.unhealthy')}`); // 获取用户邮箱和订阅信息 const userEmail = instance.usage?.user?.email || ''; @@ -343,7 +352,7 @@ function renderUsageDetails(usage) { totalSection.innerHTML = `
- 总用量 + ${t('usage.card.totalUsage')} ${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}
@@ -399,9 +408,9 @@ function createUsageBreakdownHTML(breakdown) { if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') { html += `
- 免费试用 + ${t('usage.card.freeTrial')} ${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)} - 到期: ${formatDate(breakdown.freeTrial.expiresAt)} + ${t('usage.card.expires', { time: formatDate(breakdown.freeTrial.expiresAt) })}
`; } @@ -414,7 +423,7 @@ function createUsageBreakdownHTML(breakdown) {
${bonus.displayName || bonus.code} ${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)} - 到期: ${formatDate(bonus.expiresAt)} + ${t('usage.card.expires', { time: formatDate(bonus.expiresAt) })}
`; } @@ -522,7 +531,7 @@ function formatDate(dateStr) { if (!dateStr) return '--'; try { const date = new Date(dateStr); - return date.toLocaleString('zh-CN', { + return date.toLocaleString(getCurrentLanguage(), { year: 'numeric', month: '2-digit', day: '2-digit', diff --git a/static/app/utils.js b/static/app/utils.js index e7846f0..8bd3afe 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -1,4 +1,5 @@ // 工具函数 +import { t, getCurrentLanguage } from './i18n.js'; /** * 格式化运行时间 @@ -10,6 +11,10 @@ function formatUptime(seconds) { const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); + + if (getCurrentLanguage() === 'en-US') { + return `${days}d ${hours}h ${minutes}m ${secs}s`; + } return `${days}天 ${hours}小时 ${minutes}分 ${secs}秒`; } @@ -26,13 +31,22 @@ function escapeHtml(text) { /** * 显示提示消息 + * @param {string} title - 提示标题 (可选,旧接口为 message) * @param {string} message - 提示消息 * @param {string} type - 消息类型 (info, success, error) */ -function showToast(message, type = 'info') { +function showToast(title, message, type = 'info') { + // 兼容旧接口 (message, type) + if (arguments.length === 2 && (message === 'success' || message === 'error' || message === 'info' || message === 'warning')) { + type = message; + message = title; + title = t(`common.${type}`); + } + const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = ` +
${escapeHtml(title)}
${escapeHtml(message)}
`; @@ -53,19 +67,20 @@ function showToast(message, type = 'info') { * @returns {string} 显示文案 */ function getFieldLabel(key) { + const isEn = getCurrentLanguage() === 'en-US'; const labelMap = { - 'customName': '自定义名称 (选填)', - 'checkModelName': '检查模型名称 (选填)', - 'checkHealth': '健康检查', + 'customName': t('modal.provider.customName') + ' ' + t('config.optional'), + 'checkModelName': t('modal.provider.checkModelName') + ' ' + t('config.optional'), + 'checkHealth': t('modal.provider.healthCheckLabel'), 'OPENAI_API_KEY': 'OpenAI API Key', 'OPENAI_BASE_URL': 'OpenAI Base URL', 'CLAUDE_API_KEY': 'Claude API Key', 'CLAUDE_BASE_URL': 'Claude Base URL', - 'PROJECT_ID': '项目ID', - 'GEMINI_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', - 'KIRO_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', - 'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', - 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', + 'PROJECT_ID': isEn ? 'Project ID' : '项目ID', + 'GEMINI_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + 'KIRO_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + 'QWEN_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', 'GEMINI_BASE_URL': 'Gemini Base URL', 'KIRO_BASE_URL': 'Base URL', 'KIRO_REFRESH_URL': 'Refresh URL', @@ -84,6 +99,7 @@ function getFieldLabel(key) { * @returns {Array} 字段配置数组 */ function getProviderTypeFields(providerType) { + const isEn = getCurrentLanguage() === 'en-US'; const fieldConfigs = { 'openai-custom': [ { @@ -130,19 +146,19 @@ function getProviderTypeFields(providerType) { 'gemini-cli-oauth': [ { id: 'PROJECT_ID', - label: '项目ID', + label: isEn ? 'Project ID' : '项目ID', type: 'text', - placeholder: 'Google Cloud项目ID' + placeholder: isEn ? 'Google Cloud Project ID' : 'Google Cloud项目ID' }, { id: 'GEMINI_OAUTH_CREDS_FILE_PATH', - label: 'OAuth凭据文件路径', + label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', type: 'text', - placeholder: '例如: ~/.gemini/oauth_creds.json' + placeholder: isEn ? 'e.g.: ~/.gemini/oauth_creds.json' : '例如: ~/.gemini/oauth_creds.json' }, { id: 'GEMINI_BASE_URL', - label: 'Gemini Base URL (选填)', + label: `Gemini Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://cloudcode-pa.googleapis.com' } @@ -150,25 +166,25 @@ function getProviderTypeFields(providerType) { 'claude-kiro-oauth': [ { id: 'KIRO_OAUTH_CREDS_FILE_PATH', - label: 'OAuth凭据文件路径', + label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', type: 'text', - placeholder: '例如: ~/.aws/sso/cache/kiro-auth-token.json' + placeholder: isEn ? 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json' : '例如: ~/.aws/sso/cache/kiro-auth-token.json' }, { id: 'KIRO_BASE_URL', - label: 'Base URL (选填)', + label: `Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse' }, { id: 'KIRO_REFRESH_URL', - label: 'Refresh URL (选填)', + label: `Refresh URL ${t('config.optional')}`, type: 'text', placeholder: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken' }, { id: 'KIRO_REFRESH_IDC_URL', - label: 'Refresh IDC URL (选填)', + label: `Refresh IDC URL ${t('config.optional')}`, type: 'text', placeholder: 'https://oidc.{{region}}.amazonaws.com/token' } @@ -176,19 +192,19 @@ function getProviderTypeFields(providerType) { 'openai-qwen-oauth': [ { id: 'QWEN_OAUTH_CREDS_FILE_PATH', - label: 'OAuth凭据文件路径', + label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', type: 'text', - placeholder: '例如: ~/.qwen/oauth_creds.json' + placeholder: isEn ? 'e.g.: ~/.qwen/oauth_creds.json' : '例如: ~/.qwen/oauth_creds.json' }, { id: 'QWEN_BASE_URL', - label: 'Qwen Base URL (选填)', + label: `Qwen Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://portal.qwen.ai/v1' }, { id: 'QWEN_OAUTH_BASE_URL', - label: 'OAuth Base URL (选填)', + label: `OAuth Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://chat.qwen.ai' } @@ -196,25 +212,25 @@ function getProviderTypeFields(providerType) { 'gemini-antigravity': [ { id: 'PROJECT_ID', - label: '项目ID (选填)', + label: isEn ? 'Project ID (Optional)' : '项目ID (选填)', type: 'text', - placeholder: 'Google Cloud项目ID (留空自动发现)' + placeholder: isEn ? 'Google Cloud Project ID (Leave blank for discovery)' : 'Google Cloud项目ID (留空自动发现)' }, { id: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - label: 'OAuth凭据文件路径', + label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', type: 'text', - placeholder: '例如: ~/.antigravity/oauth_creds.json' + placeholder: isEn ? 'e.g.: ~/.antigravity/oauth_creds.json' : '例如: ~/.antigravity/oauth_creds.json' }, { id: 'ANTIGRAVITY_BASE_URL_DAILY', - label: 'Daily Base URL (选填)', + label: `Daily Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://daily-cloudcode-pa.sandbox.googleapis.com' }, { id: 'ANTIGRAVITY_BASE_URL_AUTOPUSH', - label: 'Autopush Base URL (选填)', + label: `Autopush Base URL ${t('config.optional')}`, type: 'text', placeholder: 'https://autopush-cloudcode-pa.sandbox.googleapis.com' } diff --git a/static/index.html b/static/index.html index 2f3303d..f14a7fe 100644 --- a/static/index.html +++ b/static/index.html @@ -1,11 +1,11 @@ - + - - AIClient2API - 管理控制台 + + AIClient2API - 管理控制台 @@ -15,17 +15,17 @@
-

AIClient2API 管理控制台

+

AIClient2API 管理控制台

- 连接中... + 连接中... - -
@@ -34,25 +34,25 @@
-