feat(i18n): 添加多语言支持并实现国际化功能

实现中英文双语支持,包括:
1. 添加i18n.js核心模块处理语言切换和翻译
2. 创建语言切换器组件
3. 更新所有UI文本使用翻译键
4. 添加I18N_GUIDE.md文档说明使用方法
5. 修改样式适配语言切换器
6. 添加adm-zip依赖支持配置文件打包下载
7. 更新登录页面支持多语言
8. 重构toast消息显示支持多语言标题
This commit is contained in:
hex2077 2025-12-20 17:27:30 +08:00
parent f3761a4254
commit fa8150701f
20 changed files with 1939 additions and 499 deletions

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 {

157
static/app/I18N_GUIDE.md Normal file
View file

@ -0,0 +1,157 @@
# 多语言支持使用指南
## 概述
本项目已实现中英文双语支持,可以通过页面右上角的语言切换按钮在简体中文和英文之间切换。
## 文件结构
```
static/app/
├── i18n.js # 多语言配置文件(包含所有翻译)
├── language-switcher.js # 语言切换组件
└── I18N_GUIDE.md # 本指南
```
## 如何使用
### 1. 在 HTML 中添加多语言支持
使用 `data-i18n` 属性标记需要翻译的元素:
```html
<!-- 文本内容 -->
<h1 data-i18n="header.title">AIClient2API 管理控制台</h1>
<!-- 按钮文本 -->
<button data-i18n="common.save">保存</button>
<!-- 输入框占位符 -->
<input type="text" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥">
<!-- 带参数的翻译 -->
<span data-i18n="upload.count" data-i18n-params='{"count": "10"}'>共 10 个配置文件</span>
```
### 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
<div class="form-group">
<label data-i18n="config.apiKey">API密钥</label>
<input
type="password"
id="apiKey"
class="form-control"
data-i18n="config.apiKeyPlaceholder"
placeholder="请输入API密钥"
>
</div>
```
对应的翻译配置:
```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');

View file

@ -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(() => {

View file

@ -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');
}
}

View file

@ -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 = `
<i class="fas fa-arrow-down"></i>
自动滚动: ${newAutoScroll ? '开' : '关'}
<span data-i18n="${newAutoScroll ? 'logs.autoScroll.on' : 'logs.autoScroll.off'}">${statusText}</span>
`;
});
}
@ -89,7 +91,7 @@ function initEventListeners() {
elements.toggleAutoScrollBtn.dataset.enabled = false;
elements.toggleAutoScrollBtn.innerHTML = `
<i class="fas fa-arrow-down"></i>
自动滚动:
<span data-i18n="logs.autoScroll.off">${t('logs.autoScroll.off')}</span>
`;
}
}
@ -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');
}
}

View file

@ -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 = '<i class="fas fa-circle"></i> 已连接';
statusBadge.innerHTML = `<i class="fas fa-circle"></i> <span data-i18n="header.status.connected">${t('header.status.connected')}</span>`;
} else {
statusBadge.classList.add('error');
icon.style.color = 'var(--danger-color)';
statusBadge.innerHTML = '<i class="fas fa-circle"></i> 连接断开';
statusBadge.innerHTML = `<i class="fas fa-circle"></i> <span data-i18n="header.status.disconnected">${t('header.status.disconnected')}</span>`;
}
}

View file

@ -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);
}

838
static/app/i18n.js Normal file
View file

@ -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': '如果授权窗口重定向后显示“无法访问”,请将该窗口地址栏的 <strong>完整 URL</strong> 粘贴到下方:',
'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': '<strong>建议:</strong>请先在配置管理中解除文件引用后再删除。',
'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 <strong>Full URL</strong> 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': '<strong>Advice:</strong> 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
};

View file

@ -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 = `
<button class="language-btn" id="languageBtn" aria-label="切换语言">
<i class="fas fa-globe"></i>
<span class="current-lang">${currentLang === 'zh-CN' ? '中文' : 'EN'}</span>
</button>
<div class="language-dropdown" id="languageDropdown">
<button class="language-option ${currentLang === 'zh-CN' ? 'active' : ''}" data-lang="zh-CN">
<i class="fas fa-check"></i>
<span>简体中文</span>
</button>
<button class="language-option ${currentLang === 'en-US' ? 'active' : ''}" data-lang="en-US">
<i class="fas fa-check"></i>
<span>English</span>
</button>
</div>
`;
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
};

View file

@ -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 = `
<div class="provider-modal-content">
<div class="provider-modal-header">
<h3><i class="fas fa-cogs"></i> ${providerType} </h3>
<h3 data-i18n="modal.provider.manage" data-i18n-params='{"type":"${providerType}"}'><i class="fas fa-cogs"></i> ${providerType} </h3>
<button class="modal-close" onclick="window.closeProviderModal(this)">
<i class="fas fa-times"></i>
</button>
@ -50,21 +51,21 @@ function showProviderManagerModal(data) {
<div class="provider-modal-body">
<div class="provider-summary">
<div class="provider-summary-item">
<span class="label">总账户数:</span>
<span class="label" data-i18n="modal.provider.totalAccounts">总账户数:</span>
<span class="value">${totalCount}</span>
</div>
<div class="provider-summary-item">
<span class="label">健康账户:</span>
<span class="label" data-i18n="modal.provider.healthyAccounts">健康账户:</span>
<span class="value">${healthyCount}</span>
</div>
<div class="provider-summary-actions">
<button class="btn btn-success" onclick="window.showAddProviderForm('${providerType}')">
<i class="fas fa-plus"></i>
<i class="fas fa-plus"></i> <span data-i18n="modal.provider.add"></span>
</button>
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" title="将所有节点的健康状态重置为健康">
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" data-i18n="modal.provider.resetHealth" title="将所有节点的健康状态重置为健康">
<i class="fas fa-heartbeat"></i>
</button>
<button class="btn btn-info" onclick="window.performHealthCheck('${providerType}')" title="对所有节点执行健康检测">
<button class="btn btn-info" onclick="window.performHealthCheck('${providerType}')" data-i18n="modal.provider.healthCheck" title="对所有节点执行健康检测">
<i class="fas fa-stethoscope"></i>
</button>
</div>
@ -135,7 +136,7 @@ function renderPagination(page, totalPages, totalItems, position = 'top') {
return `
<div class="pagination-container ${position}" data-position="${position}">
<div class="pagination-info">
显示 ${startItem}-${endItem} / ${totalItems}
<span data-i18n="pagination.showing" data-i18n-params='{"start":"${startItem}","end":"${endItem}","total":"${totalItems}"}'>显示 ${startItem}-${endItem} / ${totalItems} </span>
</div>
<div class="pagination-controls">
<button class="page-btn nav-btn" onclick="window.goToProviderPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
@ -147,11 +148,11 @@ function renderPagination(page, totalPages, totalItems, position = 'top') {
</button>
</div>
<div class="pagination-jump">
<span>跳转到</span>
<input type="number" min="1" max="${totalPages}" value="${page}"
<span data-i18n="pagination.jumpTo">跳转到</span>
<input type="number" min="1" max="${totalPages}" value="${page}"
onkeypress="if(event.key==='Enter')window.goToProviderPage(parseInt(this.value))"
class="page-jump-input">
<span></span>
<span data-i18n="pagination.page"></span>
</div>
</div>
`;
@ -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 = '<div class="error-message">加载模型列表失败</div>';
container.innerHTML = `<div class="error-message">${t('common.error')}: 加载模型列表失败</div>`;
}
});
}
@ -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 = `
<div class="provider-error-info">
<i class="fas fa-exclamation-circle text-danger"></i>
<span class="error-label">最后错误:</span>
<span class="error-label" data-i18n="modal.provider.lastError">最后错误:</span>
<span class="error-message" title="${escapedErrorMsg}">${escapedErrorMsg}</span>
</div>
`;
@ -385,24 +386,24 @@ function renderProviderList(providers) {
<div class="provider-meta">
<span class="health-status">
<i class="${healthIcon}"></i>
健康状态: ${healthText}
<span data-i18n="modal.provider.healthCheckLabel">健康状态</span>: <span data-i18n="${isHealthy ? 'modal.provider.status.healthy' : 'modal.provider.status.unhealthy'}">${healthText}</span>
</span> |
<span class="disabled-status">
<i class="${disabledIcon}"></i>
状态: ${disabledText}
<span data-i18n="upload.detail.status">状态</span>: <span data-i18n="${isDisabled ? 'modal.provider.status.disabled' : 'modal.provider.status.enabled'}">${disabledText}</span>
</span> |
使用次数: ${provider.usageCount || 0} |
失败次数: ${provider.errorCount || 0} |
最后使用: ${lastUsed}
<span data-i18n="modal.provider.usageCount">使用次数</span>: ${provider.usageCount || 0} |
<span data-i18n="modal.provider.errorCount">失败次数</span>: ${provider.errorCount || 0} |
<span data-i18n="modal.provider.lastUsed">最后使用</span>: ${lastUsed}
</div>
<div class="provider-health-meta">
<span class="health-check-time">
<i class="fas fa-clock"></i>
最后检测: ${lastHealthCheckTime}
<span data-i18n="modal.provider.lastCheck">最后检测</span>: ${lastHealthCheckTime}
</span> |
<span class="health-check-model">
<i class="fas fa-cube"></i>
检测模型: ${lastHealthCheckModel}
<span data-i18n="modal.provider.checkModel">检测模型</span>: ${lastHealthCheckModel}
</span>
</div>
${errorInfoHtml}
@ -412,10 +413,10 @@ function renderProviderList(providers) {
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
<i class="fas fa-edit"></i>
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit"></span>
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
<i class="fas fa-trash"></i>
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete"></span>
</button>
</div>
</div>
@ -478,8 +479,8 @@ function renderProviderConfig(provider) {
data-config-key="${fieldKey}"
data-config-value="${actualValue}"
disabled>
<option value="true" ${isEnabled ? 'selected' : ''}>启用</option>
<option value="false" ${!isEnabled ? 'selected' : ''}>禁用</option>
<option value="true" ${isEnabled ? 'selected' : ''} data-i18n="modal.provider.enabled">启用</option>
<option value="false" ${!isEnabled ? 'selected' : ''} data-i18n="modal.provider.disabled">禁用</option>
</select>
</div>
`;
@ -633,12 +634,12 @@ function renderProviderConfig(provider) {
html += `
<div class="config-item not-supported-models-section">
<label>
<i class="fas fa-ban"></i>
<span class="help-text">选择此提供商不支持的模型系统会自动排除这些模型</span>
<i class="fas fa-ban"></i> <span data-i18n="modal.provider.unsupportedModels"></span>
<span class="help-text" data-i18n="modal.provider.unsupportedModelsHelp">选择此提供商不支持的模型系统会自动排除这些模型</span>
</label>
<div class="not-supported-models-container" data-uuid="${provider.uuid}">
<div class="models-loading">
<i class="fas fa-spinner fa-spin"></i> ...
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="modal.provider.loadingModels">...</span>
</div>
</div>
</div>
@ -790,10 +791,10 @@ function editProvider(uuid, event) {
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-save" onclick="window.saveProvider('${uuid}', event)">
<i class="fas fa-save"></i>
<i class="fas fa-save"></i> <span data-i18n="modal.provider.save"></span>
</button>
<button class="btn-small btn-cancel" onclick="window.cancelEdit('${uuid}', event)">
<i class="fas fa-times"></i>
<i class="fas fa-times"></i> <span data-i18n="modal.provider.cancel"></span>
</button>
`;
}, 100);
@ -857,10 +858,10 @@ function cancelEdit(uuid, event) {
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
<i class="fas fa-edit"></i>
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit"></span>
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
<i class="fas fa-trash"></i>
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete"></span>
</button>
`;
}
@ -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 = `
<h4><i class="fas fa-plus"></i> </h4>
<h4 data-i18n="modal.provider.addTitle"><i class="fas fa-plus"></i> </h4>
<div class="form-grid">
<div class="form-group">
<label>自定义名称 <span class="optional-mark">(选填)</span></label>
<input type="text" id="newCustomName" placeholder="例如: 我的节点1">
<label><span data-i18n="modal.provider.customName">自定义名称</span> <span class="optional-mark" data-i18n="config.optional">()</span></label>
<input type="text" id="newCustomName" data-i18n="modal.provider.customName" placeholder="例如: 我的节点1">
</div>
<div class="form-group">
<label>检查模型名称 <span class="optional-mark">(选填)</span></label>
<input type="text" id="newCheckModelName" placeholder="例如: gpt-3.5-turbo">
<label><span data-i18n="modal.provider.checkModelName">检查模型名称</span> <span class="optional-mark" data-i18n="config.optional">()</span></label>
<input type="text" id="newCheckModelName" data-i18n="modal.provider.checkModelName" placeholder="例如: gpt-3.5-turbo">
</div>
<div class="form-group">
<label>健康检查</label>
<label data-i18n="modal.provider.healthCheckLabel">健康检查</label>
<select id="newCheckHealth">
<option value="false">禁用</option>
<option value="true">启用</option>
<option value="false" data-i18n="modal.provider.disabled">禁用</option>
<option value="true" data-i18n="modal.provider.enabled">启用</option>
</select>
</div>
</div>
@ -1054,10 +1055,10 @@ function showAddProviderForm(providerType) {
</div>
<div class="form-actions" style="margin-top: 15px;">
<button class="btn btn-success" onclick="window.addProvider('${providerType}')">
<i class="fas fa-save"></i>
<i class="fas fa-save"></i> <span data-i18n="modal.provider.save"></span>
</button>
<button class="btn btn-secondary" onclick="this.closest('.add-provider-form').remove()">
<i class="fas fa-times"></i>
<i class="fas fa-times"></i> <span data-i18n="modal.provider.cancel"></span>
</button>
</div>
`;
@ -1184,7 +1185,7 @@ function addDynamicConfigFields(form, providerType) {
fields += '</div>';
}
} else {
fields = '<p>不支持的提供商类型</p>';
fields = `<p data-i18n="modal.provider.noProviderType">${t('modal.provider.noProviderType')}</p>`;
}
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 = '<div class="no-models">该提供商类型暂无可用模型列表</div>';
container.innerHTML = `<div class="no-models" data-i18n="modal.provider.noModels">${t('modal.provider.noModels')}</div>`;
return;
}

View file

@ -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 = `
<div class="provider-header">
@ -187,19 +188,19 @@ function renderProviders(providers) {
</div>
<div class="provider-stats">
<div class="provider-stat">
<span class="provider-stat-label">总账户</span>
<span class="provider-stat-label" data-i18n="providers.stat.totalAccounts">${t('providers.stat.totalAccounts')}</span>
<span class="provider-stat-value">${totalCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">健康账户</span>
<span class="provider-stat-label" data-i18n="providers.stat.healthyAccounts">${t('providers.stat.healthyAccounts')}</span>
<span class="provider-stat-value">${healthyCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">使用次数</span>
<span class="provider-stat-label" data-i18n="providers.stat.usageCount">${t('providers.stat.usageCount')}</span>
<span class="provider-stat-value">${usageCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">错误次数</span>
<span class="provider-stat-label" data-i18n="providers.stat.errorCount">${t('providers.stat.errorCount')}</span>
<span class="provider-stat-value">${errorCount}</span>
</div>
</div>
@ -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 `
<button class="generate-auth-btn" title="生成OAuth授权链接">
<i class="fas fa-key"></i>
<span>生成授权</span>
<span data-i18n="providers.auth.generate">${t('providers.auth.generate')}</span>
</button>
`;
}
@ -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 = `
<div class="auth-instructions">
<h4>授权步骤</h4>
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
<ol>
<li>点击下方按钮在浏览器中打开授权页面</li>
<li>完成授权后系统会自动获取凭据文件</li>
<li>凭据文件可在上传配置管理中查看和管理</li>
<li>授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟</li>
<li data-i18n="oauth.modal.step1">${t('oauth.modal.step1')}</li>
<li data-i18n="oauth.modal.step2.qwen">${t('oauth.modal.step2.qwen')}</li>
<li data-i18n="oauth.modal.step3">${t('oauth.modal.step3')}</li>
<li data-i18n="oauth.modal.step4.qwen" data-i18n-params='{"min":"${Math.floor(authInfo.expiresIn / 60)}"}'>${t('oauth.modal.step4.qwen', { min: Math.floor(authInfo.expiresIn / 60) })}</li>
</ol>
</div>
`;
} else {
instructionsHtml = `
<div class="auth-instructions">
<h4>授权步骤</h4>
<h4 data-i18n="oauth.modal.steps">${t('oauth.modal.steps')}</h4>
<ol>
<li>点击下方按钮在浏览器中打开授权页面</li>
<li>使用您的Google账号登录并授权</li>
<li>授权完成后凭据文件会自动保存</li>
<li>凭据文件可在上传配置管理中查看和管理</li>
<li data-i18n="oauth.modal.step1">${t('oauth.modal.step1')}</li>
<li data-i18n="oauth.modal.step2.google">${t('oauth.modal.step2.google')}</li>
<li data-i18n="oauth.modal.step4.google">${t('oauth.modal.step4.google')}</li>
<li data-i18n="oauth.modal.step3">${t('oauth.modal.step3')}</li>
</ol>
</div>
`;
@ -424,18 +425,18 @@ function showAuthModal(authUrl, authInfo) {
modal.innerHTML = `
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3><i class="fas fa-key"></i>OAuth </h3>
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.modal.title">${t('oauth.modal.title')}</span></h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="auth-info">
<p><strong>提供商:</strong> ${authInfo.provider}</p>
<p><strong data-i18n="oauth.modal.provider">${t('oauth.modal.provider')}</strong> ${authInfo.provider}</p>
${instructionsHtml}
<div class="auth-url-section">
<label>授权链接:</label>
<label data-i18n="oauth.modal.urlLabel">${t('oauth.modal.urlLabel')}</label>
<div class="auth-url-container">
<input type="text" readonly value="${authUrl}" class="auth-url-input">
<button class="copy-btn" title="复制链接">
<button class="copy-btn" data-i18n="oauth.modal.copyTitle" title="复制链接">
<i class="fas fa-copy"></i>
</button>
</div>
@ -443,10 +444,10 @@ function showAuthModal(authUrl, authInfo) {
</div>
</div>
<div class="modal-footer">
<button class="modal-cancel">关闭</button>
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="open-auth-btn">
<i class="fas fa-external-link-alt"></i>
在浏览器中打开
<span data-i18n="oauth.modal.openInBrowser">${t('oauth.modal.openInBrowser')}</span>
</button>
</div>
</div>
@ -469,7 +470,7 @@ function showAuthModal(authUrl, authInfo) {
const input = modal.querySelector('.auth-url-input');
input.select();
document.execCommand('copy');
showToast('授权链接已复制到剪贴板', 'success');
showToast(t('common.success'), t('oauth.success.msg'), 'success');
});
// 在浏览器中打开按钮
@ -498,19 +499,19 @@ function showAuthModal(authUrl, authInfo) {
window.addEventListener('oauth_success_event', handleOAuthSuccess);
if (authWindow) {
showToast('已打开授权窗口,请在窗口中完成操作', 'info');
showToast(t('common.info'), t('oauth.window.opened'), 'info');
// 添加手动输入回调 URL 的 UI
const urlSection = modal.querySelector('.auth-url-section');
if (urlSection && !modal.querySelector('.manual-callback-section')) {
const manualInputHtml = `
<div class="manual-callback-section" style="margin-top: 20px; padding: 15px; background: #fffbeb; border: 1px solid #fef3c7; border-radius: 8px;">
<h4 style="color: #92400e; margin-bottom: 8px;"><i class="fas fa-exclamation-circle"></i> </h4>
<p style="font-size: 0.875rem; color: #b45309; margin-bottom: 10px;">如果授权窗口重定向后显示无法访问请将该窗口地址栏的 <strong>完整 URL</strong> </p>
<h4 style="color: #92400e; margin-bottom: 8px;"><i class="fas fa-exclamation-circle"></i> <span data-i18n="oauth.manual.title">${t('oauth.manual.title')}</span></h4>
<p style="font-size: 0.875rem; color: #b45309; margin-bottom: 10px;" data-i18n-html="oauth.manual.desc">${t('oauth.manual.desc')}</p>
<div class="auth-url-container" style="display: flex; gap: 5px;">
<input type="text" class="manual-callback-input" placeholder="粘贴回调 URL (包含 code=...)" style="flex: 1; padding: 8px; border: 1px solid #fcd34d; border-radius: 4px; background: white; color: black;">
<input type="text" class="manual-callback-input" data-i18n="oauth.manual.placeholder" placeholder="粘贴回调 URL (包含 code=...)" style="flex: 1; padding: 8px; border: 1px solid #fcd34d; border-radius: 4px; background: white; color: black;">
<button class="btn btn-success apply-callback-btn" style="padding: 8px 15px; white-space: nowrap; background: #059669; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="fas fa-check"></i>
<i class="fas fa-check"></i> <span data-i18n="oauth.manual.submit">${t('oauth.manual.submit')}</span>
</button>
</div>
</div>
@ -535,7 +536,7 @@ function showAuthModal(authUrl, authInfo) {
localUrl.hostname = window.location.hostname;
localUrl.protocol = window.location.protocol;
showToast('正在完成授权...', 'info');
showToast(t('common.info'), t('oauth.processing'), 'info');
// 优先在子窗口中跳转(如果没关)
if (authWindow && !authWindow.closed) {
@ -547,11 +548,11 @@ function showAuthModal(authUrl, authInfo) {
}
} else {
showToast('该 URL 似乎不包含有效的授权代码', 'warning');
showToast(t('common.warning'), t('oauth.invalid.url'), 'warning');
}
} catch (err) {
console.error('处理回调失败:', err);
showToast('无效的 URL 格式', 'error');
showToast(t('common.error'), t('oauth.error.format'), 'error');
}
};
@ -576,7 +577,7 @@ function showAuthModal(authUrl, authInfo) {
}
}, 1000);
} else {
showToast('授权窗口被浏览器拦截,请允许弹出窗口', 'error');
showToast(t('common.error'), t('oauth.window.blocked'), 'error');
}
});

View file

@ -1,6 +1,7 @@
// 路径路由示例功能模块
import { showToast } from './utils.js';
import { t } from './i18n.js';
/**
* 初始化路径路由示例功能
@ -67,7 +68,7 @@ function initCopyButtons() {
try {
await navigator.clipboard.writeText(path);
showToast(`路径已复制: ${path}`, 'success');
showToast(t('common.success'), `${t('common.success')}: ${path}`, 'success');
// 临时更改按钮图标
const icon = button.querySelector('i');
@ -84,7 +85,7 @@ function initCopyButtons() {
} catch (error) {
console.error('Failed to copy to clipboard:', error);
showToast('复制失败', 'error');
showToast(t('common.error'), t('common.error'), 'error');
}
}
});
@ -124,8 +125,8 @@ function getAvailableRoutes() {
openai: '/claude-custom/v1/chat/completions',
claude: '/claude-custom/v1/messages'
},
description: '官方Claude API',
badge: '官方API',
description: t('dashboard.routing.official'),
badge: t('dashboard.routing.official'),
badgeClass: 'official'
},
{
@ -135,8 +136,8 @@ function getAvailableRoutes() {
openai: '/claude-kiro-oauth/v1/chat/completions',
claude: '/claude-kiro-oauth/v1/messages'
},
description: '免费使用Claude Sonnet 4.5',
badge: '免费使用',
description: t('dashboard.routing.free'),
badge: t('dashboard.routing.free'),
badgeClass: 'oauth'
},
{
@ -146,8 +147,8 @@ function getAvailableRoutes() {
openai: '/openai-custom/v1/chat/completions',
claude: '/openai-custom/v1/messages'
},
description: '官方OpenAI API',
badge: '官方API',
description: t('dashboard.routing.official'),
badge: t('dashboard.routing.official'),
badgeClass: 'official'
},
{
@ -157,8 +158,8 @@ function getAvailableRoutes() {
openai: '/gemini-cli-oauth/v1/chat/completions',
claude: '/gemini-cli-oauth/v1/messages'
},
description: '突破Gemini免费限制',
badge: '突破限制',
description: t('dashboard.routing.oauth'),
badge: t('dashboard.routing.oauth'),
badgeClass: 'oauth'
},
{
@ -169,7 +170,7 @@ function getAvailableRoutes() {
claude: '/openai-qwen-oauth/v1/messages'
},
description: 'Qwen Code Plus',
badge: '代码专用',
badge: t('dashboard.routing.oauth'),
badgeClass: 'oauth'
},
{
@ -180,7 +181,7 @@ function getAvailableRoutes() {
claude: '/openaiResponses-custom/v1/messages'
},
description: '结构化对话API',
badge: '结构化对话',
badge: 'Responses',
badgeClass: 'responses'
}
];
@ -202,7 +203,7 @@ function highlightProviderRoute(provider) {
card.style.boxShadow = '';
}, 3000);
showToast(`已定位到: ${provider}`, 'success');
showToast(t('common.success'), t('common.success') + `: ${provider}`, 'success');
}
}
@ -216,7 +217,7 @@ async function copyCurlExample(provider, options = {}) {
const route = routes.find(r => r.provider === provider);
if (!route) {
showToast('未找到对应的路由', 'error');
showToast(t('common.error'), t('common.error'), 'error');
return;
}
@ -224,7 +225,7 @@ async function copyCurlExample(provider, options = {}) {
const path = route.paths[protocol];
if (!path) {
showToast('未找到对应的协议路径', 'error');
showToast(t('common.error'), t('common.error'), 'error');
return;
}
@ -322,10 +323,10 @@ async function copyCurlExample(provider, options = {}) {
try {
await navigator.clipboard.writeText(curlCommand);
showToast('curl命令已复制到剪贴板', 'success');
showToast(t('common.success'), t('oauth.success.msg'), 'success');
} catch (error) {
console.error('Failed to copy curl command:', error);
showToast('复制失败', 'error');
showToast(t('common.error'), t('common.error'), 'error');
}
}

View file

@ -1495,7 +1495,7 @@ input:checked + .toggle-slider:before {
}
.btn-quick-link {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border: none;
border-radius: 4px;
@ -1513,15 +1513,15 @@ input:checked + .toggle-slider:before {
.btn-quick-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
background: linear-gradient(135deg, #047857 0%, var(--primary-color) 100%);
}
.btn-quick-link i {
font-size: 10px;
}
.btn-batch-link-kiro {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
.btn-batch-link {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border: none;
border-radius: 6px;
@ -1537,13 +1537,13 @@ input:checked + .toggle-slider:before {
font-weight: 500;
}
.btn-batch-link-kiro:hover {
.btn-batch-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
background: linear-gradient(135deg, #047857 0%, var(--primary-color) 100%);
}
.btn-batch-link-kiro i {
.btn-batch-link i {
font-size: 11px;
}
@ -4359,3 +4359,121 @@ input:checked + .toggle-slider:before {
padding: 0.5rem;
}
}
/* ==================== 语言切换器样式 ==================== */
.language-switcher {
position: relative;
display: inline-block;
}
.language-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: var(--transition);
}
.language-btn:hover {
background: var(--bg-tertiary);
color: var(--primary-color);
border-color: var(--primary-color);
transform: translateY(-1px);
}
.language-btn i {
font-size: 1rem;
}
.current-lang {
font-weight: 600;
}
.language-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: var(--shadow-lg);
min-width: 150px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s ease;
z-index: 1000;
}
.language-dropdown.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.language-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary);
transition: var(--transition);
position: relative;
}
.language-option:first-child {
border-radius: 0.5rem 0.5rem 0 0;
}
.language-option:last-child {
border-radius: 0 0 0.5rem 0.5rem;
}
.language-option:hover {
background: var(--bg-secondary);
}
.language-option i {
font-size: 0.875rem;
color: var(--primary-color);
opacity: 0;
transition: opacity 0.3s ease;
}
.language-option.active i {
opacity: 1;
}
.language-option span {
flex: 1;
}
/* 响应式调整 */
@media (max-width: 768px) {
.language-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.language-btn .current-lang {
display: none;
}
.language-dropdown {
right: auto;
left: 0;
}
}

View file

@ -1,6 +1,7 @@
// 上传配置管理功能模块
import { showToast } from './utils.js';
import { t } from './i18n.js';
let allConfigs = []; // 存储所有配置数据
let filteredConfigs = []; // 存储过滤后的配置数据
@ -45,7 +46,7 @@ function renderConfigList() {
container.innerHTML = '';
if (!filteredConfigs.length) {
container.innerHTML = '<div class="no-configs"><p>未找到匹配的配置文件</p></div>';
container.innerHTML = `<div class="no-configs"><p data-i18n="upload.noConfigs">${t('upload.noConfigs')}</p></div>`;
return;
}
@ -69,7 +70,7 @@ function createConfigItemElement(config, index) {
item.dataset.index = index;
const statusIcon = config.isUsed ? 'fa-check-circle' : 'fa-circle';
const statusText = config.isUsed ? '已关联' : '未关联';
const statusText = config.isUsed ? t('upload.statusFilter.used') : t('upload.statusFilter.unused');
const typeIcon = config.type === 'oauth' ? 'fa-key' :
config.type === 'api-key' ? 'fa-lock' :
@ -97,36 +98,36 @@ function createConfigItemElement(config, index) {
<div class="config-item-modified">${formatDate(config.modified)}</div>
<div class="config-item-status">
<i class="fas ${statusIcon}"></i>
${statusText}
<span data-i18n="${config.isUsed ? 'upload.statusFilter.used' : 'upload.statusFilter.unused'}">${statusText}</span>
${quickLinkBtnHtml}
</div>
</div>
<div class="config-item-details">
<div class="config-details-grid">
<div class="config-detail-item">
<div class="config-detail-label">文件路径</div>
<div class="config-detail-label" data-i18n="upload.detail.path">文件路径</div>
<div class="config-detail-value">${config.path}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">文件大小</div>
<div class="config-detail-label" data-i18n="upload.detail.size">文件大小</div>
<div class="config-detail-value">${formatFileSize(config.size)}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">最后修改</div>
<div class="config-detail-label" data-i18n="upload.detail.modified">最后修改</div>
<div class="config-detail-value">${formatDate(config.modified)}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">关联状态</div>
<div class="config-detail-value">${statusText}</div>
<div class="config-detail-label" data-i18n="upload.detail.status">关联状态</div>
<div class="config-detail-value" data-i18n="${config.isUsed ? 'upload.statusFilter.used' : 'upload.statusFilter.unused'}">${statusText}</div>
</div>
</div>
${usageInfoHtml}
<div class="config-item-actions">
<button class="btn-small btn-view" data-path="${config.path}">
<i class="fas fa-eye"></i>
<i class="fas fa-eye"></i> <span data-i18n="upload.action.view">${t('upload.action.view')}</span>
</button>
<button class="btn-small btn-delete-small" data-path="${config.path}">
<i class="fas fa-trash"></i>
<i class="fas fa-trash"></i> <span data-i18n="upload.action.delete">${t('upload.action.delete')}</span>
</button>
</div>
</div>
@ -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 += `
<div class="usage-detail-item" data-usage-type="${usageTypeKey}">
<i class="fas ${icon}"></i>
@ -210,7 +212,7 @@ function generateUsageInfoHtml(config) {
<div class="config-usage-info">
<div class="usage-info-header">
<i class="fas fa-link"></i>
<span class="usage-info-title">关联详情 (${typeLabel})</span>
<span class="usage-info-title" data-i18n="upload.usage.title" data-i18n-params='{"type":"${typeLabel}"}'>关联详情 (${typeLabel})</span>
</div>
<div class="usage-details-list">
${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 = `
<div class="config-modal-content">
<div class="config-modal-header">
<h3>配置文件: ${fileData.name}</h3>
<h3><span data-i18n="nav.config">${t('nav.config')}</span>: ${fileData.name}</h3>
<button class="modal-close">
<i class="fas fa-times"></i>
</button>
@ -398,27 +409,27 @@ function showConfigModal(fileData) {
<div class="config-modal-body">
<div class="config-file-info">
<div class="file-info-item">
<span class="info-label">文件路径:</span>
<span class="info-label" data-i18n="upload.detail.path">${t('upload.detail.path')}:</span>
<span class="info-value">${fileData.path}</span>
</div>
<div class="file-info-item">
<span class="info-label">文件大小:</span>
<span class="info-label" data-i18n="upload.detail.size">${t('upload.detail.size')}:</span>
<span class="info-value">${formatFileSize(fileData.size)}</span>
</div>
<div class="file-info-item">
<span class="info-label">最后修改:</span>
<span class="info-label" data-i18n="upload.detail.modified">${t('upload.detail.modified')}:</span>
<span class="info-value">${formatDate(fileData.modified)}</span>
</div>
</div>
<div class="config-content">
<label>文件内容:</label>
<label data-i18n="common.info">文件内容:</label>
<pre class="config-content-display">${escapeHtml(fileData.content)}</pre>
</div>
</div>
<div class="config-modal-footer">
<button class="btn btn-secondary btn-close-modal">关闭</button>
<button class="btn btn-secondary btn-close-modal" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="btn btn-primary btn-copy-content" data-path="${fileData.path}">
<i class="fas fa-copy"></i>
<i class="fas fa-copy"></i> <span data-i18n="oauth.modal.copyTitle">${t('oauth.modal.copyTitle')}</span>
</button>
</div>
</div>
@ -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 = `
<div class="delete-modal-content">
<div class="delete-modal-header">
<h3><i class="${icon}"></i> ${title}</h3>
<h3 data-i18n="${isUsed ? 'upload.delete.confirmTitleUsed' : 'upload.delete.confirmTitle'}"><i class="${icon}"></i> ${title}</h3>
<button class="modal-close">
<i class="fas fa-times"></i>
</button>
@ -547,29 +558,29 @@ function showDeleteConfirmModal(config) {
</div>
<div class="warning-content">
${isUsed ?
'<h4>⚠️ 此配置已被系统使用</h4><p>删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。</p>' :
'<h4>🗑️ 确认删除配置文件</h4><p>此操作将永久删除配置文件,且无法撤销。</p>'
`<h4 data-i18n="upload.delete.warningUsedTitle">${t('upload.delete.warningUsedTitle')}</h4><p data-i18n="upload.delete.warningUsedDesc">${t('upload.delete.warningUsedDesc')}</p>` :
`<h4 data-i18n="upload.delete.warningUnusedTitle">${t('upload.delete.warningUnusedTitle')}</h4><p data-i18n="upload.delete.warningUnusedDesc">${t('upload.delete.warningUnusedDesc')}</p>`
}
</div>
</div>
<div class="config-info">
<div class="config-info-item">
<span class="info-label">文件名:</span>
<span class="info-label" data-i18n="upload.delete.fileName">文件名:</span>
<span class="info-value">${config.name}</span>
</div>
<div class="config-info-item">
<span class="info-label">文件路径:</span>
<span class="info-label" data-i18n="upload.detail.path">文件路径:</span>
<span class="info-value">${config.path}</span>
</div>
<div class="config-info-item">
<span class="info-label">文件大小:</span>
<span class="info-label" data-i18n="upload.detail.size">文件大小:</span>
<span class="info-value">${formatFileSize(config.size)}</span>
</div>
<div class="config-info-item">
<span class="info-label">关联状态:</span>
<span class="info-value status-${isUsed ? 'used' : 'unused'}">
${isUsed ? '已关联' : '未关联'}
<span class="info-label" data-i18n="upload.detail.status">关联状态:</span>
<span class="info-value status-${isUsed ? 'used' : 'unused'}" data-i18n="${isUsed ? 'upload.statusFilter.used' : 'upload.statusFilter.unused'}">
${isUsed ? t('upload.statusFilter.used') : t('upload.statusFilter.unused')}
</span>
</div>
</div>
@ -580,23 +591,23 @@ function showDeleteConfirmModal(config) {
<i class="fas fa-info-circle"></i>
</div>
<div class="alert-content">
<h5>关联详情</h5>
<p>此配置文件正在被系统使用删除后可能会导致:</p>
<h5 data-i18n="upload.delete.usageAlertTitle">${t('upload.delete.usageAlertTitle')}</h5>
<p data-i18n="upload.delete.usageAlertDesc">${t('upload.delete.usageAlertDesc')}</p>
<ul>
<li>相关的AI服务无法正常工作</li>
<li>配置管理中的设置失效</li>
<li>提供商池配置丢失</li>
<li data-i18n="upload.delete.usageAlertItem1">${t('upload.delete.usageAlertItem1')}</li>
<li data-i18n="upload.delete.usageAlertItem2">${t('upload.delete.usageAlertItem2')}</li>
<li data-i18n="upload.delete.usageAlertItem3">${t('upload.delete.usageAlertItem3')}</li>
</ul>
<p><strong>建议</strong></p>
<p data-i18n-html="upload.delete.usageAlertAdvice">${t('upload.delete.usageAlertAdvice')}</p>
</div>
</div>
` : ''}
</div>
<div class="delete-modal-footer">
<button class="btn btn-secondary btn-cancel-delete">取消</button>
<button class="btn btn-secondary btn-cancel-delete" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="${buttonClass} btn-confirm-delete" data-path="${config.path}">
<i class="fas fa-${isUsed ? 'exclamation-triangle' : 'trash'}"></i>
${isUsed ? '强制删除' : '确认删除'}
<span data-i18n="${isUsed ? 'upload.delete.forceDelete' : 'upload.delete.confirmDelete'}">${isUsed ? t('upload.delete.forceDelete') : t('upload.delete.confirmDelete')}</span>
</button>
</div>
</div>
@ -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,

View file

@ -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 = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p>暂无用量数据</p>
<p data-i18n="usage.noData">${t('usage.noData')}</p>
</div>
`;
return;
@ -180,7 +189,7 @@ function renderUsageData(data, container) {
container.innerHTML = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p>暂无已初始化的服务实例</p>
<p data-i18n="usage.noInstances">${t('usage.noInstances')}</p>
</div>
`;
return;
@ -216,8 +225,8 @@ function createProviderGroup(providerType, instances) {
<i class="fas fa-chevron-right toggle-icon"></i>
<i class="${providerIcon} provider-icon"></i>
<span class="provider-name">${providerDisplayName}</span>
<span class="instance-count">${instanceCount} 个实例</span>
<span class="success-count ${successCount === instanceCount ? 'all-success' : ''}">${successCount}/${instanceCount} 成功</span>
<span class="instance-count" data-i18n="usage.group.instances" data-i18n-params='{"count":"${instanceCount}"}'>${t('usage.group.instances', { count: instanceCount })}</span>
<span class="success-count ${successCount === instanceCount ? 'all-success' : ''}" data-i18n="usage.group.success" data-i18n-params='{"count":"${successCount}","total":"${instanceCount}"}'>${t('usage.group.success', { count: successCount, total: instanceCount })}</span>
</div>
`;
@ -268,10 +277,10 @@ function createInstanceUsageCard(instance, providerType) {
: '<i class="fas fa-times-circle status-error"></i>';
const healthBadge = instance.isDisabled
? '<span class="badge badge-disabled">已禁用</span>'
? `<span class="badge badge-disabled" data-i18n="usage.card.status.disabled">${t('usage.card.status.disabled')}</span>`
: (instance.isHealthy
? '<span class="badge badge-healthy">健康</span>'
: '<span class="badge badge-unhealthy">异常</span>');
? `<span class="badge badge-healthy" data-i18n="usage.card.status.healthy">${t('usage.card.status.healthy')}</span>`
: `<span class="badge badge-unhealthy" data-i18n="usage.card.status.unhealthy">${t('usage.card.status.unhealthy')}</span>`);
// 获取用户邮箱和订阅信息
const userEmail = instance.usage?.user?.email || '';
@ -343,7 +352,7 @@ function renderUsageDetails(usage) {
totalSection.innerHTML = `
<div class="total-usage-header">
<span class="total-label"><i class="fas fa-chart-pie"></i> </span>
<span class="total-label"><i class="fas fa-chart-pie"></i> <span data-i18n="usage.card.totalUsage">${t('usage.card.totalUsage')}</span></span>
<span class="total-value">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
</div>
<div class="progress-bar ${progressClass}">
@ -399,9 +408,9 @@ function createUsageBreakdownHTML(breakdown) {
if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') {
html += `
<div class="extra-usage-info free-trial">
<span class="extra-label"><i class="fas fa-gift"></i> </span>
<span class="extra-label"><i class="fas fa-gift"></i> <span data-i18n="usage.card.freeTrial">${t('usage.card.freeTrial')}</span></span>
<span class="extra-value">${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)}</span>
<span class="extra-expires">到期: ${formatDate(breakdown.freeTrial.expiresAt)}</span>
<span class="extra-expires" data-i18n="usage.card.expires" data-i18n-params='{"time":"${formatDate(breakdown.freeTrial.expiresAt)}"}'>${t('usage.card.expires', { time: formatDate(breakdown.freeTrial.expiresAt) })}</span>
</div>
`;
}
@ -414,7 +423,7 @@ function createUsageBreakdownHTML(breakdown) {
<div class="extra-usage-info bonus">
<span class="extra-label"><i class="fas fa-star"></i> ${bonus.displayName || bonus.code}</span>
<span class="extra-value">${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)}</span>
<span class="extra-expires">到期: ${formatDate(bonus.expiresAt)}</span>
<span class="extra-expires" data-i18n="usage.card.expires" data-i18n-params='{"time":"${formatDate(bonus.expiresAt)}"}'>${t('usage.card.expires', { time: formatDate(bonus.expiresAt) })}</span>
</div>
`;
}
@ -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',

View file

@ -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 = `
<div style="font-weight: 600; margin-bottom: 4px;">${escapeHtml(title)}</div>
<div>${escapeHtml(message)}</div>
`;
@ -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 <span class="optional-tag">(选填)</span>',
label: `Gemini Base URL <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">(选填)</span>',
label: `Base URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse'
},
{
id: 'KIRO_REFRESH_URL',
label: 'Refresh URL <span class="optional-tag">(选填)</span>',
label: `Refresh URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken'
},
{
id: 'KIRO_REFRESH_IDC_URL',
label: 'Refresh IDC URL <span class="optional-tag">(选填)</span>',
label: `Refresh IDC URL <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">(选填)</span>',
label: `Qwen Base URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://portal.qwen.ai/v1'
},
{
id: 'QWEN_OAUTH_BASE_URL',
label: 'OAuth Base URL <span class="optional-tag">(选填)</span>',
label: `OAuth Base URL <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">(选填)</span>',
label: `Daily Base URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://daily-cloudcode-pa.sandbox.googleapis.com'
},
{
id: 'ANTIGRAVITY_BASE_URL_AUTOPUSH',
label: 'Autopush Base URL <span class="optional-tag">(选填)</span>',
label: `Autopush Base URL <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://autopush-cloudcode-pa.sandbox.googleapis.com'
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - AIClient2API</title>
<title data-i18n="login.title">登录 - AIClient2API</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<style>
* {
@ -181,30 +181,34 @@
}
}
</style>
<link rel="stylesheet" href="app/styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div style="position: absolute; top: 20px; right: 20px;" id="langSwitcherContainer"></div>
<div class="login-container">
<div class="logo">
<img src="/favicon.ico" alt="Logo" onerror="this.style.display='none'">
<h1>AIClient2API</h1>
<p>请登录以继续</p>
<p data-i18n="login.heading">请登录以继续</p>
</div>
<form id="loginForm">
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
name="password"
<label for="password" data-i18n="login.password">密码</label>
<input
type="password"
id="password"
name="password"
data-i18n="login.passwordPlaceholder"
placeholder="请输入密码"
autocomplete="current-password"
required
>
<div class="error-message" id="errorMessage">密码错误,请重试</div>
<div class="error-message" id="errorMessage" data-i18n="login.error.incorrect">密码错误,请重试</div>
</div>
<button type="submit" class="login-button" id="loginButton">
<button type="submit" class="login-button" id="loginButton" data-i18n="login.button">
登录
</button>
</form>
@ -214,7 +218,48 @@
</div>
</div>
<script>
<script type="module">
import { initI18n, t } from './app/i18n.js';
import { initLanguageSwitcher } from './app/language-switcher.js';
// 初始化多语言
initI18n();
// 初始化语言切换器
const langContainer = document.getElementById('langSwitcherContainer');
if (langContainer) {
import('./app/language-switcher.js').then(module => {
const switcher = module.createLanguageSwitcher();
langContainer.appendChild(switcher);
// 绑定事件逻辑(由于是动态创建,复用逻辑)
const languageBtn = switcher.querySelector('#languageBtn');
const languageDropdown = switcher.querySelector('#languageDropdown');
const languageOptions = switcher.querySelectorAll('.language-option');
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');
module.setLanguage(lang);
switcher.querySelector('.current-lang').textContent = lang === 'zh-CN' ? '中文' : 'EN';
languageOptions.forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
languageDropdown.classList.remove('show');
});
});
document.addEventListener('click', () => {
languageDropdown.classList.remove('show');
});
});
}
const loginForm = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
const errorMessage = document.getElementById('errorMessage');
@ -229,13 +274,13 @@
const password = passwordInput.value.trim();
if (!password) {
showError('请输入密码');
showError(t('login.error.empty'));
return;
}
// 禁用按钮并显示加载状态
loginButton.disabled = true;
loginButton.innerHTML = '<span class="loading"></span>登录中...';
loginButton.innerHTML = `<span class="loading"></span>${t('login.loggingIn')}`;
errorMessage.classList.remove('show');
try {
@ -259,17 +304,17 @@
// 跳转到主页
window.location.href = '/';
} else {
showError(data.message || '密码错误,请重试');
showError(data.message || t('login.error.incorrect'));
loginButton.disabled = false;
loginButton.innerHTML = '登录';
loginButton.innerHTML = t('login.button');
passwordInput.value = '';
passwordInput.focus();
}
} catch (error) {
console.error('登录错误:', error);
showError('登录失败,请检查网络连接');
showError(t('login.error.failed'));
loginButton.disabled = false;
loginButton.innerHTML = '登录';
loginButton.innerHTML = t('login.button');
}
});