From 5dbb2a360505db02d6185faeaa67e8305f7e1d3b Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 17:16:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B1=D0=BE=D1=80=20=D1=8F=D0=B7?= =?UTF-8?q?=D1=8B=D0=BA=D0=B0=20/=20=E8=AF=AD=E8=A8=80=E9=80=89=E6=8B=A9?= =?UTF-8?q?=20/=20Language=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/I18N.md | 67 ++ index.html | 4 +- package.json | 2 + scripts/check-i18n.mjs | 114 ++ scripts/i18n-runtime.test.mjs | 63 ++ src-tauri/src/i18n/mod.rs | 219 ++++ src-tauri/src/i18n/ru.rs | 87 ++ src-tauri/src/i18n/zh_cn.rs | 85 ++ src-tauri/src/lib.rs | 3 + src-tauri/src/oauth_server.rs | 6 +- src-tauri/src/proxy.rs | 14 +- src-tauri/src/referrals.rs | 31 +- src-tauri/src/tray.rs | 59 +- src/App.css | 137 ++- src/App.tsx | 2 + src/components/AccountList.css | 9 +- src/components/AccountList.tsx | 108 +- src/components/LanguagePicker.tsx | 77 ++ src/components/Settings.css | 54 +- src/components/Settings.tsx | 37 + src/i18n/index.ts | 98 ++ src/i18n/ru.ts | 1755 +++++++++++++++++++++++++++++ src/i18n/runtime.ts | 110 ++ src/main.tsx | 22 +- 24 files changed, 3056 insertions(+), 107 deletions(-) create mode 100644 docs/I18N.md create mode 100644 scripts/check-i18n.mjs create mode 100644 scripts/i18n-runtime.test.mjs create mode 100644 src-tauri/src/i18n/mod.rs create mode 100644 src-tauri/src/i18n/ru.rs create mode 100644 src-tauri/src/i18n/zh_cn.rs create mode 100644 src/components/LanguagePicker.tsx create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/ru.ts create mode 100644 src/i18n/runtime.ts diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 0000000..574d493 --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,67 @@ +# Internationalization + +## 中文 + +### 工作方式 + +- 简体中文是源码语言,也是无法识别系统语言或没有对应翻译时的默认语言。 +- 应用启动时优先读取系统语言;Linux 上如果进程只有 `C/POSIX` 通用语言,则回退到 `/etc/default/locale`,然后在 `src/i18n/index.ts` 的 `appLocales` 中匹配语言前缀。 +- 如果用户没有手动选择语言,应用会根据系统语言自动选择已提供的翻译;未识别或未提供翻译时使用简体中文。顶栏菜单只显示可用语言,用户手动选择的语言保存在本机 `localStorage` 中,并覆盖后续自动检测。 +- `src/i18n/runtime.ts` 负责翻译 React 渲染出的文本、常用可访问性属性、浏览器对话框和通知。 +- Tauri 后端通过 `set_app_locale` 与前端使用相同语言;OAuth 页面、托盘菜单和系统通知放在 `src-tauri/src/i18n/` 中。 + +### 添加新语言 + +1. 复制 `src/i18n/ru.ts` 为新的语言文件,实现并导出一个 `UiLocale`。 +2. 在 `src/i18n/index.ts` 导入该文件,并向 `appLocales` 添加语言代码、系统语言前缀、本地语言名称、旗帜、翻译对象和 `contributors` 中的翻译贡献者。可以为每位贡献者添加可选的个人主页 URL。 +3. 如果该语言需要翻译后端可见文本,复制 `src-tauri/src/i18n/ru.rs`,实现 `BackendLocale`,然后在 `src-tauri/src/i18n/mod.rs` 的 `BACKEND_LOCALES` 中注册。 +4. 优先翻译完整句子。动态消息中的 `{}`、`{name}` 等占位符数量必须与中文源文本一致。 +5. 运行 `npm run check:i18n`、`npm run build` 和 `cargo check --manifest-path src-tauri/Cargo.toml`。 + +`contributors` 会在“关于”中自动生成翻译贡献者列表。该列表仅在当前语言不是源语言时显示。每个新的语言文件还必须翻译列表标题 `翻译` 和说明 `感谢帮助应用支持更多语言的贡献者`。语言的本地名称和贡献者姓名不会再次翻译。 + +不要直接把组件中的中文替换为另一种语言。中文必须继续作为可维护的源码和回退语言。 + +## English + +### How it works + +- Simplified Chinese is the source language and the fallback when the system locale is unknown or no translation is available. +- At startup, the app reads the system locale and matches its prefix against `appLocales` in `src/i18n/index.ts`. On Linux, a generic `C/POSIX` process locale falls back to `/etc/default/locale`. +- When the user has not chosen a language manually, the app detects the system locale and selects an available translation; unknown or unavailable locales fall back to Simplified Chinese. The header menu lists only available languages. A manual choice is stored in `localStorage` and disables later automatic switching. +- `src/i18n/runtime.ts` translates React-rendered text, common accessibility attributes, browser dialogs, and notifications. +- The frontend synchronizes the selected locale with Tauri through `set_app_locale`. OAuth pages, native tray items, and system notifications live in `src-tauri/src/i18n/`. + +### Adding a language + +1. Copy `src/i18n/ru.ts` to a new locale file and export a `UiLocale` implementation. +2. Import it in `src/i18n/index.ts`, then add its locale code, system-language prefixes, native name, flag, translation object, and translation contributors in `contributors` to `appLocales`. Each contributor may have an optional profile URL. +3. If native backend text must be translated, copy `src-tauri/src/i18n/ru.rs`, implement `BackendLocale`, and register it in `BACKEND_LOCALES` in `src-tauri/src/i18n/mod.rs`. +4. Prefer complete messages over fragments. Preserve the number of `{}` and named placeholders from the Chinese source text. +5. Run `npm run check:i18n`, `npm run build`, and `cargo check --manifest-path src-tauri/Cargo.toml`. + +The `contributors` entries automatically populate the translation credits in About. The credits are shown only when the active language differs from the source language. Every new locale file must also translate the credits heading `翻译` and description `感谢帮助应用支持更多语言的贡献者`. Native language names and contributor names are displayed as registered and are not translated again. + +Do not replace Chinese strings directly in components. Chinese must remain the maintainable source and fallback language. + +## Русский + +### Как это работает + +- Упрощённый китайский остаётся исходным языком и включается, если системная локаль не определена или для неё нет перевода. +- При запуске приложение читает системную локаль и сопоставляет её префикс с реестром `appLocales` в `src/i18n/index.ts`. В Linux при общей локали процесса `C/POSIX` используется `/etc/default/locale`. +- Если пользователь ещё не выбирал язык вручную, приложение определяет системную локаль и выбирает доступный перевод; при неизвестной локали или отсутствии перевода используется упрощённый китайский. В меню шапки отображаются только доступные языки. Ручной выбор сохраняется в `localStorage` и отключает дальнейшее автоматическое переключение. +- `src/i18n/runtime.ts` переводит текст, созданный React, основные атрибуты доступности, браузерные диалоги и уведомления. +- Frontend передаёт выбранную локаль Tauri-командой `set_app_locale`. OAuth-страницы, нативное tray-меню и системные уведомления находятся в `src-tauri/src/i18n/`. + +### Как добавить язык + +1. Скопируйте `src/i18n/ru.ts` в файл новой локали и экспортируйте реализацию `UiLocale`. +2. Импортируйте её в `src/i18n/index.ts`, затем добавьте в `appLocales` код локали, префиксы системного языка, самоназвание языка, флаг, объект перевода и авторов перевода в `contributors`. Для каждого автора можно указать необязательную ссылку на профиль. +3. Если нужен перевод видимых строк backend, скопируйте `src-tauri/src/i18n/ru.rs`, реализуйте `BackendLocale` и зарегистрируйте локаль в `BACKEND_LOCALES` из `src-tauri/src/i18n/mod.rs`. +4. Предпочитайте переводы целых сообщений фрагментам. Сохраняйте число `{}` и именованных подстановок из исходной китайской строки. +5. Выполните `npm run check:i18n`, `npm run build` и `cargo check --manifest-path src-tauri/Cargo.toml`. + +Записи `contributors` автоматически формируют список авторов переводов в разделе «О программе». Список показывается, только когда активный язык отличается от исходного. В файле каждой новой локали также нужно перевести заголовок `翻译` и описание `感谢帮助应用支持更多语言的贡献者`. Самоназвания языков и имена участников выводятся как зарегистрированы и повторно не переводятся. + +Не заменяйте китайские строки непосредственно в компонентах. Китайский должен оставаться поддерживаемым исходным и резервным языком. diff --git a/index.html b/index.html index ff93803..0b63a04 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,10 @@ - + - Tauri + React + Typescript + Codex Switcher diff --git a/package.json b/package.json index 6a73513..0d390fd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "npm run check:i18n", "build": "tsc && vite build", + "check:i18n": "node scripts/check-i18n.mjs && node --test scripts/i18n-runtime.test.mjs", "preview": "vite preview", "tauri": "tauri" }, diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..7624eaa --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const sourceRoot = path.resolve('src'); +const localePath = path.join(sourceRoot, 'i18n', 'ru.ts'); +const han = /\p{Script=Han}/u; +const normalize = value => value.replace(/\s+/g, ' ').trim(); + +function sourceFile(file) { + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, kind); +} + +function visit(node, callback) { + callback(node); + ts.forEachChild(node, child => visit(child, callback)); +} + +function sourceFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(file); + return [file]; + }); +} + +const localeSource = sourceFile(localePath); +const translations = new Map(); +const errors = []; + +visit(localeSource, node => { + if (!ts.isVariableDeclaration(node) || node.name.getText(localeSource) !== 'replacements') return; + if (!node.initializer || !ts.isArrayLiteralExpression(node.initializer)) return; + for (const entry of node.initializer.elements) { + if (!ts.isArrayLiteralExpression(entry) || entry.elements.length !== 2) { + errors.push('ru.ts: каталог должен состоять из пар [исходный текст, перевод]'); + continue; + } + const [source, target] = entry.elements; + if (!ts.isStringLiteralLike(source) || !ts.isStringLiteralLike(target)) { + errors.push('ru.ts: исходный текст и перевод должны быть строковыми литералами'); + continue; + } + const key = normalize(source.text); + if (translations.has(key)) { + errors.push(`ru.ts: повтор исходной строки ${JSON.stringify(key)}`); + } else { + translations.set(key, target.text); + } + if (han.test(target.text)) { + errors.push(`ru.ts: в переводе остались иероглифы: ${JSON.stringify(target.text)}`); + } + } +}); + +if (translations.size === 0) errors.push('ru.ts: каталог переводов не найден или пуст'); + +for (const [source, target] of translations) { + const sourcePlaceholders = source.match(/\{[^{}]*\}/g)?.length ?? 0; + const targetPlaceholders = target.match(/\{[^{}]*\}/g)?.length ?? 0; + if (sourcePlaceholders !== targetPlaceholders) { + errors.push(`ru.ts: число подстановок не совпадает: ${JSON.stringify(source)} -> ${JSON.stringify(target)}`); + } +} + +function checkLiteral(file, ast, node, value, kind) { + const text = normalize(value); + if (!text || !han.test(text)) return; + if (translations.has(text)) return; + const { line } = ast.getLineAndCharacterOfPosition(node.getStart(ast)); + errors.push(`${path.relative(process.cwd(), file)}:${line + 1}: нет точного перевода ${kind} ${JSON.stringify(text)}`); +} + +for (const file of sourceFiles(sourceRoot).filter(file => /\.tsx?$/.test(file))) { + if (file.startsWith(path.join(sourceRoot, 'i18n') + path.sep)) continue; + const ast = sourceFile(file); + visit(ast, node => { + if (ts.isJsxText(node)) { + checkLiteral(file, ast, node, node.getText(ast), 'JSX-текста'); + } else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + checkLiteral(file, ast, node, node.text, 'строки'); + } else if (ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) { + checkLiteral(file, ast, node, node.text, 'части шаблона'); + } + }); +} + +const rustRoot = path.resolve('src-tauri', 'src'); +const rustCandidate = /(Err\b|map_err|ok_or|unwrap_or|return\s+"|=>\s*"|Ok\(|format!|\.into\(\)|to_string\(\))/; +const rustLog = /(println!|eprintln!|tracing::|debug!|info!|warn!|trace!)/; + +for (const file of sourceFiles(rustRoot).filter(file => file.endsWith('.rs'))) { + if (file.includes(`${path.sep}i18n${path.sep}`)) continue; + let lines = fs.readFileSync(file, 'utf8').split('\n'); + const tests = lines.findIndex(line => /#\[cfg\(test\)\]/.test(line)); + if (tests >= 0) lines = lines.slice(0, tests); + lines.forEach((line, index) => { + if (/^\s*\/\//.test(line) || rustLog.test(line) || !rustCandidate.test(line)) return; + for (const match of line.matchAll(/"((?:\\.|[^"\\])*)"/g)) { + const text = normalize(match[1].replace(/\\n/g, ' ').replace(/\\"/g, '"')); + if (!han.test(text) || translations.has(text)) continue; + errors.push(`${path.relative(process.cwd(), file)}:${index + 1}: нет перевода возможного сообщения backend ${JSON.stringify(text)}`); + } + }); +} + +if (errors.length > 0) { + console.error(`Проверка локализации не пройдена (${errors.length}):`); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 1; +} else { + console.log(`Проверка локализации пройдена: ${translations.size} уникальных русских строк.`); +} diff --git a/scripts/i18n-runtime.test.mjs b/scripts/i18n-runtime.test.mjs new file mode 100644 index 0000000..dcb1af7 --- /dev/null +++ b/scripts/i18n-runtime.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = fs.readFileSync('src/i18n/ru.ts', 'utf8'); +const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const module = { exports: {} }; +vm.runInNewContext(code, { module, exports: module.exports }); +const { translateRu } = module.exports; + +function transpileModule(file, context = {}) { + const source = fs.readFileSync(file, 'utf8'); + const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const loaded = { exports: {} }; + vm.runInNewContext(code, { + module: loaded, + exports: loaded.exports, + require: name => context[name] ?? {}, + }); + return loaded.exports; +} + +const { resolveAppLocale } = transpileModule('src/i18n/index.ts', { + './ru': { russianLocale: {} }, + './runtime': { installUiLocale() {} }, +}); + +test('system locale selects Russian and unsupported locales fall back to Chinese', () => { + assert.equal(resolveAppLocale('ru'), 'ru'); + assert.equal(resolveAppLocale('ru-RU'), 'ru'); + assert.equal(resolveAppLocale('zh-CN'), 'zh-CN'); + assert.equal(resolveAppLocale('en-US'), 'zh-CN'); + assert.equal(resolveAppLocale(''), 'zh-CN'); + assert.equal(resolveAppLocale('en-US', 'ru'), 'ru'); + assert.equal(resolveAppLocale('ru-RU', 'zh-CN'), 'zh-CN'); + assert.equal(resolveAppLocale('ru-RU', 'unsupported'), 'ru'); + assert.equal(resolveAppLocale('ru-RU', 'auto'), 'ru'); +}); + +test('dynamic backend messages retain values and prefer the most specific template', () => { + const cases = [ + ['模型列表请求失败: timeout', 'Не удалось запросить список моделей: timeout'], + [ + 'Server 不可达(primary=http://a, fallback=http://b)', + 'Server недоступен (основной адрес: http://a, резервный: http://b)', + ], + ['已重置 2 个限额窗口', 'Сброшено окон лимита: 2'], + ]; + for (const [input, expected] of cases) assert.equal(translateRu(input), expected); +}); + +test('complete messages win over fragment replacements', () => { + assert.equal( + translateRu('Fast 模式已开启(2x 额度消耗,更快推理)。重启 Codex 生效。'), + 'Режим Fast включён: ответы быстрее, расход квоты удвоен. Перезапустите Codex для применения', + ); +}); diff --git a/src-tauri/src/i18n/mod.rs b/src-tauri/src/i18n/mod.rs new file mode 100644 index 0000000..9bd331e --- /dev/null +++ b/src-tauri/src/i18n/mod.rs @@ -0,0 +1,219 @@ +mod ru; +#[allow(dead_code)] +mod zh_cn; + +use ru::RussianLocale; +use std::sync::RwLock; +use zh_cn::ChineseLocale; + +pub const APP_NAME: &str = "Codex Switcher"; + +trait BackendLocale: Sync { + fn oauth_success_html(&self) -> &'static str; + fn oauth_failure_response(&self) -> &'static str; + fn tray_tooltip_logged_out(&self) -> String; + fn tray_tooltip_account(&self, name: &str, five_hour_left: f64, weekly_left: f64) -> String; + fn tray_show_main(&self) -> &'static str; + fn tray_next_account(&self) -> &'static str; + fn tray_quit(&self) -> &'static str; + fn notification_account_banned_subtitle(&self) -> &'static str; + fn notification_auto_switch_subtitle(&self) -> &'static str; + fn injected_switch_message(&self, account_name: &str) -> String; + fn referral_unsupported_program(&self) -> &'static str; + fn referral_uncertain_send_suffix(&self) -> &'static str; + fn referral_network_error(&self, error: &reqwest::Error, uncertain: &str) -> String; + fn referral_non_json_response(&self, status: u16, uncertain: &str) -> String; + fn referral_upstream_rejected(&self) -> &'static str; + fn referral_failed_emails(&self, emails: &serde_json::Value) -> String; + fn referral_unrecognized_response(&self, uncertain: &str) -> String; + fn referral_missing_items(&self) -> &'static str; + fn referral_no_available_campaign(&self) -> &'static str; +} + +const RUSSIAN_LOCALE: RussianLocale = RussianLocale; +const CHINESE_LOCALE: ChineseLocale = ChineseLocale; + +struct BackendLocaleDefinition { + code: &'static str, + language_prefixes: &'static [&'static str], + locale: &'static dyn BackendLocale, +} + +static BACKEND_LOCALES: &[BackendLocaleDefinition] = &[ + BackendLocaleDefinition { + code: "zh-CN", + language_prefixes: &["zh"], + locale: &CHINESE_LOCALE, + }, + BackendLocaleDefinition { + code: "ru", + language_prefixes: &["ru"], + locale: &RUSSIAN_LOCALE, + }, +]; + +static ACTIVE_LOCALE: RwLock> = RwLock::new(None); + +fn locale_by_code(code: &str) -> Option<&'static dyn BackendLocale> { + BACKEND_LOCALES + .iter() + .find(|entry| entry.code.eq_ignore_ascii_case(code)) + .map(|entry| entry.locale) +} + +fn active_locale() -> &'static dyn BackendLocale { + if let Ok(active) = ACTIVE_LOCALE.read() { + if let Some(locale) = *active { + return locale; + } + } + let language = std::env::var("LANG").unwrap_or_default().to_lowercase(); + BACKEND_LOCALES + .iter() + .find(|entry| { + entry + .language_prefixes + .iter() + .any(|prefix| language.starts_with(prefix)) + }) + .map(|entry| entry.locale) + .unwrap_or(&CHINESE_LOCALE) +} + +#[tauri::command] +pub fn set_app_locale(app: tauri::AppHandle, locale: String) -> Result<(), String> { + let selected = locale_by_code(&locale) + .ok_or_else(|| format!("Unsupported application locale: {locale}"))?; + *ACTIVE_LOCALE.write().map_err(|error| error.to_string())? = Some(selected); + crate::tray::update_tray_native_menu(&app).map_err(|error| error.to_string())?; + crate::tray::update_tray_menu(&app); + Ok(()) +} + +fn is_generic_locale(locale: &str) -> bool { + matches!( + locale.trim().to_ascii_lowercase().as_str(), + "c" | "c.utf-8" | "posix" + ) +} + +fn locale_from_environment() -> Option { + ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] + .into_iter() + .filter_map(|name| std::env::var(name).ok()) + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty() && !is_generic_locale(value)) +} + +#[cfg(target_os = "linux")] +fn locale_from_system_file() -> Option { + let contents = std::fs::read_to_string("/etc/default/locale").ok()?; + contents.lines().find_map(|line| { + let (name, value) = line.split_once('=')?; + if name.trim() != "LANG" { + return None; + } + let value = value.trim().trim_matches(['"', '\'']); + (!value.is_empty() && !is_generic_locale(value)).then(|| value.to_string()) + }) +} + +#[cfg(target_os = "macos")] +fn locale_from_system_file() -> Option { + let output = std::process::Command::new("defaults") + .args(["read", "-g", "AppleLocale"]) + .output() + .ok()?; + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty() && !is_generic_locale(&value)).then_some(value) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn locale_from_system_file() -> Option { + None +} + +#[tauri::command] +pub fn get_system_locale() -> Option { + locale_from_environment().or_else(locale_from_system_file) +} + +pub fn oauth_success_html() -> &'static str { + active_locale().oauth_success_html() +} + +pub fn oauth_failure_response() -> &'static str { + active_locale().oauth_failure_response() +} + +pub fn tray_tooltip_logged_out() -> String { + active_locale().tray_tooltip_logged_out() +} + +pub fn tray_tooltip_account(name: &str, five_hour_left: f64, weekly_left: f64) -> String { + active_locale().tray_tooltip_account(name, five_hour_left, weekly_left) +} + +pub fn tray_tooltip_default() -> String { + APP_NAME.to_string() +} + +pub fn tray_show_main() -> &'static str { + active_locale().tray_show_main() +} + +pub fn tray_next_account() -> &'static str { + active_locale().tray_next_account() +} + +pub fn tray_quit() -> &'static str { + active_locale().tray_quit() +} + +pub fn notification_account_banned_subtitle() -> &'static str { + active_locale().notification_account_banned_subtitle() +} + +pub fn notification_auto_switch_subtitle() -> &'static str { + active_locale().notification_auto_switch_subtitle() +} + +pub fn injected_switch_message(account_name: &str) -> String { + active_locale().injected_switch_message(account_name) +} + +pub fn referral_unsupported_program() -> &'static str { + active_locale().referral_unsupported_program() +} + +pub fn referral_uncertain_send_suffix() -> &'static str { + active_locale().referral_uncertain_send_suffix() +} + +pub fn referral_network_error(error: &reqwest::Error, uncertain: &str) -> String { + active_locale().referral_network_error(error, uncertain) +} + +pub fn referral_non_json_response(status: u16, uncertain: &str) -> String { + active_locale().referral_non_json_response(status, uncertain) +} + +pub fn referral_upstream_rejected() -> &'static str { + active_locale().referral_upstream_rejected() +} + +pub fn referral_failed_emails(emails: &serde_json::Value) -> String { + active_locale().referral_failed_emails(emails) +} + +pub fn referral_unrecognized_response(uncertain: &str) -> String { + active_locale().referral_unrecognized_response(uncertain) +} + +pub fn referral_missing_items() -> &'static str { + active_locale().referral_missing_items() +} + +pub fn referral_no_available_campaign() -> &'static str { + active_locale().referral_no_available_campaign() +} diff --git a/src-tauri/src/i18n/ru.rs b/src-tauri/src/i18n/ru.rs new file mode 100644 index 0000000..4504e65 --- /dev/null +++ b/src-tauri/src/i18n/ru.rs @@ -0,0 +1,87 @@ +use super::{BackendLocale, APP_NAME}; + +pub struct RussianLocale; + +impl BackendLocale for RussianLocale { + fn oauth_success_html(&self) -> &'static str { + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\ +

Авторизация выполнена

\ +

OpenAI успешно подключён. Можно закрыть это окно и вернуться в приложение.

\ + " + } + + fn oauth_failure_response(&self) -> &'static str { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n\ + Авторизация не выполнена: проверка state не пройдена или отсутствуют параметры" + } + + fn tray_tooltip_logged_out(&self) -> String { + format!("{APP_NAME} - вход не выполнен") + } + + fn tray_tooltip_account(&self, name: &str, five_hour_left: f64, weekly_left: f64) -> String { + format!("{APP_NAME} - {name} | 5ч: {five_hour_left:.0}% неделя: {weekly_left:.0}%") + } + + fn tray_show_main(&self) -> &'static str { + "Открыть главное окно" + } + + fn tray_next_account(&self) -> &'static str { + "Переключиться на следующий аккаунт" + } + + fn tray_quit(&self) -> &'static str { + "Выйти" + } + + fn notification_account_banned_subtitle(&self) -> &'static str { + "Аккаунт заблокирован" + } + + fn notification_auto_switch_subtitle(&self) -> &'static str { + "Автопереключение" + } + + fn injected_switch_message(&self, account_name: &str) -> String { + format!("⚡ [Codex Switcher] Переключено на {account_name}") + } + + fn referral_unsupported_program(&self) -> &'static str { + "Неподдерживаемый тип пригласительной акции" + } + + fn referral_uncertain_send_suffix(&self) -> &'static str { + "; результат отправки не подтверждён. Сначала проверьте историю приглашений и не отправляйте повторно вслепую" + } + + fn referral_network_error(&self, error: &reqwest::Error, uncertain: &str) -> String { + format!("Сетевая ошибка API приглашений: {error}{uncertain}") + } + + fn referral_non_json_response(&self, status: u16, uncertain: &str) -> String { + format!( + "API приглашений вернул ответ HTTP {status} не в формате JSON. Возможно, требуется сеанс входа официального приложения; подтвердить право на участие не удалось{uncertain}" + ) + } + + fn referral_upstream_rejected(&self) -> &'static str { + "Запрос отклонён upstream" + } + + fn referral_failed_emails(&self, emails: &serde_json::Value) -> String { + format!("; email: {emails}") + } + + fn referral_unrecognized_response(&self, uncertain: &str) -> String { + format!("Не удалось распознать ответ API приглашений{uncertain}") + } + + fn referral_missing_items(&self) -> &'static str { + "В истории приглашений отсутствует поле items; подтвердить записи не удалось" + } + + fn referral_no_available_campaign(&self) -> &'static str { + "Для текущего аккаунта нет доступной пригласительной акции; обновите сведения о праве на участие" + } +} diff --git a/src-tauri/src/i18n/zh_cn.rs b/src-tauri/src/i18n/zh_cn.rs new file mode 100644 index 0000000..2799638 --- /dev/null +++ b/src-tauri/src/i18n/zh_cn.rs @@ -0,0 +1,85 @@ +use super::{BackendLocale, APP_NAME}; + +pub struct ChineseLocale; + +impl BackendLocale for ChineseLocale { + fn oauth_success_html(&self) -> &'static str { + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\ +

授权成功

已成功连接 OpenAI,你可以关闭此窗口并回到应用。

\ + " + } + + fn oauth_failure_response(&self) -> &'static str { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n授权失败: State 校验不通过或参数缺失" + } + + fn tray_tooltip_logged_out(&self) -> String { + format!("{APP_NAME} - 未登录") + } + + fn tray_tooltip_account(&self, name: &str, five_hour_left: f64, weekly_left: f64) -> String { + format!("{APP_NAME} - {name} | 5H: {five_hour_left:.0}% 周: {weekly_left:.0}%") + } + + fn tray_show_main(&self) -> &'static str { + "打开主窗口" + } + + fn tray_next_account(&self) -> &'static str { + "切换到下一个账号" + } + + fn tray_quit(&self) -> &'static str { + "退出" + } + + fn notification_account_banned_subtitle(&self) -> &'static str { + "检测到封号" + } + + fn notification_auto_switch_subtitle(&self) -> &'static str { + "自动切号" + } + + fn injected_switch_message(&self, account_name: &str) -> String { + format!("⚡ [Codex Switcher] 已切换到 {account_name}") + } + + fn referral_unsupported_program(&self) -> &'static str { + "不支持的邀请活动类型" + } + + fn referral_uncertain_send_suffix(&self) -> &'static str { + ";发送结果未确认,请先查询邀请记录,勿直接重发" + } + + fn referral_network_error(&self, error: &reqwest::Error, uncertain: &str) -> String { + format!("邀请接口网络错误:{error}{uncertain}") + } + + fn referral_non_json_response(&self, status: u16, uncertain: &str) -> String { + format!( + "邀请接口返回 HTTP {status} 非 JSON 响应,可能需要官方桌面版登录会话;无法确认活动资格{uncertain}" + ) + } + + fn referral_upstream_rejected(&self) -> &'static str { + "请求被上游拒绝" + } + + fn referral_failed_emails(&self, emails: &serde_json::Value) -> String { + format!(";邮箱:{emails}") + } + + fn referral_unrecognized_response(&self, uncertain: &str) -> String { + format!("邀请接口响应格式无法识别{uncertain}") + } + + fn referral_missing_items(&self) -> &'static str { + "邀请记录响应缺少 items,无法确认记录" + } + + fn referral_no_available_campaign(&self) -> &'static str { + "当前账号没有可发送的邀请活动,请刷新资格" + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e3cf5a5..b772f81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod chat_inbound; mod codex_sessions; mod codex_ua; mod deep_link; +mod i18n; mod ide_control; pub mod kimi_quota; pub mod mailbox; @@ -6358,6 +6359,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_accounts, + i18n::set_app_locale, + i18n::get_system_locale, get_current_account_id, import_current_account, switch_account, diff --git a/src-tauri/src/oauth_server.rs b/src-tauri/src/oauth_server.rs index 3d38bcf..a46609b 100644 --- a/src-tauri/src/oauth_server.rs +++ b/src-tauri/src/oauth_server.rs @@ -154,9 +154,7 @@ async fn handle_callback(listener: TcpListener, app_handle: AppHandle, expected_ if let Some(code) = extract_oauth_code_from_request(&request, &expected_state) { // 发送成功 HTML 并通知前端 - let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\ -

授权成功

已成功连接 OpenAI,你可以关闭此窗口并回到应用。

\ - "; + let response = crate::i18n::oauth_success_html(); let _ = socket.write_all(response.as_bytes()).await; if let Err(e) = app_handle.emit("oauth-callback-received", code) { @@ -165,7 +163,7 @@ async fn handle_callback(listener: TcpListener, app_handle: AppHandle, expected_ return; } - let response = "HTTP/1.1 400 Bad Request\r\n\r\n授权失败: State 校验不通过或参数缺失"; + let response = crate::i18n::oauth_failure_response(); let _ = socket.write_all(response.as_bytes()).await; } } diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs index 6a702ee..57327f4 100644 --- a/src-tauri/src/proxy.rs +++ b/src-tauri/src/proxy.rs @@ -2352,8 +2352,10 @@ fn mark_current_banned(state: &ProxyState) { let _ = std::process::Command::new("osascript") .arg("-e") .arg(format!( - "display notification \"{}\" with title \"Codex Switcher\" subtitle \"检测到封号\"", - notify_name + "display notification \"{}\" with title \"{}\" subtitle \"{}\"", + notify_name, + crate::i18n::APP_NAME, + crate::i18n::notification_account_banned_subtitle() )) .output(); }); @@ -2667,8 +2669,10 @@ fn do_switch(state: &ProxyState, new_id: &str, reason: SwitchReason) -> Result<( let _ = std::process::Command::new("osascript") .arg("-e") .arg(format!( - "display notification \"{}\" with title \"Codex Switcher\" subtitle \"自动切号\"", - notify_msg + "display notification \"{}\" with title \"{}\" subtitle \"{}\"", + notify_msg, + crate::i18n::APP_NAME, + crate::i18n::notification_auto_switch_subtitle() )) .output(); }); @@ -2677,7 +2681,7 @@ fn do_switch(state: &ProxyState, new_id: &str, reason: SwitchReason) -> Result<( // 注入 WebSocket 消息标记(可配置,实验性) if inject_enabled { PENDING_INJECT_MSG.lock().ok().map(|mut msg| { - *msg = Some(format!("⚡ [Codex Switcher] 已切换到 {}", to_name)); + *msg = Some(crate::i18n::injected_switch_message(&to_name)); }); } diff --git a/src-tauri/src/referrals.rs b/src-tauri/src/referrals.rs index 23f6732..bb4ff2a 100644 --- a/src-tauri/src/referrals.rs +++ b/src-tauri/src/referrals.rs @@ -8,7 +8,7 @@ fn context(program: &str) -> Result { "codex_referral_consumer" | "codex_referral_workspace" => { Ok(json!({"program_id": program, "entrypoint": "persistent"})) } - _ => Err("不支持的邀请活动类型".into()), + _ => Err(crate::i18n::referral_unsupported_program().into()), } } @@ -36,41 +36,40 @@ fn request( async fn response(req: reqwest::RequestBuilder, sending: bool) -> Result { let uncertain = if sending { - ";发送结果未确认,请先查询邀请记录,勿直接重发" + crate::i18n::referral_uncertain_send_suffix() } else { "" }; let resp = req .send() .await - .map_err(|e| format!("邀请接口网络错误:{e}{uncertain}"))?; + .map_err(|e| crate::i18n::referral_network_error(&e, uncertain))?; let status = resp.status(); - let data = resp.json::().await.map_err(|_| { - format!( - "邀请接口返回 HTTP {} 非 JSON 响应,可能需要官方桌面版登录会话;无法确认活动资格{}", - status.as_u16(), - uncertain - ) - })?; + let data = resp + .json::() + .await + .map_err(|_| crate::i18n::referral_non_json_response(status.as_u16(), uncertain))?; if !status.is_success() { let detail = data.get("detail").unwrap_or(&data); let message = detail .as_str() .or_else(|| detail.get("message").and_then(Value::as_str)) - .unwrap_or("请求被上游拒绝"); + .unwrap_or(crate::i18n::referral_upstream_rejected()); let failed = detail .get("failed_emails") .or_else(|| data.get("failed_emails")); return Err(format!( - "HTTP {}:{}{}{}", + "HTTP {}: {}{}{}", status.as_u16(), message, - failed.map(|v| format!(";邮箱:{v}")).unwrap_or_default(), + failed + .map(crate::i18n::referral_failed_emails) + .unwrap_or_default(), uncertain )); } if !data.is_object() { - return Err(format!("邀请接口响应格式无法识别{uncertain}")); + return Err(crate::i18n::referral_unrecognized_response(uncertain)); } Ok(data) } @@ -104,7 +103,7 @@ pub async fn tracking( } let data = response(req, false).await?; if !data["items"].is_array() { - return Err("邀请记录响应缺少 items,无法确认记录".into()); + return Err(crate::i18n::referral_missing_items().into()); } Ok(data) } @@ -117,7 +116,7 @@ fn send_body( ) -> Result { let mut body = context(program)?; if offer["should_show"] != true { - return Err("当前账号没有可发送的邀请活动,请刷新资格".into()); + return Err(crate::i18n::referral_no_available_campaign().into()); } // Detect an offer change between review and submission, including grant amounts. for key in ["offer_id", "grants", "requires_explicit_confirmation"] { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index a80d036..4bac5f8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -36,17 +36,7 @@ pub fn init(app: &AppHandle) -> Result<(), Box> { // Windows 右键需要真正挂载 native menu;仅监听 TrayIconEvent 会把右键 // 也当成 popup 点击,系统不会自动生成完整托盘菜单。 - let show_main = MenuItem::with_id(app, "tray-show-main", "打开主窗口", true, None::<&str>)?; - let next_account = MenuItem::with_id( - app, - "tray-next-account", - "切换到下一个账号", - true, - None::<&str>, - )?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = PredefinedMenuItem::quit(app, Some("退出"))?; - let menu = Menu::with_items(app, &[&show_main, &next_account, &separator, &quit])?; + let menu = build_native_menu(app)?; let _tray = TrayIconBuilder::with_id("main") .icon(icon) @@ -87,6 +77,33 @@ pub fn init(app: &AppHandle) -> Result<(), Box> { Ok(()) } +fn build_native_menu(app: &AppHandle) -> tauri::Result> { + let show_main = MenuItem::with_id( + app, + "tray-show-main", + crate::i18n::tray_show_main(), + true, + None::<&str>, + )?; + let next_account = MenuItem::with_id( + app, + "tray-next-account", + crate::i18n::tray_next_account(), + true, + None::<&str>, + )?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = PredefinedMenuItem::quit(app, Some(crate::i18n::tray_quit()))?; + Menu::with_items(app, &[&show_main, &next_account, &separator, &quit]) +} + +pub fn update_tray_native_menu(app: &AppHandle) -> tauri::Result<()> { + if let Some(tray) = app.tray_by_id("main") { + tray.set_menu(Some(build_native_menu(app)?))?; + } + Ok(()) +} + /// 显示/隐藏 tray popup 窗口 fn toggle_popup(app: &AppHandle, position: tauri::PhysicalPosition) { let label = "tray-popup"; @@ -114,7 +131,7 @@ fn toggle_popup(app: &AppHandle, position: tauri::PhysicalPosition) { let url = tauri::WebviewUrl::App("index.html".into()); match WebviewWindowBuilder::new(app, label, url) - .title("Codex Switcher") + .title(crate::i18n::APP_NAME) .inner_size(popup_width, popup_height) .resizable(false) .decorations(false) @@ -215,17 +232,21 @@ pub fn update_tray_menu(app: &AppHandle) { }; if let Some(current_id) = &store.current { if let Some(acc) = store.accounts.get(current_id) { - let quota = acc - .cached_quota + acc.cached_quota .as_ref() - .map(|q| format!(" | 5H: {:.0}% 周: {:.0}%", q.five_hour_left, q.weekly_left)) - .unwrap_or_default(); - format!("Codex Switcher - {}{}", acc.name, quota) + .map(|q| { + crate::i18n::tray_tooltip_account( + &acc.name, + q.five_hour_left, + q.weekly_left, + ) + }) + .unwrap_or_else(|| format!("{} - {}", crate::i18n::APP_NAME, acc.name)) } else { - "Codex Switcher".to_string() + crate::i18n::tray_tooltip_default() } } else { - "Codex Switcher - 未登录".to_string() + crate::i18n::tray_tooltip_logged_out() } // store guard 在 block 结束(这一行)时 drop,set_tooltip 在外面跑 }; diff --git a/src/App.css b/src/App.css index d600d60..8ebd39e 100644 --- a/src/App.css +++ b/src/App.css @@ -156,6 +156,7 @@ body { display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 16px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); @@ -165,6 +166,7 @@ body { display: flex; align-items: center; gap: 10px; + flex: 0 0 auto; } .app-logo { @@ -184,6 +186,7 @@ body { font-size: 18px; font-weight: 700; color: var(--text-primary); + white-space: nowrap; } .app-version { @@ -234,12 +237,80 @@ body { .header-actions { display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.language-picker { + position: relative; + flex: 0 0 auto; +} + +.language-picker-trigger { + width: 34px; + height: 32px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); + cursor: pointer; + font-size: 17px; + line-height: 1; + list-style: none; +} + +.language-picker-trigger:hover, +.language-picker.open .language-picker-trigger { + background: var(--hover-bg); + border-color: var(--primary-color); +} + +.language-picker-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 100; + width: 178px; + padding: 4px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--card-bg); + box-shadow: 0 8px 24px rgba(31, 35, 40, 0.16); + max-height: min(360px, calc(100vh - 96px)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.language-picker-menu button { + width: 100%; + min-height: 34px; + display: grid; + grid-template-columns: 22px 1fr 14px; + align-items: center; gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.language-picker-menu button:hover, +.language-picker-menu button.selected { + background: var(--hover-bg); } /* 导航菜单 */ .header-nav { display: flex; + flex: 0 1 auto; gap: 4px; background: var(--section-bg); padding: 4px; @@ -270,6 +341,70 @@ body { background: var(--badge-bg); } +@media (max-width: 1400px) { + html[lang^='ru'] .app-header { + padding-inline: 16px; + } + + html[lang^='ru'] .nav-item { + padding-inline: 9px; + font-size: 12px; + } + + html[lang^='ru'] .header-actions .btn { + padding-inline: 10px; + } +} + +@media (max-width: 1280px) { + html[lang^='ru'] .app-header { + gap: 8px; + padding-inline: 12px; + } + + html[lang^='ru'] .header-left { + gap: 8px; + } + + html[lang^='ru'] .header-left h1 { + font-size: 16px; + } + + html[lang^='ru'] .app-version { + display: none; + } + + html[lang^='ru'] .proxy-indicator { + padding-inline: 8px; + } + + html[lang^='ru'] .nav-item { + padding-inline: 7px; + font-size: 11px; + } + + html[lang^='ru'] .header-actions { + gap: 6px; + } + + html[lang^='ru'] .header-actions .btn { + padding-inline: 8px; + font-size: 12px; + } +} + +@media (max-width: 1100px) { + .app-header { + flex-wrap: wrap; + } + + .header-nav { + order: 3; + width: 100%; + justify-content: center; + } +} + .app-main { flex: 1; @@ -680,4 +815,4 @@ body { font-size: 14px; color: var(--text-secondary); max-width: 320px; -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index cd668db..3f50884 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { SessionRoutes } from './components/SessionRoutes'; import CachePanel from './components/CachePanel'; import { ConfirmModal } from './components/ConfirmModal'; import { RelayImportConfirm } from './components/RelayImportConfirm'; +import { LanguagePicker } from './components/LanguagePicker'; import './App.css'; import { isMacOS } from './platform'; @@ -370,6 +371,7 @@ function App() {
+ diff --git a/src/components/AccountList.css b/src/components/AccountList.css index 0f738cb..520ab82 100644 --- a/src/components/AccountList.css +++ b/src/components/AccountList.css @@ -121,6 +121,14 @@ flex: 1; } +.toolbar-actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; + flex: 0 0 auto; +} + /* 紧凑 filter 按钮(label·count 内联,省 toolbar 宽度) */ .filter-btn-compact { gap: 4px; @@ -185,7 +193,6 @@ .toolbar-icon-btn { width: 30px; height: 30px; - margin-left: 6px; padding: 0; display: inline-flex; align-items: center; diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index a829a54..57bdb60 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -888,63 +888,65 @@ export function AccountList({ })}
- - {onAddAccount && ( +
- )} - {onAddRelay && ( - + )} + {onAddRelay && ( + + )} + {onRefreshUsage && ( + + )} + - )} - {onRefreshUsage && ( - - )} - +
diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..9cc5802 --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,77 @@ +import { Check } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { + AUTO_LOCALE, + appLocales, + getActiveLocale, + getLocalePreference, + setLocalePreference, +} from '../i18n'; + +export function LanguagePicker() { + const [open, setOpen] = useState(false); + const pickerRef = useRef(null); + const triggerRef = useRef(null); + const activeLocale = getActiveLocale(); + const preference = getLocalePreference(); + + useEffect(() => { + if (!open) return undefined; + + const handlePointerDown = (event: PointerEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + return ( +
+ + {open &&
+ {appLocales.map(locale => { + const isSelected = preference === locale.code || ( + preference === AUTO_LOCALE && activeLocale.code === locale.code + ); + return ( + + ); + })} +
} +
+ ); +} diff --git a/src/components/Settings.css b/src/components/Settings.css index a2d5d2e..cbb8ce1 100644 --- a/src/components/Settings.css +++ b/src/components/Settings.css @@ -358,6 +358,58 @@ transform: translateY(-1px); } +.translation-credit-list { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.translation-credit { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.translation-credit-language, +.translation-contributor { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.translation-credit-language { + color: var(--text-secondary); + font-weight: 500; +} + +.translation-contributor { + color: var(--primary-color); + text-decoration: none; +} + +a.translation-contributor:hover { + text-decoration: underline; +} + +@media (max-width: 720px) { + .translation-credits { + align-items: flex-start; + gap: 12px; + } + + .translation-credit-list { + align-items: flex-start; + } + + .translation-credit { + justify-content: flex-start; + } +} + /* ===== 手机锚 (Session Anchor) ===== */ .anchor-actions { display: flex; @@ -611,4 +663,4 @@ background: rgba(148, 163, 184, 0.12); color: var(--text-muted); border-color: rgba(148, 163, 184, 0.25); -} \ No newline at end of file +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index b5de5cc..30a82ff 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -5,6 +5,7 @@ import { Account, effectiveKind } from '../hooks/useAccounts'; import { formatPlanLabel } from '../utils/planLabel'; import './Settings.css'; import { isMacOS } from '../platform'; +import { appLocales, getActiveLocale, SOURCE_LOCALE_CODE } from '../i18n'; interface AppSettings { auto_reload_ide: boolean; @@ -861,6 +862,42 @@ export function Settings({ accounts = [], onSetSessionAnchor }: SettingsProps = GitHub
+ {getActiveLocale().code !== SOURCE_LOCALE_CODE && ( +
+
+ 翻译 + 感谢帮助应用支持更多语言的贡献者 +
+
+ {appLocales + .filter(locale => locale.translation && locale.contributors?.length) + .map(locale => ( +
+ + + {locale.nativeName} + + {locale.contributors?.map(contributor => contributor.url ? ( + + + ) : ( + + {contributor.name} + + ))} +
+ ))} +
+
+ )} ); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..effac69 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,98 @@ +import { russianLocale } from './ru'; +import { installUiLocale, type UiLocale } from './runtime'; + +export interface AppLocaleDefinition { + code: string; + languagePrefixes: readonly string[]; + nativeName: string; + flag: string; + translation?: UiLocale; + contributors?: readonly LocaleContributor[]; +} + +export interface LocaleContributor { + name: string; + url?: string; +} + +export const SOURCE_LOCALE_CODE = 'zh-CN'; +export const AUTO_LOCALE = 'auto'; + +export const appLocales: readonly AppLocaleDefinition[] = [ + { + code: SOURCE_LOCALE_CODE, + languagePrefixes: ['zh'], + nativeName: '中文', + flag: '🇨🇳', + }, + { + code: 'ru', + languagePrefixes: ['ru'], + nativeName: 'Русский', + flag: '🇷🇺', + translation: russianLocale, + contributors: [ + { name: 'Ivan4537', url: 'https://github.com/Ivan4537' }, + ], + }, +]; + +export type AppLocaleCode = string; +export type LocalePreference = typeof AUTO_LOCALE | AppLocaleCode; + +const localePreferenceKey = 'codex-switcher.locale'; +let activeLocaleCode = SOURCE_LOCALE_CODE; + +function requestedLocale(): string { + return (navigator.language || SOURCE_LOCALE_CODE).toLowerCase(); +} + +function findLocale(code: string): AppLocaleDefinition | undefined { + return appLocales.find(locale => locale.code.toLowerCase() === code.toLowerCase()); +} + +export function resolveAppLocale( + language: string, + preference: LocalePreference = AUTO_LOCALE, +): AppLocaleCode { + if (preference !== AUTO_LOCALE) { + const preferred = findLocale(preference); + if (preferred) return preferred.code; + } + const normalized = language.toLowerCase(); + return appLocales.find(locale => ( + locale.languagePrefixes.some(prefix => normalized.startsWith(prefix.toLowerCase())) + ))?.code ?? SOURCE_LOCALE_CODE; +} + +export function getLocalePreference(): LocalePreference { + try { + const stored = localStorage.getItem(localePreferenceKey); + return stored && (stored === AUTO_LOCALE || findLocale(stored)) ? stored : AUTO_LOCALE; + } catch { + return AUTO_LOCALE; + } +} + +export function setLocalePreference(preference: LocalePreference) { + if (preference !== AUTO_LOCALE && !findLocale(preference)) return; + localStorage.setItem(localePreferenceKey, preference); + window.location.reload(); +} + +export function getActiveLocale(): AppLocaleDefinition { + return findLocale(activeLocaleCode) ?? appLocales[0]; +} + +// Chinese remains the source language and the fallback for unsupported locales. +export function installAppLocale(systemLocale?: string): AppLocaleCode { + activeLocaleCode = resolveAppLocale(systemLocale ?? requestedLocale(), getLocalePreference()); + const locale = getActiveLocale(); + if (locale.translation) { + installUiLocale(locale.translation); + } else { + document.documentElement.lang = locale.code; + document.title = 'Codex Switcher'; + } + return locale.code; +} diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts new file mode 100644 index 0000000..672985e --- /dev/null +++ b/src/i18n/ru.ts @@ -0,0 +1,1755 @@ +import type { UiLocale } from './runtime'; + +type Replacement = readonly [source: string, target: string]; + +const replacements: Replacement[] = [ + ['切换语言', 'Сменить язык'], + ['跟随系统', 'Системный язык'], + // Messages returned by the Tauri backend. Placeholders are matched at runtime. + ['模型列表请求失败: {}', 'Не удалось запросить список моделей: {}'], + ['模型列表 JSON 解析失败: {}', 'Не удалось разобрать JSON списка моделей: {}'], + ['上游拒绝了模型列表请求', 'Upstream отклонил запрос списка моделей'], + ['上游模型列表为空或响应格式不识别', 'Список моделей upstream пуст или имеет неизвестный формат'], + ['中转账号不可用', 'Relay-аккаунт недоступен'], + ['请先配置此中转的模型 ID', 'Сначала укажите ID модели для этого relay'], + ['该账号未配置这个模型', 'Эта модель не настроена для аккаунта'], + ['设置文件权限失败: {}', 'Не удалось задать права файла: {}'], + ['设置目录权限失败: {}', 'Не удалось задать права каталога: {}'], + ['写入文件失败: {}', 'Не удалось записать файл: {}'], + ['Google 账号不存在', 'Google-аккаунт не найден'], + ['只能选择 Google Antigravity 账号', 'Можно выбрать только аккаунт Google Antigravity'], + ['Google 账号不可用,请先重新授权', 'Google-аккаунт недоступен; сначала повторите авторизацию'], + ['创建目录失败: {}', 'Не удалось создать каталог: {}'], + ['序列化失败: {}', 'Не удалось сериализовать данные: {}'], + ['读取 auth.json 失败: {}', 'Не удалось прочитать auth.json: {}'], + ['解析 auth.json 失败: {}', 'Не удалось разобрать auth.json: {}'], + ['写入临时文件失败: {}', 'Не удалось записать временный файл: {}'], + ['重命名文件失败 (Atomic Write): {}', 'Не удалось атомарно переименовать файл: {}'], + ['账号不存在: {}', 'Аккаунт не найден: {}'], + ['导出失败: {}', 'Не удалось экспортировать: {}'], + ['导入失败: {}', 'Не удалось импортировать: {}'], + ['Base64 解码失败: {}', 'Не удалось декодировать Base64: {}'], + ['base64 解码失败: {}', 'Не удалось декодировать base64: {}'], + ['UTF-8 转换失败: {}', 'Не удалось преобразовать в UTF-8: {}'], + ['JSON 解析失败: {}', 'Не удалось разобрать JSON: {}'], + ['无法定位 Windows 系统目录', 'Не удалось найти системный каталог Windows'], + ['无法启动剪贴板工具: {}', 'Не удалось запустить средство буфера обмена: {}'], + ['剪贴板输入不可写', 'Буфер обмена недоступен для записи'], + ['写入剪贴板失败: {}', 'Не удалось записать в буфер обмена: {}'], + ['等待剪贴板工具失败: {}', 'Ошибка ожидания средства буфера обмена: {}'], + ['剪贴板工具退出失败: {}', 'Средство буфера обмена завершилось с ошибкой: {}'], + ['剪贴板写入超时,请手动复制', 'Истекло время записи в буфер обмена; скопируйте вручную'], + ['创建临时文件失败: {}', 'Не удалось создать временный файл: {}'], + ['重命名临时文件失败: {}', 'Не удалось переименовать временный файл: {}'], + ['邀请奖励或活动条件已变化,请刷新资格后确认', 'Награда или условия акции изменились; обновите сведения и подтвердите повторно'], + ['当前没有剩余奖励名额,已阻止发送邀请', 'Свободных бонусных мест нет; отправка приглашения отменена'], + ['本次最多可邀请 {cap} 个邮箱', 'За один раз можно пригласить не более {cap} адресов email'], + ['邮箱格式不正确', 'Некорректный формат email'], + ['发送结果缺少 invites,请先查询邀请记录,勿直接重发', 'В результате отправки нет поля invites; проверьте историю приглашений и не отправляйте повторно вслепую'], + ['无法绑定 Antigravity OAuth 回调端口 {CALLBACK_PORT}: {e}', 'Не удалось занять порт обратного вызова Antigravity OAuth {CALLBACK_PORT}: {e}'], + ['登录流程状态锁异常', 'Не удалось получить доступ к состоянию входа'], + ['Antigravity 登录流程已过期或未启动', 'Процесс входа Antigravity истёк или не был запущен'], + ['Google 未返回模型额度,请稍后重试;这不表示额度为 0', 'Google не вернул квоты моделей. Повторите позже; это не означает, что квота равна нулю'], + ['zip 解析失败: {}', 'Не удалось разобрать ZIP: {}'], + ['zip entry {} 失败: {}', 'Ошибка элемента ZIP {}: {}'], + ['{} 解析 JSON 失败: {}', 'Не удалось разобрать JSON в {}: {}'], + ['{} 缺 credentials', 'В {} отсутствует поле credentials'], + ['无', 'Нет'], + ['未找到 IDE {} 的配置', 'Конфигурация IDE {} не найдена'], + ['不由自主', 'Нет управления приложением'], + ['不允许发送按键', 'Отправка нажатий клавиш запрещена'], + ['无法执行 osascript: {}', 'Не удалось выполнить osascript: {}'], + ['AppleScript 执行失败: {}', 'Не удалось выполнить AppleScript: {}'], + ['store lock 失败', 'Не удалось заблокировать хранилище'], + ['client/solo 模式禁止本机 rt 刷新;Server 不可达,跳过本机降级', 'В режиме client/solo локальное обновление RT запрещено; Server недоступен, локальный резервный путь пропущен'], + ['chat 入站选中的账号不存在: {}', 'Выбранный для входящего чата аккаунт не найден: {}'], + ['chat 入站选中的账号缺 access_token: {}', 'У выбранного для входящего чата аккаунта нет access_token: {}'], + ['上游请求失败: {}', 'Запрос к upstream не выполнен: {}'], + ['current 已登出', 'Текущий аккаунт вышел из системы'], + ['current 没有 refresh_token', 'У текущего аккаунта нет refresh_token'], + ['上游 HTTP {}', 'Upstream вернул HTTP {}'], + ['读取请求体失败', 'Не удалось прочитать тело запроса'], + ['Responses Relay 缺少 API key', 'Для Responses Relay не задан API-ключ'], + ['上游连接失败: {}', 'Не удалось подключиться к upstream: {}'], + ['响应构建失败', 'Не удалось сформировать ответ'], + ['账号已登出/RT 被轮换', 'Аккаунт вышел из системы или RT был заменён'], + ['缺 refresh_token 无法刷新', 'Обновление невозможно: отсутствует refresh_token'], + ['瞬时刷新失败: {}', 'Временный сбой обновления: {}'], + ['5xx 透传', 'Ответ 5xx передан без изменений'], + ['4xx 透传失败', 'Не удалось передать ответ 4xx'], + ['所有账号额度已耗尽,最早恢复:{}', 'Квоты всех аккаунтов исчерпаны. Ближайшее восстановление: {}'], + ['所有账号额度已耗尽', 'Квоты всех аккаунтов исчерпаны'], + ['Server 不可达: {}', 'Server недоступен: {}'], + ['无法从 {} 构造 Server proxy URL', 'Не удалось сформировать URL прокси Server из {}'], + ['构建 no_proxy client 失败: {}', 'Не удалось создать клиент no_proxy: {}'], + ['转发到 Server 失败: {e:?}', 'Не удалось перенаправить запрос на Server: {e:?}'], + ['silent_retry: 读首 chunk 失败: {}', 'silent_retry: не удалось прочитать первый фрагмент: {}'], + ['未配置 remote_shared_secret', 'Не задан remote_shared_secret'], + ['转发请求失败: {}', 'Не удалось перенаправить запрос: {}'], + ['流构建失败', 'Не удалось сформировать поток'], + ['锁失败', 'Не удалось получить блокировку'], + ['WebSocket 请求构建失败: {}', 'Не удалось сформировать запрос WebSocket: {}'], + ['刷新后 WebSocket 仍连接失败: {}', 'После обновления подключение WebSocket по-прежнему не устанавливается: {}'], + ['101 构建失败', 'Не удалось сформировать ответ 101'], + ['translator 请求处理失败: {}', 'Translator не смог обработать запрос: {}'], + ['Relay base_url 未配置', 'Для Relay не задан base_url'], + ['Relay base_url 解析失败', 'Не удалось разобрать base_url Relay'], + ['relay 上游连接失败: {}', 'Не удалось подключиться к upstream relay: {}'], + ['relay 上游 {}: {}', 'Upstream relay вернул {}: {}'], + ['sync 响应翻译失败: {}', 'Не удалось преобразовать синхронный ответ: {}'], + ['relay SSE 上游错误: {}', 'Ошибка SSE upstream relay: {}'], + ['StepFun /models 请求失败: {}', 'Запрос StepFun /models не выполнен: {}'], + ['上游响应构建失败', 'Не удалось сформировать ответ upstream'], + ['网络请求失败: {}', 'Сетевой запрос не выполнен: {}'], + ['wham/usage 请求失败', 'Запрос wham/usage не выполнен'], + ['刷新后重试失败: {}', 'Повторный запрос после обновления не выполнен: {}'], + ['刷新后重试失败', 'Повторный запрос после обновления не выполнен'], + ['ACCOUNT_LOGGED_OUT:您已登出或登录了其他账号,请重新登录', 'ACCOUNT_LOGGED_OUT: выполнен выход или вход в другой аккаунт; войдите заново'], + ['ACCOUNT_BANNED:该账号已被封禁', 'ACCOUNT_BANNED: аккаунт заблокирован'], + ['当前激活账号访问配额接口返回 401/403;已禁用本地 refresh_token 刷新,请稍后重试或在 Codex 中触发一次请求', 'Запрос квоты активного аккаунта вернул 401/403. Локальное обновление refresh_token отключено; повторите позже или выполните запрос в Codex'], + ['TOKEN_INVALID:授权已失效,请删除该账号后重新登录', 'TOKEN_INVALID: авторизация недействительна; удалите аккаунт и войдите заново'], + ['读取响应失败: {}', 'Не удалось прочитать ответ: {}'], + ['解析 JSON 失败: {}', 'Не удалось разобрать JSON: {}'], + ['ACCOUNT_BANNED:该账号已被封禁(workspace 已停用)', 'ACCOUNT_BANNED: аккаунт заблокирован (workspace отключён)'], + ['{}H 限额', 'Лимит {}ч'], + ['{}天后重置', 'Сброс через {} дн.'], + ['{}小时{}分钟后重置', 'Сброс через {} ч {} мин'], + ['{}分钟后重置', 'Сброс через {} мин'], + ['usage 请求失败: {}', 'Запрос usage не выполнен: {}'], + ['usage JSON 解析失败: {}', 'Не удалось разобрать JSON usage: {}'], + ['上游响应缺 remaining/balance 字段', 'В ответе upstream нет поля remaining или balance'], + ['subscription 请求失败: {}', 'Запрос subscription не выполнен: {}'], + ['subscription JSON 解析失败: {}', 'Не удалось разобрать JSON subscription: {}'], + ['subscription 缺 soft_limit_usd 字段', 'В ответе subscription нет поля soft_limit_usd'], + ['无法从 base_url 解析 origin: {}', 'Не удалось получить origin из base_url: {}'], + ['MiMo Cookie 缺少 api-platform_serviceToken 或 userId', 'В Cookie MiMo отсутствует api-platform_serviceToken или userId'], + ['MiMo usage 响应缺 monthUsage.items', 'В ответе MiMo usage нет monthUsage.items'], + ['MiMo usage 响应缺 percent/used/limit', 'В ответе MiMo usage нет percent, used или limit'], + ['StepFun 额度查询需要 platform.stepfun.com 的 Oasis-Token', 'Для проверки квоты StepFun требуется Oasis-Token с platform.stepfun.com'], + ['StepFun 额度请求失败: {}', 'Запрос квоты StepFun не выполнен: {}'], + ['StepFun 额度 JSON 解析失败: {}', 'Не удалось разобрать JSON квоты StepFun: {}'], + ['上游拒绝了额度查询', 'Upstream отклонил запрос квоты'], + ['订阅 Credit', 'Кредит подписки'], + ['加油包 Credit', 'Дополнительный кредит'], + ['响应中没有可识别的 Step Plan 额度字段', 'В ответе нет распознаваемых полей квоты Step Plan'], + ['StepFun 额度解析失败: {}', 'Не удалось разобрать квоту StepFun: {}'], + ['MiMo usage 请求失败: {}', 'Запрос MiMo usage не выполнен: {}'], + ['MiMo 登录态已失效,请重新登录后复制 Cookie', 'Сеанс MiMo истёк; войдите заново и скопируйте Cookie'], + ['MiMo Cookie 无效或权限不足', 'Cookie MiMo недействителен или не имеет необходимых прав'], + ['MiMo usage JSON 解析失败: {}', 'Не удалось разобрать JSON MiMo usage: {}'], + ['至少需要 1 个邀请邮箱', 'Укажите хотя бы один email для приглашения'], + ['邀请 {} 获得', 'Получено за приглашение {}'], + ['Codex 赠送', 'Подарок Codex'], + ['未知来源', 'Источник неизвестен'], + ['token 失效(401),请刷新或重新登录', 'Токен недействителен (401); обновите его или войдите заново'], + ['需要验证(403)', 'Требуется подтверждение (403)'], + ['请求过于频繁(429),稍后再试', 'Слишком много запросов (429); повторите позже'], + ['上游返回 HTTP {}', 'Upstream вернул HTTP {}'], + ['上游返回非 JSON', 'Upstream вернул ответ не в формате JSON'], + ['上游未返回有效的重置银行明细,当前次数未知(不代表已清空)', 'Upstream не вернул корректные сведения о доступных сбросах. Их число неизвестно; это не означает, что сбросов не осталось'], + ['已重置 {} 个限额窗口', 'Сброшено окон лимита: {}'], + ['当前没有可重置的限额(额度还没用到上限)', 'Сейчас нечего сбрасывать: квота ещё не исчерпана'], + ['上游返回:{}', 'Ответ upstream: {}'], + ['配额已耗尽(usage_limit_reached)', 'Квота исчерпана (usage_limit_reached)'], + ['token 失效(401)', 'Токен недействителен (401)'], + ['需要验证或提交保证书(403)', 'Требуется подтверждение или гарантийное заявление (403)'], + ['配额已耗尽(429)', 'Квота исчерпана (429)'], + ['Server 不可达(fallback={})', 'Server недоступен (резервный адрес: {})'], + ['Server 不可达(primary={})', 'Server недоступен (основной адрес: {})'], + ['Server 不可达(primary={}, fallback={})', 'Server недоступен (основной адрес: {}, резервный: {})'], + ['Server Google token 租约失败: {error}', 'Не удалось получить аренду Google-токена у Server: {error}'], + ['Server Google token 租约失败', 'Не удалось получить аренду Google-токена у Server'], + ['构建 HTTP client 失败: {}', 'Не удалось создать HTTP-клиент: {}'], + ['连接 Server 失败: {}', 'Не удалось подключиться к Server: {}'], + ['Server /health 返回 {}', 'Server /health вернул {}'], + ['解析 /health 响应失败: {}', 'Не удалось разобрать ответ /health: {}'], + ['连接失败: {}', 'Не удалось подключиться: {}'], + ['共享密钥不正确', 'Неверный общий ключ'], + ['Server /accounts 返回 {}', 'Server /accounts вернул {}'], + ['请求失败: {}', 'Запрос не выполнен: {}'], + ['Server 返回 {}', 'Server вернул {}'], + ['响应缺少 accounts 字段', 'В ответе нет поля accounts'], + ['反序列化账号列表失败: {}', 'Не удалось прочитать список аккаунтов: {}'], + ['创建 SSOT 目录失败: {}', 'Не удалось создать каталог SSOT: {}'], + ['读取目录失败: {}', 'Не удалось прочитать каталог: {}'], + ['移除旧 Antigravity skills 链接失败: {}', 'Не удалось удалить старую ссылку навыков Antigravity: {}'], + ['移除旧 Antigravity skills 目录失败: {}', 'Не удалось удалить старый каталог навыков Antigravity: {}'], + ['移除旧 Antigravity skill 失败: {}', 'Не удалось удалить старый навык Antigravity: {}'], + ['移除旧 Antigravity skills manifest 失败: {}', 'Не удалось удалить старый манифест навыков Antigravity: {}'], + ['Antigravity skills 目录不可用', 'Каталог навыков Antigravity недоступен'], + ['移除 Antigravity skills symlink 失败: {}', 'Не удалось удалить символическую ссылку навыков Antigravity: {}'], + ['Antigravity skills 路径不是目录: {:?}', 'Путь навыков Antigravity не является каталогом: {:?}'], + ['创建 Antigravity skills 目录失败: {}', 'Не удалось создать каталог навыков Antigravity: {}'], + ['移除过期 Antigravity skill {} 失败: {}', 'Не удалось удалить устаревший навык Antigravity {}: {}'], + ['覆盖 Antigravity skill {} 失败: {}', 'Не удалось заменить навык Antigravity {}: {}'], + ['生成 Antigravity skills manifest 失败: {}', 'Не удалось создать манифест навыков Antigravity: {}'], + ['写入 Antigravity skills manifest 失败: {}', 'Не удалось записать манифест навыков Antigravity: {}'], + ['移除 Antigravity skill 失败: {}', 'Не удалось удалить навык Antigravity: {}'], + ['移除 Antigravity skills manifest 失败: {}', 'Не удалось удалить манифест навыков Antigravity: {}'], + ['创建 symlink 失败: {}', 'Не удалось создать символическую ссылку: {}'], + ['读取条目失败: {}', 'Не удалось прочитать элемент: {}'], + ['复制文件失败: {}', 'Не удалось скопировать файл: {}'], + ['备份文件失败: {}', 'Не удалось создать резервную копию файла: {}'], + ['写入失败: {}', 'Не удалось записать: {}'], + ['未知 app: {}', 'Неизвестное приложение: {}'], + ['下载失败: {}', 'Не удалось скачать: {}'], + ['下载失败: HTTP {}', 'Не удалось скачать: HTTP {}'], + ['读取失败: {}', 'Не удалось прочитать: {}'], + ['在仓库中未找到 skill: {}', 'Навык не найден в репозитории: {}'], + ['skill 不存在: {}', 'Навык не найден: {}'], + ['打开 ZIP 失败: {}', 'Не удалось открыть ZIP: {}'], + ['读取 ZIP 条目失败: {}', 'Не удалось прочитать элемент ZIP: {}'], + ['创建文件失败: {}', 'Не удалось создать файл: {}'], + ['skill 目录不存在: {}', 'Каталог навыка не найден: {}'], + ['读取目录失败 {:?}: {}', 'Не удалось прочитать каталог {:?}: {}'], + ['添加目录失败: {}', 'Не удалось добавить каталог: {}'], + ['开始文件失败: {}', 'Не удалось начать запись файла: {}'], + ['打开文件失败 {:?}: {}', 'Не удалось открыть файл {:?}: {}'], + ['读取文件失败: {}', 'Не удалось прочитать файл: {}'], + ['写入 zip 失败: {}', 'Не удалось записать ZIP: {}'], + ['finish zip 失败: {}', 'Не удалось завершить создание ZIP: {}'], + ['非法 skill 名: {}', 'Недопустимое имя навыка: {}'], + ['创建 SSOT 失败: {}', 'Не удалось создать SSOT: {}'], + ['创建 staging 失败: {}', 'Не удалось создать временный каталог импорта: {}'], + ['打开 zip 失败: {}', 'Не удалось открыть ZIP: {}'], + ['读取 zip 条目失败: {}', 'Не удалось прочитать элемент ZIP: {}'], + ['zip 条目名非法: {}', 'Недопустимое имя элемента ZIP: {}'], + ['zip 条目缺少顶层目录', 'В элементе ZIP отсутствует корневой каталог'], + ['zip 顶层目录 {:?} 与期望 {} 不一致', 'Корневой каталог ZIP {:?} не совпадает с ожидаемым {}'], + ['创建父目录失败: {}', 'Не удалось создать родительский каталог: {}'], + ['创建文件失败 {:?}: {}', 'Не удалось создать файл {:?}: {}'], + ['读取 zip 内容失败: {}', 'Не удалось прочитать содержимое ZIP: {}'], + ['备份原目录失败: {}', 'Не удалось создать резервную копию исходного каталога: {}'], + ['替换 skill 失败: {}', 'Не удалось заменить навык: {}'], + ['sentinel/req 请求失败: {e}', 'Запрос sentinel/req не выполнен: {e}'], + ['sentinel/req 读取响应失败: {e}', 'Не удалось прочитать ответ sentinel/req: {e}'], + ['sentinel/req JSON 解析失败: {e}', 'Не удалось разобрать JSON sentinel/req: {e}'], + ['构建 HTTP client 失败: {e}', 'Не удалось создать HTTP-клиент: {e}'], + ['/oauth/authorize 请求失败: {e}', 'Запрос /oauth/authorize не выполнен: {e}'], + ['authorize/continue 失败: {e}', 'Запрос authorize/continue не выполнен: {e}'], + ['authorize/continue 读响应失败: {e}', 'Не удалось прочитать ответ authorize/continue: {e}'], + ['send-otp 失败: {e}', 'Не удалось отправить OTP: {e}'], + ['send-otp 非 200: {status} body={}', 'send-otp вернул статус {status}; тело ответа: {}'], + ['email-otp/validate 失败: {e}', 'Не удалось проверить OTP email: {e}'], + ['session_dump 失败: {e}', 'Не удалось получить session_dump: {e}'], + ['workspace/select 失败: {e}', 'Не удалось выбрать workspace: {e}'], + ['无法获取 continue_url,OTP 校验/workspace 选择都没给出,无法继续', 'Не получен continue_url: ни проверка OTP, ни выбор workspace не дали адреса для продолжения'], + ['follow {url} 失败: {e}', 'Не удалось перейти по {url}: {e}'], + ['follow_until_callback 超过 15 跳', 'Превышен лимит из 15 переходов до callback'], + ['URL 解析失败: {e}', 'Не удалось разобрать URL: {e}'], + ['URL 里没有 code 参数: {url}', 'В URL нет параметра code: {url}'], + ['请求令牌失败: {}', 'Не удалось запросить токен: {}'], + ['OpenAI 返回错误: {}', 'OpenAI вернул ошибку: {}'], + ['解析令牌响应失败: {}', 'Не удалось разобрать ответ с токеном: {}'], + ['store 锁中毒', 'Блокировка хранилища повреждена'], + ['刷新令牌失败: {}', 'Не удалось обновить токен: {}'], + ['刷新令牌被拒绝: {}', 'Обновление токена отклонено: {}'], + ['解析刷新响应失败: {}', 'Не удалось разобрать ответ обновления: {}'], + ['OTP 等待超时', 'Истекло время ожидания OTP'], + ['nissanserena GET 失败: {e}', 'Запрос nissanserena GET не выполнен: {e}'], + ['nissanserena 读响应失败: {e}', 'Не удалось прочитать ответ nissanserena: {e}'], + ['回调链接不能为空', 'Ссылка обратного вызова не может быть пустой'], + ['未能从输入中解析出 code 参数', 'Не удалось извлечь параметр code из введённого значения'], + ['state 校验不通过:这个回调链接不属于本次登录流程', 'Проверка state не пройдена: ссылка обратного вызова не относится к текущему процессу входа'], + ['登录流程已过期或未启动,请先点击『立即登录 OpenAI』', 'Процесс входа истёк или не был запущен. Сначала нажмите «Войти в OpenAI сейчас»'], + ['派发 oauth-callback-received 失败: {}', 'Не удалось отправить событие oauth-callback-received: {}'], + ['无法启动 pbcopy: {}', 'Не удалось запустить pbcopy: {}'], + ['pbcopy stdin 不可写', 'Стандартный ввод pbcopy недоступен для записи'], + ['写入 pbcopy 失败: {}', 'Не удалось записать в pbcopy: {}'], + ['等待 pbcopy 退出失败: {}', 'Ошибка ожидания завершения pbcopy: {}'], + ['pbcopy 返回非零: {:?}', 'pbcopy завершился с ненулевым кодом: {:?}'], + ['锁被污染', 'Блокировка повреждена'], + ['登录流程已过期或未启动', 'Процесс входа истёк или не был запущен'], + ['锁获取失败: {}', 'Не удалось получить блокировку: {}'], + ['Google ST 获取失败', 'Не удалось получить Google ST'], + ['Google ST 缺失', 'Отсутствует Google ST'], + ['Google project_id 缺失', 'Отсутствует Google project_id'], + ['账号已被删除', 'Аккаунт удалён'], + ['读取 OAuth body 失败: {error}', 'Не удалось прочитать тело OAuth: {error}'], + ['OAuth JSON 解析失败: {error}', 'Не удалось разобрать JSON OAuth: {error}'], + ['读取 body 失败: {}', 'Не удалось прочитать тело запроса: {}'], + ['保存失败: {}', 'Не удалось сохранить: {}'], + ['锁获取失败', 'Не удалось получить блокировку'], + ['序列化响应失败: {}', 'Не удалось сериализовать ответ: {}'], + ['刷新 token 失败: {}', 'Не удалось обновить токен: {}'], + ['无 access_token 且无 refresh_token', 'Отсутствуют и access_token, и refresh_token'], + ['TOKEN_INVALID:刷新 token 失败: {}', 'TOKEN_INVALID: не удалось обновить токен: {}'], + ['switch_to 失败: {}', 'Не удалось выполнить switch_to: {}'], + ['加载图标失败: {}', 'Не удалось загрузить значок: {}'], + ['安全确认已过期,请重新点击修复', 'Срок подтверждения истёк; снова нажмите «Исправить»'], + ['安全确认无效,请重新点击修复', 'Подтверждение недействительно; снова нажмите «Исправить»'], + ['缺少安全确认,请重新点击修复', 'Отсутствует подтверждение; снова нажмите «Исправить»'], + ['当前 auth.json 缺少 refresh_token,无法自动续期,请重新登录', 'В текущем auth.json нет refresh_token, поэтому автоматическое продление невозможно. Войдите заново'], + ['仅 ChatGPT OAuth 订阅账号支持周期保鲜', 'Продление периода поддерживается только для аккаунтов с подпиской ChatGPT OAuth'], + ['base_url 必须以 http:// 或 https:// 开头', 'base_url должен начинаться с http:// или https://'], + ['api_key 不能为空', 'api_key не может быть пустым'], + ['name 不能为空', 'Имя не может быть пустым'], + ['中转站账号缺 base_url', 'У relay-аккаунта отсутствует base_url'], + ['中转站账号缺 api_key', 'У relay-аккаунта отсутствует api_key'], + ['账号在刷新模型时被删除', 'Аккаунт был удалён во время обновления моделей'], + ['账号在刷新模型时发生变化', 'Аккаунт изменился во время обновления моделей'], + ['账号 {} 不存在', 'Аккаунт {} не найден'], + ['自动探测未命中:上游不支持 /v1/dashboard/billing 或 /v1/usage(可手动选 usage 策略,或保持「不拉取」)', 'Автоопределение не сработало: upstream не поддерживает /v1/dashboard/billing или /v1/usage. Выберите стратегию usage вручную либо оставьте «Не запрашивать»'], + ['MiMo 配额查询需要登录 platform.xiaomimimo.com 后复制 Cookie header', 'Для проверки квоты MiMo войдите на platform.xiaomimimo.com и скопируйте заголовок Cookie'], + ['StepFun 额度查询需要登录 platform.stepfun.com 后复制 Oasis-Token', 'Для проверки квоты StepFun войдите на platform.stepfun.com и скопируйте Oasis-Token'], + ['未支持的 usage_preset: {}', 'Неподдерживаемый usage_preset: {}'], + ['usage 策略未确定', 'Стратегия usage не определена'], + ['OAuth 未返回 refresh_token,无法自动续期', 'OAuth не вернул refresh_token; автоматическое продление невозможно'], + ['无法从授权响应中解析用户信息 (Missing ID Token)', 'Не удалось получить данные пользователя из ответа авторизации: отсутствует ID Token'], + ['没有当前激活账号', 'Нет активного аккаунта'], + ['已覆盖 ~/.codex/auth.json', 'Файл ~/.codex/auth.json перезаписан'], + ['OpenAI OAuth 登录', 'Вход через OpenAI OAuth'], + ['该账号不是 Google Antigravity 账号', 'Этот аккаунт не относится к Google Antigravity'], + ['Antigravity 账号由模型路由自动选择,不切换 Codex 当前身份', 'Аккаунты Antigravity выбираются маршрутизацией моделей автоматически и не меняют текущий аккаунт Codex'], + ['账号缺少 access_token', 'У аккаунта отсутствует access_token'], + ['该账号正在被其他流程刷新,请稍后重试', 'Аккаунт обновляется другим процессом; повторите позже'], + ['仅 solo 模式支持同号操作', 'Операция синхронизации аккаунта поддерживается только в режиме solo'], + ['没有可用账号', 'Нет доступных аккаунтов'], + ['遍历完所有账号,未发现可用配额的账号', 'Проверены все аккаунты; аккаунтов с доступной квотой не найдено'], + ['PROVIDER_ACCOUNT:该 Provider 账号不支持 OpenAI usage 查询', 'PROVIDER_ACCOUNT: этот аккаунт провайдера не поддерживает запрос OpenAI usage'], + ['TOKEN_INVALID:client/solo 模式禁止本机 rt 刷新;请确认账号已同步到 Server', 'TOKEN_INVALID: в режиме client/solo локальное обновление RT запрещено; проверьте синхронизацию аккаунта с Server'], + ['TOKEN_INVALID:无 access_token 且无 refresh_token', 'TOKEN_INVALID: отсутствуют и access_token, и refresh_token'], + ['邀请邮箱过多:{} 个,最多 50 个', 'Слишком много адресов для приглашения: {}; допускается не более 50'], + ['PROVIDER_ACCOUNT:该 Provider 账号不支持 Codex 邀请', 'PROVIDER_ACCOUNT: этот аккаунт провайдера не поддерживает приглашения Codex'], + ['你好', 'Здравствуйте'], + ['UNSUPPORTED_ACCOUNT:仅 ChatGPT OAuth 订阅账号支持 Codex 唤醒', 'UNSUPPORTED_ACCOUNT: запуск Codex поддерживается только для аккаунтов с подпиской ChatGPT OAuth'], + ['PROVIDER_ACCOUNT:该 Provider 账号不支持 codex 终端', 'PROVIDER_ACCOUNT: этот аккаунт провайдера не поддерживает терминал Codex'], + ['账号 auth_json 结构异常', 'Некорректная структура auth_json аккаунта'], + ['账号缺少 codex tokens,无法启动', 'У аккаунта нет токенов Codex; запуск невозможен'], + ['创建 CODEX_HOME 失败: {}', 'Не удалось создать CODEX_HOME: {}'], + ['写 auth.json 失败: {}', 'Не удалось записать auth.json: {}'], + ['写 config.toml 失败: {}', 'Не удалось записать config.toml: {}'], + ['打开 Terminal 失败: {}', 'Не удалось открыть терминал: {}'], + ['已为 {} 打开 codex 终端', 'Терминал Codex открыт для {}'], + ['同步失败:账号不存在或 User ID 不匹配', 'Синхронизация не выполнена: аккаунт не найден или User ID не совпадает'], + ['PROVIDER_ACCOUNT:该 Provider 账号不支持 OpenAI usage', 'PROVIDER_ACCOUNT: этот аккаунт провайдера не поддерживает OpenAI usage'], + ['当前激活账号与 ~/.codex/auth.json 身份不匹配,已拒绝覆盖,请先在 Codex 中切回同一账号', 'Активный аккаунт не совпадает с аккаунтом в ~/.codex/auth.json. Перезапись отклонена; сначала переключите Codex на тот же аккаунт'], + ['session route 不存在: {}', 'Маршрут сессии не найден: {}'], + ['执行失败: {}', 'Не удалось выполнить: {}'], + ['已终止 {} 个 codex 进程', 'Завершено процессов Codex: {}'], + ['读取 {} 失败: {}', 'Не удалось прочитать {}: {}'], + ['写入 {} 失败: {}', 'Не удалось записать {}: {}'], + ['config.toml(失败: {})', 'config.toml (ошибка: {})'], + ['创建 config.toml 失败: {}', 'Не удалось создать config.toml: {}'], + ['读取 config.toml 失败: {}', 'Не удалось прочитать config.toml: {}'], + ['写入 config.toml 失败: {}', 'Не удалось записать config.toml: {}'], + ['~/.codex/config.toml 不存在', 'Файл ~/.codex/config.toml не найден'], + ['Fast 模式已开启(2x 额度消耗,更快推理)。重启 Codex 生效。', 'Режим Fast включён: ответы быстрее, расход квоты удвоен. Перезапустите Codex для применения'], + ['Fast 模式已关闭。重启 Codex 生效。', 'Режим Fast отключён. Перезапустите Codex для применения'], + ['[features] goals = true 已写入。重启 Codex 生效。', 'Параметр [features] goals = true записан. Перезапустите Codex для применения'], + ['[features] goals 已关闭。重启 Codex 生效。', 'Функция [features] goals отключена. Перезапустите Codex для применения'], + ['本地未找到账号 {}', 'Локальный аккаунт {} не найден'], + ['zip task 崩溃: {}', 'Задача ZIP аварийно завершилась: {}'], + ['已停止(当前模式 {})', 'Остановлено (текущий режим: {})'], + ['已启动 http://{}:{}', 'Запущено: http://{}:{}'], + + + // Dynamic fragments and complete mixed-language labels. + ['分钟 后重置', 'мин до сброса'], + ['后台保活已停用(', 'Фоновое продление отключено ('], + [',需重新登录)@', ', требуется повторный вход) @'], + ['后台保活临时失败(', 'Временный сбой фонового продления ('], + ['代理已自动切号 →', 'Прокси автоматически переключил аккаунт →'], + ['检测到封号:', 'Обнаружена блокировка аккаунта:'], + [',已自动切换', ', выполнено автоматическое переключение'], + ['(早期数据·无 account)', '(старые данные, без аккаунта)'], + ['+ 添加', '+ Добавить'], + ['没有匹配「', 'Нет правил, соответствующих «'], + ['已复制 ✓', 'Скопировано ✓'], + ['确认删除路由「', 'Удалить маршрут «'], + ['来源:', 'Источник:'], + ['⚠️ 链接里携带的', '⚠️ Переданное в ссылке значение '], + ['未在内置白名单中,已忽略。账号仍可正常使用, 但额度查询不会自动进行。导入后可在账号详情中手动选 usage preset。', 'не входит во встроенный список разрешённых значений и было проигнорировано. Аккаунт останется доступен, но квота не будет проверяться автоматически. После импорта стратегию проверки квоты можно выбрать в параметрах аккаунта.'], + ['添加路由', 'Добавить маршрут'], + ['同步到 Server 失败:', 'Не удалось синхронизировать с Server:'], + ['已安装 (', 'Установлено ('], + [',此操作将从所有 CLI 目录移除该 skill。', ', навык будет удалён из всех каталогов CLI.'], + ['失败:', 'Ошибка: '], + [',远端账号数', ', аккаунтов на Server: '], + ['✅ 已设为手机锚', '✅ Мобильная привязка установлена'], + ['❌ 绑定失败:', '❌ Не удалось установить привязку: '], + ['✅ 已解除手机锚绑定', '✅ Мобильная привязка снята'], + ['❌ 解除失败:', '❌ Не удалось снять привязку: '], + ['这将尝试移除 Codex App 的安全隔离属性。 系统可能会弹窗要求输入密码以获得权限。是否继续?', 'Будет выполнена попытка удалить quarantine-атрибут Codex App. Система может запросить пароль администратора. Продолжить?'], + ['off=独立;server=Server 侧提供 API;client=本机,rt 旋转走 Server。 老 solo 模式已合并到 client(自动迁移:solo → client + 本机直连上游 + 本机管 current)。', 'off — автономная работа; server — предоставление API на стороне Server; client — работа на этом компьютере с обновлением RT через Server. Прежний режим solo объединён с client и автоматически переносится в client с локальным подключением к upstream и локальным управлением current.'], + ['off(关闭)', 'off (выключено)'], + ['(未设置)', '(не задано)'], + ['优先尝试,建议填局域网 IP,如 http://192.168.2.14:18081', 'Используется первым. Рекомендуется адрес в локальной сети, например http://192.168.2.14:18081'], + ['主不通时自动切换,建议填 ZeroTier IP,如 http://172.26.96.198:18081', 'Используется автоматически, если основной адрес недоступен. Рекомендуется адрес ZeroTier, например http://172.26.96.198:18081'], + ['磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号; 切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。 仅 ChatGPT 订阅号可绑定。', 'Файл ~/.codex/auth.json всегда остаётся привязан к этому аккаунту, как и мобильное удалённое подключение Codex.app. При переключении других аккаунтов файл на диске не меняется, а выход прокси переключается без разрыва мобильного подключения. Привязать можно только аккаунт с подпиской ChatGPT.'], + ['· 代理出口', '· выход прокси'], + ['Vs 上月 $', 'К прошлому месяцу: $'], + ['身份在线(手机 bridge 不掉线), 代理出口已切到', 'остаётся активным для мобильного подключения, а выход прокси переключён на '], + ['(代理正在注入当前激活号的 token,', '(прокси подставляет токен активного аккаунта, '], + ['; 关闭代理后 codex 会读到这个号)', '; после отключения прокси Codex прочитает этот аккаунт)'], + ['⚠️ 失效', '⚠️ Недействителен'], + ['💰 额度', '💰 Квота'], + ['代理已启动', 'Прокси запущен'], + ['代理已停止', 'Прокси остановлен'], + ['(新终端窗口生效)', '(вступит в силу в новом окне терминала)'], + ['auto:代理开启时自动走热切(不写 auth.json,不杀进程,仅刷 store.current)。 代理关闭时回退到冷切。cold:无论如何都冷切。', 'auto: при включённом прокси используется горячее переключение без записи auth.json и завершения процессов; обновляется только store.current. При выключенном прокси используется холодное переключение. cold: всегда использовать холодное переключение.'], + ['。 同邮箱已存在的账号会跳过,不覆盖现有 token。', '. Аккаунты с уже существующим email будут пропущены, имеющиеся токены не перезаписываются.'], + ['(同名)', '(с тем же именем)'], + ['新增账号详情(', 'Сведения о новых аккаунтах ('], + ['⚠ 仅 RT,首次请求自动 refresh', '⚠ Только RT; при первом запросе токен обновится автоматически'], + ['), 绕过 Codex 手机验证。支持单个对象、对象数组、或者嵌套容器,会自动递归识别。', '), что позволяет пройти проверку телефона Codex. Поддерживаются отдельный объект, массив объектов и вложенные контейнеры; формат определяется рекурсивно.'], + ['导入账号详情(', 'Сведения об импортированных аккаунтах ('], + ['(id_token 合成)', '(id_token сформирован автоматически)'], + ['失败项(', 'Ошибки ('], + ['glm_zhipu (GLM 自家 quota 接口)', 'glm_zhipu (собственный API квот GLM)'], + ['kimi_coding (Kimi 编程套餐 5H / 7D)', 'kimi_coding (тариф Kimi Coding, 5ч / 7д)'], + ['mimo_token_plan (MiMo 控制台 Cookie)', 'mimo_token_plan (Cookie из консоли MiMo)'], + ['stepfun_plan (StepFun 控制台 Oasis-Token)', 'stepfun_plan (Oasis-Token из консоли StepFun)'], + ['Oasis-Token=...(或直接粘贴 token)', 'Oasis-Token=... (или вставьте сам токен)'], + ['将从本地官方 Codex 的登录状态 (`auth.json`) 中提取认证信息。', 'Данные авторизации будут извлечены из локального файла входа официального Codex (`auth.json`).'], + ['deep link 添加。', 'добавление по ссылке приложения.'], + ['(自定义 base URL)', '(собственный базовый URL)'], + ['kimi_coding · Kimi 编程套餐 5H / 7D', 'kimi_coding · тариф Kimi Coding, 5ч / 7д'], + ['mimo_token_plan · 需 Cookie', 'mimo_token_plan · требуется Cookie'], + ['stepfun_plan · Step Plan 控制台 Token', 'stepfun_plan · токен из консоли Step Plan'], + ['· 已过期', '· истёк'], + ['· 今天', '· сегодня'], + ['账号还有', 'До окончания аккаунта осталось '], + [',点击修改', ', нажмите для изменения'], + ['(消耗了到期', '(использован кредит со сроком до '], + ['的那条)', ')'], + ['[AutoRefresh] 触发', '[AutoRefresh] запущено для '], + ['个账号 reset 后自动刷新:', ' аккаунтов после сброса:'], + [',已刷新额度', ', квота обновлена'], + ['推送 Server 成功(', 'успешно отправлен на Server ('], + ['Google 切号失败:', 'Не удалось переключить Google-аккаунт: '], + ['设为其模型的当前账号(Codex / Google 不变)', 'назначен текущим аккаунтом для своих моделей (Codex и Google не изменены)'], + ['登录已失效:refresh_token 已过期或被撤销,请重新登录', 'Сеанс входа недействителен: refresh_token истёк или был отозван. Войдите заново'], + ['刷新失败:', 'Не удалось обновить: '], + ['📱 手机锚', '📱 Мобильная привязка'], + ['🔄 次数未知', '🔄 Число сбросов неизвестно'], + ['⚡ 当前已被限流(额度桶为 0)——现在用一次主动重置回收最大,点击查看明细', '⚡ Сейчас действует ограничение: окно квоты равно нулю. Активный сброс даст максимальный эффект; нажмите, чтобы посмотреть сведения.'], + ['🚀 启动 codex', '🚀 Запустить Codex'], + ['周期保鲜最近错误:', 'Последняя ошибка продления периода: '], + ['🌿 周期保鲜 ·', '🌿 Продление периода ·'], + ['🌿 保鲜已关 ·', '🌿 Продление отключено ·'], + ['最近尝试:', 'Последняя попытка: '], + ['最近成功:', 'Последнее успешное выполнение: '], + ['最近结果:', 'Последний результат: '], + ['主动重置 ·', 'Активный сброс ·'], + ['拉取明细失败:', 'Не удалось загрузить сведения: '], + [',区别仅到期时间;', ', различается только срок действия; '], + [',也可粘贴包含它的 Cookie header。', '; также можно вставить заголовок Cookie, который его содержит.'], + [':每次切号前后强制抓 quota 写 `~/.codex-switcher/quota-snapshots.jsonl`。同一窗口内任意两次快照的 Δused_pct 配合期间代理 tokens → 推出该 Plan 总容量(capacity = Δtokens / Δpct × 100)。', ': до и после каждого переключения снимок квоты принудительно записывается в `~/.codex-switcher/quota-snapshots.jsonl`. Любые два снимка одного окна вместе с числом токенов прокси за этот период позволяют оценить общую ёмкость тарифа: capacity = Δtokens / Δpct × 100.'], + ['5h 周期(', 'Периоды 5ч ('], + ['周周期(', 'Недельные периоды ('], + ['每格显示「', 'В каждой ячейке показано «'], + ['= `实测累加 ÷ snapshot used_pct × 100`,用快照里 used_pct 最大那个算(量化误差最小)。`?%` 标记的 used_pct 偏小,估算误差大。', '= `фактическая сумма ÷ snapshot used_pct × 100`; расчёт выполняется по снимку с наибольшим used_pct, чтобы снизить ошибку округления. Метка `?%` означает, что used_pct мал и оценка менее точна.'], + ['🔴 = 窗口内触发过限额切号 —— 此时实测累加 ≈ Plan 实际窗口配额。', '🔴 = в этом окне было переключение из-за лимита; фактическая сумма примерно равна реальной квоте окна тарифа.'], + ['点开账号看历史周期,点开周期看 session 明细。', 'Раскройте аккаунт, чтобы посмотреть историю периодов, затем раскройте период для просмотра сессий.'], + ['切号日志 (', 'Журнал переключений ('], + ['后台任务日志 (', 'Журнал фоновых задач ('], + ['% · 窗口', '% · окно '], + ['Token Plan 订阅(tp-key;配额用控制台 Cookie)', 'Подписка Token Plan (tp-key; квота проверяется по Cookie из консоли)'], + ['手动填 base_url;余额自动探测', 'Укажите base_url вручную; баланс определяется автоматически'], + + // Fragments rendered as separate React text nodes. + ['模型', 'Модель'], + ['最后更新:', 'Последнее обновление: '], + ['奖励', 'Награда'], + ['目标账号异常:', 'Ошибка целевого аккаунта: '], + ['还没有路由规则。点', 'Правил маршрутизации пока нет. Нажмите '], + ['」的规则。', '».'], + ['未知账号(已删除? id:', 'Неизвестный аккаунт (возможно, удалён; id:'], + ['当前活跃会话', 'Текущая активная сессия'], + ['授权链接', 'Ссылка авторизации'], + ['以确认:', 'для подтверждения:'], + ['生成', 'Создать'], + ['心跳时自动把本机 current 对齐到 Server 的 current。 Server 不可达会静默跳过,保持本机现状。', 'При проверке связи локальный current автоматически синхронизируется с current на Server. Если Server недоступен, синхронизация молча пропускается и локальное состояние сохраняется.'], + ['上游模型额度:', 'Квоты моделей upstream: '], + [';窗口明细未获取', '; сведения об окнах не получены'], + ['代理已自动切换到账号:', 'Прокси автоматически переключился на аккаунт:'], + ['写入', 'Записать '], + ['goals = true 到 ~/.codex/config.toml。', 'goals = true в ~/.codex/config.toml.'], + ['(codex_credentials zip / 单 .json)、', ' (архив codex_credentials или отдельный .json), '], + ['个账号', ' аккаунтов'], + ['失效(约 30 天)后账号会变成不可用, 需要重新粘贴一次新 session。Plus 账号能正常调用模型,Free 账号即使导入也无 API 权限。', 'истекает примерно через 30 дней, после чего аккаунт становится недоступен и потребуется вставить новую сессию. Аккаунты Plus могут обращаться к моделям; у Free-аккаунтов нет доступа к API даже после импорта.'], + [')粘贴到下方:', ') и вставьте ниже:'], + ['选一个', 'Выберите сервис'], + [',base URL 自动填好,下一步只用粘 API Key。\n 需要在多家 Coding Plan 之间切换额度的话,可以把同一个服务添加多次(用账号名区分)。\n 也支持', ': базовый URL заполнится автоматически, на следующем шаге останется вставить API-ключ. Чтобы переключать квоты между несколькими Coding Plan, добавьте один сервис несколько раз под разными именами аккаунтов. Также поддерживается '], + ['切换服务', 'Сменить сервис'], + ['前缀', 'Префикс'], + ['API 地址(可修改)', 'Адрес API (можно изменить)'], + ['默认模型 ID(可修改)', 'ID модели по умолчанию (можно изменить)'], + ['余额查询', 'Проверка баланса'], + ['额度凭证', 'Данные для проверки квоты'], + ['填写凭据', 'Укажите данные доступа'], + ['← 返回选择', '← Вернуться к выбору'], + ['设为这些模型的当前号:', 'Сделать текущим для моделей: '], + ['(不影响 Codex / Google)', ' (не влияет на Codex / Google)'], + ['(通常最早到期优先,客户端无法指定)。', ' (обычно сначала используется кредит с ближайшим сроком; клиент не может выбрать его вручную).'], + ['消耗 1 次会把当前已耗尽的 5H / 周限额窗口立刻清零,此操作不可撤销。若额度还没用到上限,上游会返回「无可重置」且', 'Одна попытка сразу сбросит исчерпанное окно лимита 5ч или недели. Действие необратимо. Если квота ещё не исчерпана, upstream вернёт «сбрасывать нечего» и '], + ['。登录', '. После входа '], + ['人', ' чел.'], + ['请检查邮箱格式和本次可发送人数(最多', 'Проверьте формат email и доступное число получателей (не более '], + ['个)。', ' email).'], + ['上游已创建', 'Upstream создал '], + ['条邀请', ' приглашений'], + ['本次奖励:', 'Награда за эту отправку: '], + ['未成功:', 'Не отправлено: '], + ['日', 'День'], + ['周', 'Неделя'], + ['月', 'Месяц'], + ['是去掉异常值后最稳的估计。Δpct<3% 的样本被丢弃(used_pct 是整数,量化误差会失真)。', 'даёт наиболее устойчивую оценку после удаления выбросов. Образцы с Δpct<3% отбрасываются: used_pct является целым числом, поэтому ошибка квантования искажает результат.'], + ['= token-history.jsonl 在该窗口内的精确求和(不平均、不打折,无论中间切号几次)。', '= точная сумма из token-history.jsonl за это окно: без усреднения и скидок, независимо от числа переключений аккаунта.'], + ['条)', ' зап.)'], + ['无数据', 'Нет данных'], + ['首次 → 末次', 'Первое → последнее'], + ['直接切换将', 'Прямое переключение '], + ['覆盖', 'перезапишет'], + ['官方插件中的当前登录状态,且无法找回这些未同步的更新。', 'текущее состояние входа официального плагина; восстановить несинхронизированные обновления будет невозможно.'], + + // Relay provider names and descriptions shown in the account dialogs. + ['月之暗面(Kimi)', 'Moonshot AI (Kimi)'], + ['编程套餐:5H / 7D 额度;使用 Kimi Code Key,模型按会员权限选择', 'Тариф для разработки: квоты 5ч / 7д; используется Kimi Code Key, модели зависят от подписки'], + ['智谱', 'Zhipu AI'], + ['开放平台 API;OpenAI 兼容,支持模型映射', 'API открытой платформы; совместим с OpenAI, поддерживает сопоставление моделей'], + ['GLM Coding Plan(codex /v1/responses ↔ /chat/completions 翻译,内置)', 'GLM Coding Plan (встроенный перевод codex /v1/responses ↔ /chat/completions)'], + ['小米', 'Xiaomi'], + ['MiMo 按量付费(sk-key;非 Token Plan,无配额限制按用量计费)', 'MiMo с оплатой по потреблению (sk-key; не Token Plan, оплата по фактическому использованию без квоты)'], + ['PinCC / PackyCode / AICodeMirror / 自建 CLIProxyAPI 等;余额自动探测', 'PinCC, PackyCode, AICodeMirror, собственный CLIProxyAPI и другие; баланс определяется автоматически'], + ['原生 Responses API;支持官网或自有中转,API 地址和模型 ID 均可修改', 'Нативный Responses API; поддерживает официальный или собственный relay, адрес API и ID модели можно изменить'], + ['开放平台 API,原生 Responses;使用平台 API Key,非 Kimi Code 订阅密钥', 'API открытой платформы с нативным Responses; используется API-ключ платформы, а не ключ подписки Kimi Code'], + ['按量付费 / 订阅服务;OpenAI Chat 兼容', 'Оплата по потреблению или подписка; совместим с OpenAI Chat'], + ['阿里云', 'Alibaba Cloud'], + ['百炼开放平台;OpenAI 兼容模式', 'Открытая платформа Bailian; режим совместимости с OpenAI'], + ['通义', 'Qwen'], + ['字节跳动', 'ByteDance'], + ['火山方舟 (Coding Plan / Agent Plan / 按量付费);model 字段需用户填实际 endpoint id', 'Volcano Engine Ark (Coding Plan, Agent Plan или оплата по потреблению); в поле model укажите фактический ID endpoint'], + ['火', 'V'], + ['腾讯', 'Tencent'], + ['腾讯混元 (Token Plan / TokenHub 按量);OpenAI Chat 兼容', 'Tencent Hunyuan (Token Plan или TokenHub с оплатой по потреблению); совместим с OpenAI Chat'], + ['混', 'H'], + ['百度', 'Baidu'], + ['百度千帆 / 文心一言 ERNIE(按量付费 / 中国订阅)', 'Baidu Qianfan / ERNIE (оплата по потреблению или подписка для Китая)'], + ['千', 'B'], + ['优刻得(UCloud)', 'UCloud'], + ['UCloud Modelverse (Coding Plan + 按量付费 国内/海外);多模型聚合', 'UCloud Modelverse (Coding Plan и оплата по потреблению в Китае или за его пределами); агрегатор моделей'], + ['Fireworks AI 海外高速推理(按量 / Fire Pass 国际订阅);OpenAI Chat 兼容', 'Fireworks AI: высокоскоростной инференс за пределами Китая (оплата по потреблению или международная подписка Fire Pass); совместим с OpenAI Chat'], + ['阶跃星辰 Step Plan', 'StepFun Step Plan'], + ['Step Plan 订阅;模型直接读取 /models,额度可用控制台 Oasis-Token 查询', 'Подписка Step Plan; модели загружаются из /models, квоту можно проверить по Oasis-Token из консоли'], + ['阶跃星辰', 'StepFun'], + ['阶跃星辰 step 系列 (按量付费 / 中国订阅 / 国际订阅)', 'Серия StepFun Step (оплата по потреблению, подписка для Китая или международная подписка)'], + ['多模型聚合 API;可配置厂商模型 ID', 'API-агрегатор моделей; можно настроить ID модели провайдера'], + ['Aiberm 全球聚合 API(OpenAI/Anthropic 双协议,模型按 token group 动态)', 'Глобальный API-агрегатор Aiberm (протоколы OpenAI и Anthropic; модели зависят от группы токена)'], + ['神马中转 API 全球按量付费聚合(OpenAI/Anthropic 双协议)', 'Глобальный relay API Shenma с оплатой по потреблению (протоколы OpenAI и Anthropic)'], + ['神', 'S'], + ['魔搭(阿里云)', 'ModelScope (Alibaba Cloud)'], + ['阿里魔搭 ModelScope 按量付费;OpenAI Chat 兼容(Qwen 系列为主)', 'Alibaba ModelScope с оплатой по потреблению; совместим с OpenAI Chat (преимущественно модели Qwen)'], + ['魔', 'M'], + ['Ollama 本地推理(localhost:11434,需先 ollama pull 模型);API Key 任意填一个非空字符串即可', 'Локальный инференс Ollama (localhost:11434; сначала выполните ollama pull для модели); в качестве API-ключа подойдёт любая непустая строка'], + + // English commands that have clear Russian UI equivalents. + ['● Proxy ON', '● Прокси включён'], + ['● Proxy OFF', '● Прокси выключен'], + ['📋 Dashboard', '📋 Открыть панель'], + ['↻ Refresh', '↻ Обновить'], + ['Base URL', 'Базовый URL'], + ['Base URL *', 'Базовый URL *'], + ['API Key', 'API-ключ'], + ['API Key (sk-... / tp-...) *', 'API-ключ (sk-... / tp-...) *'], + ['deep link', 'ссылка приложения'], + ['deep link。', 'ссылка приложения.'], + ['Plan', 'Тариф'], + ['Email / Plan', 'Email / тариф'], + ['Session', 'Сессия'], + ['Token', 'Токены'], + ['检查同步状态失败:', 'Не удалось проверить состояние синхронизации:'], + ['[Frontend] 收到后台刷新通知,重新加载账号列表', '[Frontend] Получено уведомление об обновлении; список аккаунтов загружается заново'], + ['[Frontend] 收到设置更新通知,重新加载设置', '[Frontend] Получено уведомление об изменении; настройки загружаются заново'], + ['同步 IDE 状态失败:', 'Не удалось синхронизировать состояние IDE:'], + ['加载设置失败:', 'Не удалось загрузить настройки:'], + ['加载代理状态失败:', 'Не удалось загрузить состояние прокси:'], + ['次', ' раз'], + ['glm_zhipu · GLM 自家 quota', 'glm_zhipu · собственная квота GLM'], + ['是去掉异常值后最稳的估计。Δpct<3% 的样本被丢弃(used_pct 是整数,量化误差会失真)。', 'даёт наиболее устойчивую оценку после удаления выбросов. Образцы с Δpct<3% отбрасываются: used_pct является целым числом, поэтому ошибка квантования искажает результат.'], + ['心跳时自动把本机 current 对齐到 Server 的 current。', 'При проверке связи локальный current автоматически синхронизируется с current на Server.'], + ['Server 不可达会静默跳过,保持本机现状。', 'Если Server недоступен, синхронизация молча пропускается и локальное состояние сохраняется.'], + ['失效(约 30 天)后账号会变成不可用,', 'истекает примерно через 30 дней, после чего аккаунт становится недоступен; '], + ['需要重新粘贴一次新 session。', 'потребуется вставить новую сессию. '], + ['秒 后重置', 'с до сброса'], + ['⚠️ 登录状态冲突警告', '⚠️ Конфликт состояния входа'], + ['检测到官方 Codex 插件中存在未同步的 Token 更新。', 'В официальном Codex обнаружены несинхронизированные обновления токена.'], + ['当前的账号状态与官方文件不一致:', 'Текущее состояние аккаунта не совпадает с официальным файлом:'], + ['直接切换将覆盖官方插件中的当前登录状态,且无法找回这些未同步的更新。', 'Прямое переключение перезапишет текущий вход официального плагина; эти несинхронизированные обновления нельзя будет восстановить.'], + ['确认覆盖并切换', 'Перезаписать и переключить'], + ['以 IDE 为准 (同步状态)', 'Взять состояние из IDE'], + ['导出成功!', 'Экспорт выполнен.'], + ['导出失败:', 'Экспорт не выполнен:'], + ['加载中...', 'Загрузка...'], + ['加载中…', 'Загрузка...'], + ['代理运行中', 'Прокси запущен'], + ['代理未启动', 'Прокси остановлен'], + ['仪表盘', 'Панель'], + ['账号管理', 'Аккаунты'], + ['代理控制', 'Управление прокси'], + ['本地代理服务器', 'Локальный прокси-сервер'], + ['路由', 'Маршруты'], + ['统计', 'Статистика'], + ['缓存', 'Кэш'], + ['设置', 'Настройки'], + ['+ 登录账号', '+ Войти в аккаунт'], + ['+ 添加中转', '+ Добавить relay'], + ['+ 添加路由', '+ Добавить маршрут'], + ['从 IDE 自动导入', 'Автоимпорт из IDE'], + ['同步状态失败:', 'Не удалось синхронизировать состояние:'], + ['切换检查失败:', 'Не удалось проверить переключение:'], + ['保守切换也失败了:', 'Резервное переключение тоже не удалось:'], + ['确认切换失败:', 'Не удалось подтвердить переключение:'], + ['覆盖 ~/.codex/auth.json 失败:', 'Не удалось перезаписать ~/.codex/auth.json:'], + ['后台保活已停用', 'Фоновое продление отключено'], + ['后台保活临时失败', 'Временный сбой фонового продления'], + ['代理已自动切号', 'Прокси автоматически переключил аккаунт. '], + ['检测到封号', 'Обнаружена блокировка аккаунта'], + ['已自动切换', 'Переключение выполнено автоматически'], + ['当前账号', 'Текущий аккаунт'], + ['取消', 'Отмена'], + ['关闭', 'Закрыть'], + ['确认', 'Подтвердить'], + ['保存', 'Сохранить'], + ['删除', 'Удалить'], + ['刷新', 'Обновить'], + ['切换', 'Переключить'], + ['导入', 'Импорт'], + ['导出', 'Экспорт'], + ['添加', 'Добавить'], + ['移除', 'Убрать'], + ['清空', 'Очистить'], + ['复制', 'Скопировать'], + ['已复制', 'Скопировано'], + ['保存并刷新', 'Сохранить и обновить'], + ['测试连接', 'Проверить соединение'], + ['重启 HTTP 服务', 'Перезапустить HTTP-сервис'], + ['写入环境变量', 'Записать переменные окружения'], + ['环境变量', 'Переменные окружения'], + ['手动启动', 'Ручной запуск'], + ['复制命令到终端运行', 'Скопируйте команду и выполните в терминале'], + ['局域网客户端', 'Клиент в локальной сети'], + ['局域网可用', 'Доступно в LAN'], + ['允许局域网访问', 'Разрешить доступ из LAN'], + ['全局代理(CLI + App 全覆盖)', 'Глобальный прокси (CLI + приложение)'], + ['同时写入 ~/.zshrc、launchctl 和 ~/.codex/config.toml,终端 CLI 和 Codex App 均走代理', 'Записывает настройки в ~/.zshrc, launchctl и ~/.codex/config.toml; терминальный CLI и Codex App будут идти через прокси'], + ['定时额度刷新', 'Плановое обновление квот'], + ['自动刷新账号额度', 'Автоматически обновлять квоты аккаунтов'], + ['刷新间隔(分钟/账号)', 'Интервал обновления (минут на аккаунт)'], + ['每个账号之间的刷新间隔', 'Пауза между обновлением отдельных аккаунтов'], + ['每轮刷新账号数', 'Аккаунтов за цикл'], + ['每轮循环刷新多少个账号', 'Сколько аккаунтов обновлять за один цикл'], + ['切号通知', 'Уведомления о переключении'], + ['macOS 系统通知', 'Системные уведомления macOS'], + ['切号时在屏幕右上角弹出系统通知', 'Показывать системное уведомление при переключении аккаунта'], + ['对话内注入通知(实验性)', 'Вставлять уведомление в диалог (экспериментально)'], + ['切号后在 Codex 对话中插入一条切号提示消息。可能影响对话状态。', 'После переключения вставляет сообщение в диалог Codex. Может повлиять на состояние диалога.'], + ['Codex 配置', 'Настройки Codex'], + ['Fast 模式', 'Режим Fast'], + ['更快推理速度,但消耗 2x 额度。', 'Более быстрые ответы, но расход квоты в 2 раза выше.'], + ['更快推理速度,但消耗 2x 额度。当前:已开启', 'Более быстрые ответы, но расход квоты в 2 раза выше. Сейчас включено'], + ['更快推理速度,但消耗 2x 额度。当前:已关闭', 'Более быстрые ответы, но расход квоты в 2 раза выше. Сейчас выключено'], + ['当前:已开启', ' Сейчас включено'], + ['当前:已关闭', ' Сейчас выключено'], + ['关闭 Fast', 'Отключить Fast'], + ['开启 Fast', 'Включить Fast'], + ['Goals 实验特性', 'Экспериментальная функция Goals'], + ['写入 [features] goals = true 到 ~/.codex/config.toml。', 'Записывает [features] goals = true в ~/.codex/config.toml.'], + ['写入 [features] goals = true 到 ~/.codex/config.toml。当前:已开启', 'Записывает [features] goals = true в ~/.codex/config.toml. Сейчас включено'], + ['写入 [features] goals = true 到 ~/.codex/config.toml。当前:已关闭', 'Записывает [features] goals = true в ~/.codex/config.toml. Сейчас выключено'], + ['写入 [features] goals = true 到 ~/.codex/config.toml', 'Записывает [features] goals = true в ~/.codex/config.toml'], + ['关闭 Goals', 'Отключить Goals'], + ['开启 Goals', 'Включить Goals'], + ['进程管理', 'Управление процессами'], + ['终止所有 Codex 进程', 'Завершить все процессы Codex'], + ['强制终止所有运行中的 codex 进程,用于切换代理模式后重启或排错', 'Принудительно завершает запущенные процессы codex для перезапуска после смены режима прокси или диагностики'], + ['终止中...', 'Завершение...'], + ['终止进程', 'Завершить процессы'], + ['智能切号策略', 'Стратегия автопереключения'], + ['5h 配额预防性切号阈值', 'Порог раннего переключения для квоты 5ч'], + ['剩余配额低于此百分比时提前切号(0 = 仅 429 触发,推荐 10)', 'Переключаться заранее, когда остаток ниже этого процента (0 = только при 429, рекомендуется 10)'], + ['周配额预防性切号阈值', 'Порог раннего переключения для недельной квоты'], + ['剩余周配额低于此百分比时提前切号(0 = 仅 429 触发,推荐 5)', 'Переключаться заранее, когда недельный остаток ниже этого процента (0 = только при 429, рекомендуется 5)'], + ['Free 账号保护线', 'Защитный порог Free-аккаунтов'], + ['Free 账号剩余配额低于此百分比时强制切号(0 = 不特殊处理,推荐 35)', 'Принудительно переключаться с Free-аккаунта ниже этого остатка (0 = без особой обработки, рекомендуется 35)'], + ['智能切号允许 FREE 账号', 'Разрешить Free-аккаунты для автопереключения'], + ['点击“切换下一个账号”时,是否允许自动寻找并切到 FREE 账号(默认优先付费账号)', 'Разрешать ли переход на Free-аккаунт при выборе следующего аккаунта (по умолчанию приоритет у платных)'], + ['SSE Bootstrap 嗅探窗口', 'Окно анализа SSE bootstrap'], + ['字节上限', 'Лимит байт'], + ['时间上限', 'Лимит времени'], + ['读到这么多字节还没见到内容事件就放行 codex(默认 32768,越大越能逮住慢上游的限额事件)', 'Если до этого объёма байт не найдено событие с содержимым, запрос пропускается в codex (по умолчанию 32768; большее значение лучше ловит медленные события лимита)'], + ['等首个内容事件最长等多久(默认 8000ms)。配合 SSE keep-alive 心跳,拉到 30000 也不会让 codex 那头超时。', 'Максимальное ожидание первого события с содержимым (по умолчанию 8000 мс). С SSE keep-alive можно увеличить до 30000 без тайм-аута на стороне codex.'], + ['界面外观', 'Внешний вид'], + ['界面配色', 'Цветовая схема'], + ['主界面颜色的色调风格', 'Цветовой стиль основного окна'], + ['经典蓝', 'Классический синий'], + ['玛瑙绿', 'Агатовый зелёный'], + ['暗黑护眼', 'Тёмная тема'], + ['关于', 'О программе'], + ['多账号智能切换 + 本地代理 + 用量统计', 'Умное переключение аккаунтов, локальный прокси и статистика использования'], + ['翻译', 'Переводы'], + ['感谢帮助应用支持更多语言的贡献者', 'Авторы и участники локализации'], + ['你好,', 'Здравствуйте,'], + ['用户', 'пользователь'], + ['手机锚生效:', 'Мобильная привязка активна:'], + ['手机锚', 'Мобильная привязка'], + ['Codex.app 仍以', 'Codex.app остаётся онлайн как'], + ['身份在线(手机 bridge 不掉线),', 'через bridge телефона без разрыва,'], + ['代理出口已切到', 'выход прокси переключён на'], + ['磁盘 auth.json 落后:', 'auth.json на диске отстаёт: '], + ['登录状态不一致:', 'Состояние входа не совпадает: '], + ['停在', 'Остался на '], + ['未知账号', 'Неизвестный аккаунт'], + ['代理正在注入当前激活号的 token,', 'прокси подставляет токен активного аккаунта, '], + ['不影响 codex 工作', 'это не мешает работе codex'], + ['关闭代理后 codex 会读到这个号', 'после отключения прокси codex прочитает этот аккаунт'], + ['检测到 IDE 正在使用', 'Обнаружено, что IDE использует'], + ['同步磁盘', 'Синхронизировать диск'], + ['修正激活状态', 'Исправить активное состояние'], + ['立即导入该账号', 'Сразу импортировать этот аккаунт'], + ['授权已失效,请删除后重新登录', 'Авторизация истекла; удалите аккаунт и войдите заново'], + ['失效', 'Недействителен'], + ['与 ~/.codex/auth.json 身份不匹配', 'Не совпадает с идентификатором в ~/.codex/auth.json'], + ['IDE 当前用:', 'IDE сейчас использует:'], + ['用此账号覆盖 IDE', 'Перезаписать IDE этим аккаунтом'], + ['改用 IDE 当前', 'Взять текущий из IDE'], + ['导入 IDE 当前', 'Импортировать текущий из IDE'], + ['切换账号', 'Переключить аккаунт'], + ['暂无账号', 'Аккаунтов нет'], + ['最佳账号推荐', 'Рекомендуемый аккаунт'], + ['推荐账号', 'Рекомендация'], + ['暂无推荐', 'Рекомендаций нет'], + ['一键切换最佳', 'Переключиться на лучший'], + ['查看所有账号', 'Все аккаунты'], + ['导出账号数据', 'Экспорт аккаунтов'], + ['账号总数', 'Всего аккаунтов'], + ['账号信息', 'Данные аккаунта'], + ['账号名称', 'Имя аккаунта'], + ['搜索邮箱...', 'Поиск по email...'], + ['关闭自动重载 IDE', 'Отключить автообновление IDE'], + ['开启自动重载 IDE', 'Включить автообновление IDE'], + ['刷新所有账号配额', 'Обновить квоты всех аккаунтов'], + ['共 ', 'Всего '], + ['点击复制账号', 'Скопировать аккаунт'], + ['该账号已被 OpenAI 封禁', 'Этот аккаунт заблокирован OpenAI'], + ['封号', 'Заблокирован'], + ['登录已失效,可能是 refresh_token 过期、被撤销或会话在其他设备结束', 'Вход истёк: возможно, refresh_token устарел, отозван или сессия завершена на другом устройстве'], + ['需重新登录', 'Нужно войти заново'], + ['该账号 Token 已过期或失效', 'Токен аккаунта истёк или недействителен'], + ['过期', 'Истёк'], + ['主动重置次数(剩余 0 次,无法重置)', 'Активные сбросы: осталось 0, сброс недоступен'], + ['主动重置次数', 'Активные сбросы'], + ['重置中', 'Сброс...'], + ['该账号当前没有可用的主动重置次数。', 'У этого аккаунта нет доступных активных сбросов.'], + ['消耗 1 次会把当前已耗尽的 5H / 周限额窗口立刻清零,此操作不可撤销。若额度还没用到上限,上游会返回「无可重置」且不扣次数。', 'Один сброс сразу обнулит исчерпанное окно 5ч/неделя. Действие необратимо. Если лимит ещё не исчерпан, upstream вернёт «сбрасывать нечего» и попытка не будет списана.'], + ['仍可直接消耗 1 次', 'Можно списать 1 попытку напрямую'], + ['消耗哪条由服务端决定', 'Какое окно сбросить, решает сервер'], + ['将被消耗', 'Будет списано'], + ['立即重置', 'Сбросить сейчас'], + ['已重置', 'Сброшено'], + ['当前没有可重置的限额', 'Сейчас нет лимита для сброса'], + ['没有可用的主动重置次数了', 'Активных сбросов больше нет'], + ['该重置请求已经兑现过了', 'Этот запрос сброса уже использован'], + ['请求过于频繁', 'Слишком частые запросы'], + ['Token 已失效,需要重新登录', 'Токен недействителен, нужен повторный вход'], + ['账号已被封禁', 'Аккаунт заблокирован'], + ['账号已到期', 'Срок аккаунта истёк'], + ['账号今天到期,点击修改', 'Аккаунт истекает сегодня, нажмите для изменения'], + ['点击设置账号到期日', 'Нажмите, чтобы задать срок аккаунта'], + ['日期格式异常,点击修正', 'Некорректный формат даты, нажмите для исправления'], + ['日期内容异常,点击修正', 'Некорректная дата, нажмите для исправления'], + ['近 7 天', 'Ближайшие 7 дней'], + ['清除日期', 'Очистить дату'], + ['账号到期日', 'Дата окончания аккаунта'], + ['到期日期', 'Дата окончания'], + ['编辑备注', 'Изменить заметку'], + ['发送 Codex 邀请', 'Отправить приглашение Codex'], + ['发送邀请', 'Отправить приглашение'], + ['发送中…', 'Отправка...'], + ['请输入 skill 名称', 'Введите имя навыка'], + ['请至少填写 1 个邀请邮箱', 'Укажите хотя бы один email для приглашения'], + ['当前套餐不支持发邀请(free 号没有邀请权限)', 'Текущий тариф не поддерживает приглашения (у Free-аккаунтов нет такого права)'], + ['已发送,邮件已投递到对方邮箱。', 'Отправлено, письмо доставлено на указанный email.'], + ['邀请未成功', 'Приглашение не отправлено'], + ['该邮箱无法被邀请(通常是已注册过 ChatGPT 的老用户,推荐奖励只对新邮箱有效)', 'Этот email нельзя пригласить: обычно это уже зарегистрированный ChatGPT-пользователь, бонус действует только для новых email'], + ['已经给该邮箱发过邀请了(同一邮箱不能重复邀请)', 'На этот email уже отправляли приглашение; повторить нельзя'], + ['邀请链接', 'Ссылка приглашения'], + ['有效;同一邮箱只能邀一次。', 'действительно; один email можно пригласить только один раз.'], + ['邮箱列表(每行一个)', 'Список email, по одному в строке'], + ['邮箱 OTP 批量自动授权', 'Пакетная OTP-авторизация email'], + ['每个邮箱 OTP 超时(秒)', 'Тайм-аут OTP на email (сек)'], + ['开始批量授权', 'Начать пакетную авторизацию'], + ['授权中…', 'Авторизация...'], + ['重试失败', 'Повторить неудачные'], + ['官方 OAuth 授权', 'Официальная OAuth-авторизация'], + ['OpenAI 登录 (推荐)', 'Вход OpenAI (рекомендуется)'], + ['直接通过 OpenAI 官方渠道登录。支持令牌自动续期,多账号切换更稳定,无需再手动更新 `auth.json`。', 'Вход через официальный канал OpenAI. Поддерживает автообновление токена, стабильнее для нескольких аккаунтов и не требует ручного обновления `auth.json`.'], + ['授权将在你系统的默认浏览器中完成,安全可信。', 'Авторизация откроется в браузере по умолчанию.'], + ['复制授权链接(指定浏览器登录)', 'Скопировать ссылку авторизации'], + ['浏览器没跳回来?手动粘贴回调链接', 'Браузер не вернулся в приложение? Вставьте callback-ссылку вручную'], + ['从浏览器地址栏复制完整 URL(包含', 'Скопируйте полный URL из адресной строки браузера, включая'], + ['将从本地官方 Codex 的登录状态', 'Будут извлечены данные входа из локального официального Codex'], + ['中提取认证信息。', ''], + ['从官方导入', 'Импорт из официального Codex'], + ['批量导入文件', 'Пакетный импорт файлов'], + ['自动识别格式,可一次选多个文件:', 'Формат распознаётся автоматически, можно выбрать несколько файлов:'], + ['codex-switcher 原生 accounts.json', 'родной accounts.json codex-switcher'], + ['chatgpt.com 网页登录的 session JSON', 'session JSON из веб-входа chatgpt.com'], + ['Web session 没有', 'В Web session нет'], + ['绕过 Codex 手机验证。支持单个对象、对象数组、或者嵌套容器,会自动递归识别。', 'обходит телефонную проверку Codex. Поддерживаются одиночный объект, массив объектов и вложенные контейнеры; распознавание рекурсивное.'], + ['解析', 'Распознано'], + ['新增账号详情', 'Детали новых аккаунтов'], + ['导入账号详情', 'Детали импортированных аккаунтов'], + ['失败项', 'Ошибки'], + ['失败', 'Ошибка'], + ['新增', 'Добавлено'], + ['同名', 'с тем же именем'], + ['同邮箱已存在的账号会跳过,不覆盖现有 token。', 'Аккаунты с уже существующим email пропускаются, существующие токены не перезаписываются.'], + ['添加中转 (Coding Plan / 通用 Responses 中转)', 'Добавить relay (Coding Plan / универсальный Responses relay)'], + ['选择中转服务', 'Выберите relay-сервис'], + ['选预设 · 填 Key', 'Выберите пресет и вставьте ключ'], + ['选完进入下一步,base URL 已自动填好', 'После выбора переходите дальше; base URL заполнится автоматически'], + ['选择目标账号', 'Выберите целевой аккаунт'], + ['选择会话', 'Выберите сессию'], + ['从最近会话挑选', 'Выбрать из недавних сессий'], + ['手动输入 ID', 'Ввести ID вручную'], + ['绑定当前活跃会话', 'Привязать текущую активную сессию'], + ['加载最近会话…', 'Загрузка недавних сессий...'], + ['最近没有活跃会话', 'Недавних активных сессий нет'], + ['粘贴 session_id(UUID 格式,36 字符)', 'Вставьте session_id (UUID, 36 символов)'], + ['看起来不像 UUID。请检查长度是否为 36 字符、包含 4 个连字符。', 'Это не похоже на UUID. Проверьте длину 36 символов и 4 дефиса.'], + ['把会话钉到账号', 'Закрепить сессию за аккаунтом'], + ['指定 codex 会话强制使用某个账号,绕过全局自动切号。', 'Заставляет указанную сессию codex использовать выбранный аккаунт, обходя глобальное автопереключение.'], + ['当前绑定账号', 'Текущая привязка'], + ['✓ 当前绑定', '✓ Текущая привязка'], + ['条规则,启用', 'правил, включено'], + ['还没有路由规则。', 'Правил маршрутизации пока нет.'], + ['没有匹配的最近会话。试试调整搜索词或切换到「手动输入 ID」。', 'Подходящих недавних сессий нет. Измените поиск или переключитесь на ручной ввод ID.'], + ['没有匹配', 'Нет совпадений'], + ['删除后该 session 将恢复全局自动切号。', 'После удаления эта session вернётся к глобальному автопереключению.'], + ['确认删除路由', 'Удалить маршрут?'], + ['目标账号已被删除', 'Целевой аккаунт удалён'], + ['Skills', 'Навыки'], + ['Skill', 'Навык'], + ['skill', 'навык'], + ['Usage Monitor', 'Монитор квот'], + ['Proxy ON', 'Прокси включён'], + ['Proxy OFF', 'Прокси выключен'], + ['SESSION', 'СЕССИЯ'], + ['WEEKLY', 'НЕДЕЛЯ'], + ['COST USAGE', 'РАСХОДЫ'], + ['TOKEN USAGE', 'ТОКЕНЫ'], + ['Remaining', 'Осталось'], + ['Spent', 'Потрачено'], + ['Tokens', 'Токены'], + ['HEALTHY', 'НОРМА'], + ['WARNING', 'ВНИМАНИЕ'], + ['CRITICAL', 'КРИТИЧНО'], + ['Resets in', 'Сброс через'], + ['In', 'Вход'], + ['Out', 'Выход'], + ['Vs 上月', 'К прошлому месяцу'], + ['手机锚 = 当前号', 'Мобильная привязка = текущий аккаунт'], + ['代理出口 ', 'выход прокси '], + ['需重登', 'Нужен вход'], + ['Google 当前', 'Текущий Google'], + ['模型 当前', 'Текущая модель'], + ['部分模型当前', 'Часть моделей текущие'], + ['Kimi 当前', 'Текущий Kimi'], + ['DeepSeek 当前', 'Текущий DeepSeek'], + ['当前模型:', 'Текущие модели: '], + ['Token 配额', 'Квота токенов'], + ['Spark 周', 'Spark нед.'], + ['三方', 'Сторонние'], + ['未获取数据', 'Данные не получены'], + ['次数未知', 'Сбросы неизвестны'], + ['上游未返回重置次数,不代表次数已清空。点击查询银行明细。', 'Upstream не вернул число сбросов; это не значит, что они закончились. Нажмите, чтобы проверить детали.'], + ['当前已被限流(额度桶为 0)——现在用一次主动重置回收最大,点击查看明细', 'Сейчас действует лимит (окно квоты равно 0) - активный сброс даст максимальный эффект. Нажмите, чтобы посмотреть детали.'], + ['点击查看所有主动重置次数(含各自到期时间),再消耗一次重置限额窗口', 'Показать все активные сбросы и сроки их действия, затем списать один сброс окна лимита'], + ['登录账号 (OpenAI / Google / 导入)', 'Войти в аккаунт (OpenAI / Google / импорт)'], + ['刷新 Codex 当前账号额度', 'Обновить квоту текущего аккаунта Codex'], + ['刷新当前列表额度', 'Обновить квоты текущего списка'], + ['ACCOUNT INVITE', 'ПРИГЛАШЕНИЕ'], + ['OFFER', 'ПРЕДЛОЖЕНИЕ'], + ['RECIPIENTS', 'ПОЛУЧАТЕЛИ'], + ['RESULT', 'РЕЗУЛЬТАТ'], + ['HISTORY', 'ИСТОРИЯ'], + ['Session 导入', 'Импорт Session'], + ['选择文件并导入', 'Выбрать файлы и импортировать'], + ['解析并导入', 'Распознать и импортировать'], + ['导入中…', 'Импорт...'], + ['跳过', 'Пропущено'], + ['仅 RT,首次请求自动 refresh', 'только RT, при первом запросе будет auto-refresh'], + ['✓ ready', '✓ готово'], + ['含 refresh_token', 'есть refresh_token'], + ['无 refresh_token,到期后需重新导入', 'нет refresh_token, после истечения нужно импортировать заново'], + ['id_token 合成', 'id_token создан автоматически'], + ['注意:', 'Внимание:'], + ['Plus 账号能正常调用模型,Free 账号即使导入也无 API 权限。', 'Plus-аккаунты смогут вызывать модели; у Free-аккаунтов API-доступа нет даже после импорта.'], + ['粘贴', 'Вставьте'], + ['网页登录的 session JSON', 'session JSON из веб-входа'], + ['连接 Google 账号', 'Подключить Google-аккаунт'], + ['等待浏览器授权…', 'Ожидание авторизации в браузере...'], + ['授权后 Gemini 模型通过 Codex Switcher 原生路由;不会修改 Codex 的 OpenAI 登录身份。', 'После авторизации модели Gemini пойдут через родную маршрутизацию Codex Switcher; OpenAI-вход Codex изменён не будет.'], + ['不打开默认浏览器,把 Google 授权链接复制到剪贴板', 'Не открывать браузер по умолчанию, а скопировать ссылку Google-авторизации в буфер'], + ['导入中转站账号', 'Импорт relay-аккаунта'], + ['链接里携带的', 'В ссылке передан'], + ['未在内置白名单中,已忽略。账号仍可正常使用,', 'не входит во встроенный белый список и был проигнорирован. Аккаунт можно использовать,'], + ['但额度查询不会自动进行。导入后可在账号详情中手动选 usage preset。', 'но квоты не будут обновляться автоматически. После импорта можно вручную выбрать usage preset в деталях аккаунта.'], + ['导入失败:', 'Импорт не выполнен:'], + ['导入中转站', 'Импортировать relay'], + ['预设', 'Пресет'], + ['选预设自动填 base_url,贴 API Key 即可。也支持', 'Выберите пресет, base_url заполнится автоматически, затем вставьте API Key. Также поддерживается'], + ['余额查询策略', 'Стратегия проверки баланса'], + ['不拉取', 'Не запрашивать'], + ['登录 platform.stepfun.com 后复制 Oasis-Token,也可粘贴 Cookie header', 'Войдите на platform.stepfun.com и скопируйте Oasis-Token; можно вставить и Cookie header'], + ['登录 platform.xiaomimimo.com 后,从 Network 复制 Cookie header', 'Войдите на platform.xiaomimimo.com и скопируйте Cookie header из Network'], + ['该凭证只用于查询 Step Plan 额度,不会参与模型请求;实际调用仍使用上面的 Step API Key。', 'Эти данные нужны только для проверки квоты Step Plan и не участвуют в запросах к моделям; реальные вызовы используют Step API Key выше.'], + ['这里的 Cookie 只用于查询 Token Plan 用量,不会参与模型请求。实际调用仍使用上面的 tp-key。', 'Этот Cookie нужен только для проверки расхода Token Plan и не участвует в запросах к моделям; реальные вызовы используют tp-key выше.'], + ['上游协议', 'Протокол upstream'], + ['中转站讲什么 wire format', 'какой wire format понимает relay'], + ['模型兜底', 'Резервная модель'], + ['客户端发的 model 没命中映射时,统一替换成这个', 'если model от клиента не найден в карте, заменить на это значение'], + ['模型映射表', 'Таблица сопоставления моделей'], + ['每行', 'Каждая строка'], + ['客户端model=中转站model', 'client_model=relay_model'], + ['如 glm-5.1(留空 = 透传不替换)', 'например glm-5.1 (пусто = передавать без замены)'], + ['修改 StepFun 额度凭证', 'Изменить данные проверки квоты StepFun'], + ['修改 MiMo 配额 Cookie', 'Изменить Cookie проверки квоты MiMo'], + ['账号:', 'Аккаунт: '], + ['后复制', 'и скопируйте'], + ['后,从 Network 请求里复制', 'и скопируйте из запроса Network'], + ['也可粘贴包含它的 Cookie header。', 'также можно вставить Cookie header, где он содержится.'], + ['仓库', 'Репозитории'], + ['发现', 'Поиск'], + ['已安装', 'Установлено'], + ['未链接', 'Не подключено'], + ['已链接', 'Подключено'], + ['暂无已安装的 skill', 'Установленных навыков пока нет'], + ['无描述', 'Без описания'], + ['正在扫描 GitHub 仓库...', 'Сканирование GitHub-репозиториев...'], + ['点击"刷新"从仓库发现 skill', 'Нажмите «Обновить», чтобы найти навыки в репозиториях'], + ['确认卸载', 'Подтвердите удаление'], + ['即将卸载', 'Будет удалён'], + ['此操作将从所有 CLI 目录移除该 skill。', 'Это удалит навык из всех CLI-каталогов.'], + ['以确认:', 'для подтверждения:'], + ['卸载', 'Удалить'], + ['发现失败:', 'Поиск не выполнен:'], + ['安装失败:', 'Установка не выполнена:'], + ['卸载失败:', 'Удаление не выполнено:'], + ['无法读取 SKILL.md', 'Не удалось прочитать SKILL.md'], + ['操作失败:', 'Операция не выполнена:'], + ['同步完成', 'Синхронизация завершена'], + ['同步失败:', 'Синхронизация не выполнена:'], + ['同步中...', 'Синхронизация...'], + ['同步到 Server', 'Синхронизировать на Server'], + ['将本机 SSOT 下所有 skill 推送到 Server(按黑名单跳过)', 'Отправить все навыки из локального SSOT на Server с учетом чёрного списка'], + ['全量同步', 'Полная синхронизация'], + ['搜索 skill...', 'Поиск навыка...'], + ['推送', 'Отправлено'], + ['已断开链接', 'отключено от Skills'], + ['已链接到 Skills', 'подключено к Skills'], + ['已卸载', 'Удалено'], + ['自动导入', 'Автоимпорт'], + ['个新 skill', 'новых навыков'], + [' 个 skill', ' навыков'], + ['已添加', 'Добавлено'], + ['套餐待同步', 'Тариф ожидает синхронизации'], + ['到期,点击修改', 'истекает, нажмите для изменения'], + ['今天到期', 'истекает сегодня'], + ['今天', 'сегодня'], + ['还有 ', 'осталось '], + [' 天到期,点击修改', ' дн. до окончания, нажмите для изменения'], + ['到期日 ', 'Дата окончания '], + ['点击修改', 'нажмите для изменения'], + ['本地已保存,但同步 Server 失败:', 'Локально сохранено, но синхронизация с Server не выполнена:'], + ['已打开 codex 终端', 'терминал codex открыт'], + ['启动失败:', 'Не удалось запустить:'], + ['重置失败:', 'Сброс не выполнен:'], + ['消耗了到期', 'списан сброс со сроком до'], + ['的那条', ''], + ['触发 ', 'Запущено '], + ['个账号 reset 后自动刷新', 'аккаунтов для автообновления после reset'], + ['刷新失败:', 'Обновление не выполнено:'], + ['天 ', ' дн. '], + ['时 ', ' ч '], + ['分', ' мин'], + ['重试中', 'Повтор...'], + ['合并到同邮箱旧账号', 'объединён со старым аккаунтом с тем же email'], + ['更新', 'Обновлено'], + ['已刷新额度', 'квота обновлена'], + ['推送 Server 成功', 'успешно отправлен на Server'], + ['推送失败:', 'Отправка не выполнена:'], + ['Google 当前账号已切换为', 'Текущий Google-аккаунт переключён на'], + ['Google 切号失败:', 'Не удалось переключить Google-аккаунт:'], + ['已将 ', 'Назначен '], + ['设为其模型的当前账号', 'текущим аккаунтом для его моделей'], + ['Codex / Google 不变', 'Codex / Google не меняются'], + ['模型切号失败:', 'Не удалось переключить модель:'], + ['登录已失效:', 'Вход истёк:'], + ['已过期或被撤销', 'истёк или был отозван'], + ['请重新登录', 'войдите заново'], + ['刷新令牌', 'Обновить токен'], + ['保存额度凭证失败:', 'Не удалось сохранить данные проверки квоты:'], + ['点击修改额度查询凭证', 'Изменить данные проверки квоты'], + ['额度余额 ', 'Баланс квоты '], + ['来自 /wham/usage 的 credits.balance', 'Значение credits.balance из /wham/usage'], + ['请刷新该账号额度以查询 credits.balance', 'Обновите квоту аккаунта, чтобы получить credits.balance'], + ['IDE 自动重载仅支持 macOS,请手动重载 IDE', 'Автообновление IDE доступно только на macOS; перезагрузите IDE вручную'], + ['配额状态', 'Состояние квот'], + ['同步/保活', 'Синхронизация/продление'], + ['操作', 'Действия'], + ['点击打开 ', 'Открыть '], + ['刷新模型额度', 'Обновить квоты моделей'], + ['切换 Google 当前账号(不影响 Codex 当前账号)', 'Переключить текущий Google-аккаунт (не влияет на текущий аккаунт Codex)'], + ['邀请同事使用 ChatGPT 桌面版', 'Пригласить коллегу в ChatGPT Desktop'], + ['邀请朋友使用 ChatGPT 桌面版', 'Пригласить друга в ChatGPT Desktop'], + ['确定要永久删除账号', 'Точно удалить аккаунт навсегда'], + ['吗?', '?'], + ['此操作不可恢复,删除后有关该账号的本地授权信息将被清除。', 'Действие необратимо: локальные данные авторизации этого аккаунта будут удалены.'], + ['ChatGPT / Codex 目前没有提供可靠的订阅到期日接口,因此这里由你手工维护。它不会修改登录 Token、不会当作额度重置时间,也不会触发自动切号。留空保存可清除日期。', + 'ChatGPT / Codex сейчас не предоставляет надёжный API даты окончания подписки, поэтому дата ведётся вручную. Она не меняет токен входа, не считается временем сброса квоты и не запускает автопереключение. Сохраните пустое поле, чтобы очистить дату.'], + ['保存中…', 'Сохранение...'], + ['系统默认自动管理全部订阅号,并以 /wham/usage 返回的 primary_window 实际时长判断 5H 或 7D,不绑定套餐名称。首次遇到无法确认是否激活的 100% 窗口时只发一次极小请求;之后按固定 reset_at 到点触发。这里可以单独关闭该账号,client/solo 模式由 Server 单端执行。', + 'По умолчанию система автоматически ведёт все подписочные аккаунты и определяет окно 5ч или 7д по фактической длительности primary_window из /wham/usage, без привязки к названию тарифа. При первом неопределённом окне 100% отправляется один минимальный запрос; дальше запуск идёт по reset_at. Здесь можно отключить это для отдельного аккаунта; в client/solo режиме задачу выполняет только Server.'], + ['当前可用次数无法确认,不代表已清空。请稍后重试查询。', 'Текущее число доступных сбросов не удалось подтвердить; это не значит, что они закончились. Повторите проверку позже.'], + ['次,按到期时间排序(最早在前)。所有次数', 'шт., отсортированы по сроку действия (самые ранние сверху). Все сбросы '], + ['等价', 'равнозначны'], + ['区别仅到期时间;', 'отличается только срок действия;'], + ['剩 ', 'осталось '], + ['不扣次数', 'не списывать сброс'], + ['重新查询', 'Проверить заново'], + ['正在重置…', 'Сброс...'], + ['或直接粘贴 token', 'или вставьте token напрямую'], + ['四段RT', 'RT в 4 частях'], + ['默认 / 上游懂 codex /v1/responses', 'по умолчанию / upstream понимает codex /v1/responses'], + ['StepFun 额度查询需要粘贴 platform.stepfun.com 的 Oasis-Token;如果暂时不查配额,请把余额查询策略改成“不拉取”。', + 'Для проверки квоты StepFun нужен Oasis-Token с platform.stepfun.com. Если квоты пока не нужны, переключите стратегию проверки баланса на «Не запрашивать».'], + ['MiMo 配额查询需要粘贴 platform.xiaomimimo.com 的 Cookie;如果暂时不查配额,请把余额查询策略改成“不拉取”。', + 'Для проверки квоты MiMo нужен Cookie с platform.xiaomimimo.com. Если квоты пока не нужны, переключите стратегию проверки баланса на «Не запрашивать».'], + ['已获取授权码,正在交换令牌...', 'Код авторизации получен, обмен токена...'], + ['授权成功!账号已添加。', 'Авторизация успешна, аккаунт добавлен.'], + ['已获取 Google 授权码,正在验证账号和项目...', 'Код авторизации Google получен, проверка аккаунта и проекта...'], + ['Google Antigravity 账号已添加。', 'Аккаунт Google Antigravity добавлен.'], + ['请输入账号名称', 'Введите имя аккаунта'], + ['正在启动官方浏览器授权...', 'Запуск официальной авторизации в браузере...'], + ['请在打开的浏览器窗口中完成 OpenAI 授权...', 'Завершите авторизацию OpenAI в открывшемся браузере...'], + ['授权链接已复制,请粘贴到目标浏览器完成授权,回调会自动回到本应用...', 'Ссылка авторизации скопирована. Вставьте её в нужный браузер; callback вернётся в приложение автоматически...'], + ['自动复制失败。请在下方选择完整链接手动复制,或重试复制。', 'Автокопирование не удалось. Выделите полную ссылку ниже вручную или повторите копирование.'], + ['正在生成授权链接...', 'Создание ссылки авторизации...'], + ['正在启动 Google Antigravity 授权...', 'Запуск авторизации Google Antigravity...'], + ['请在浏览器中完成 Google 授权...', 'Завершите авторизацию Google в браузере...'], + ['账号导入文件', 'Файлы импорта аккаунтов'], + ['所有文件', 'Все файлы'], + ['请粘贴 ChatGPT session JSON', 'Вставьте ChatGPT session JSON'], + ['已提交回调链接,正在交换令牌...', 'Callback-ссылка отправлена, обмен токена...'], + ['添加账号', 'Добавить аккаунт'], + ['带 ', 'с '], + ['处理中...', 'Обработка...'], + ['导入当前账号', 'Импортировать текущий аккаунт'], + ['立即登录 OpenAI', 'Войти в OpenAI сейчас'], + ['提交中...', 'Отправка...'], + ['开始授权', 'Начать авторизацию'], + ['GLM 自家 quota 接口', 'родной quota API GLM'], + ['Kimi 编程套餐 5H / 7D', 'Kimi Coding Plan 5ч / 7д'], + ['MiMo 控制台 Cookie', 'Cookie консоли MiMo'], + ['StepFun 控制台 Oasis-Token', 'Oasis-Token консоли StepFun'], + ['StepFun 额度凭证', 'Данные проверки квоты StepFun'], + ['基于 new-api / CLIProxyAPI / sub2api 的第三方中转,原生 /v1/responses', 'Сторонний relay на базе new-api / CLIProxyAPI / sub2api, родной /v1/responses'], + ['厂商编程订阅,按服务支持选择 Responses 或 Chat Completions', 'Поставщик coding-подписки; выберите Responses или Chat Completions по поддержке сервиса'], + ['厂商按量付费 API(DeepSeek / Kimi / 通义 / OpenRouter 等)', 'API поставщика с оплатой по расходу (DeepSeek / Kimi / Tongyi / OpenRouter и др.)'], + ['手动填 base_url', 'Заполнить base_url вручную'], + ['保存后直接读取 StepFun /models 返回的原生 step-* 模型,在 Codex 模型列表中选择。', 'После сохранения будут прочитаны родные step-* модели из StepFun /models, их можно выбрать в списке моделей Codex.'], + ['保存后可在 Codex 中选择此中转的模型,无需切换 ChatGPT 账号。', 'После сохранения модели этого relay можно выбирать в Codex без переключения ChatGPT-аккаунта.'], + ['预设仅提供默认地址;使用自有中转时,请填写该中转的 API 地址和对应 Key。', 'Пресет задаёт только адрес по умолчанию; для собственного relay укажите его API-адрес и ключ.'], + ['填写此 API 实际支持的模型 ID', 'Укажите ID модели, которую реально поддерживает этот API'], + ['会显示在 Codex 模型列表;高级设置可添加其他模型。', 'Будет показана в списке моделей Codex; другие модели можно добавить в расширенных настройках.'], + ['需 Cookie', 'нужен Cookie'], + ['Step Plan 控制台 Token', 'Token консоли Step Plan'], + ['从 platform.stepfun.com Cookie 复制 Oasis-Token,或直接粘贴 token', 'Скопируйте Oasis-Token из Cookie на platform.stepfun.com или вставьте token напрямую'], + ['模型来源', 'Источник моделей'], + ['高级设置(模型兜底 / 映射表)', 'Расширенные настройки (резервная модель / таблица сопоставления)'], + ['Step Plan 不使用模型映射;模型列表由官方 API 返回,保存后刷新 Codex 模型列表即可。', 'Step Plan не использует сопоставление моделей; список возвращает официальный API, после сохранения обновите список моделей Codex.'], + ['客户端发的 model 未命中映射时统一替换', 'Единая замена, если model от клиента не найдена в сопоставлении'], + ['API Key 不能为空', 'API Key не может быть пустым'], + ['StepFun 额度查询需要粘贴 platform.stepfun.com 的 Oasis-Token;不查配额请把策略改成「不拉取」。', 'Для проверки квоты StepFun нужен Oasis-Token с platform.stepfun.com; если квоты не нужны, выберите стратегию «Не запрашивать».'], + ['MiMo 配额查询需要粘贴 platform.xiaomimimo.com 的 Cookie;不查配额请把策略改成「不拉取」。', 'Для проверки квоты MiMo нужен Cookie с platform.xiaomimimo.com; если квоты не нужны, выберите стратегию «Не запрашивать».'], + ['账号于 ', 'Аккаунт истекает '], + ['本地已保存,但同步 Server 失败:', 'Локально сохранено, но синхронизация с Server не выполнена: '], + ['启动失败:', 'Не удалось запустить: '], + ['重置失败:', 'Сброс не выполнен: '], + ['切号失败:', 'Не удалось переключить аккаунт: '], + ['模型切号失败:', 'Не удалось переключить модель: '], + ['登录已失效:', 'Вход истёк: '], + ['导入中...', 'Импорт...'], + ['注意:', 'Внимание:'], + ['(带', ' (с '], + ['添加中转', 'Добавить relay'], + ['余额 ', 'Баланс '], + ['✓ 已选', '✓ Выбрано'], + ['一键使用 →', 'Использовать →'], + ['没有账号。先去「账号管理」添加。', 'Аккаунтов нет. Сначала добавьте их в разделе «Аккаунты».'], + ['例如:GLM 跑文档', 'Например: GLM для документов'], + ['在路由列表里显示这个名字,方便识别。', 'Это имя будет показано в списке маршрутов, чтобы его было проще узнать.'], + ['准备就绪 — 点「添加路由」', 'Готово — нажмите «Добавить маршрут»'], + ['选好会话和账号即可添加', 'Выберите сессию и аккаунт, затем добавьте маршрут'], + ['重置未知', 'Сброс неизвестен'], + ['待刷新', 'Нужно обновить'], + ['上游未提供重置时间', 'Upstream не передал время сброса'], + ['重置时间:', 'Время сброса: '], + ['7D 额度已耗尽,请展开查看', 'Квота 7D исчерпана, раскройте подробности'], + ['7D耗尽', '7д исчерпано'], + ['暂无模型额度,点击刷新', 'Квот моделей пока нет, нажмите обновить'], + ['收起全部模型', 'Свернуть все модели'], + ['查看全部模型', 'Показать все модели'], + ['全部模型额度', 'Квоты всех моделей'], + ['剩余额度 · 重置倒计时', 'Остаток квоты · обратный отсчёт до сброса'], + ['默认优先显示 5H;5H 与 7D 同时生效,同组模型共享额度。未获取的窗口显示未知。', 'По умолчанию сначала показано 5ч; 5ч и 7д действуют одновременно, модели одной группы делят квоту. Не полученные окна отображаются как неизвестные.'], + ['模型额度列表', 'Список квот моделей'], + ['早期数据·无 account', 'старые данные · без account'], + ['正在切换...', 'Переключение...'], + ['新账号', 'Новый аккаунт'], + ['复制中…', 'Копирование...'], + ['复制此链接', 'Скопировать эту ссылку'], + ['端口已更新(重启代理后生效)', 'Порт обновлён (вступит в силу после перезапуска прокси)'], + ['保存失败:', 'Не удалось сохранить:'], + ['新终端窗口生效', 'вступит в силу в новом окне терминала'], + ['启动代理', 'Запустить прокси'], + ['关闭代理', 'Остановить прокси'], + ['复制命令到 PowerShell 运行', 'Скопировать команду для запуска в PowerShell'], + ['client 模式:本机不跑 oauth,改为从 Server /quotas 自动同步 cached_quota(约 5 分钟一轮)', 'Режим client: локальный oauth не запускается; cached_quota автоматически синхронизируется из Server /quotas примерно раз в 5 минут'], + ['按最后更新时间排序,自动循环刷新所有账号的配额数据', 'Сортировать по времени последнего обновления и циклически обновлять квоты всех аккаунтов'], + ['邀请同事加入工作区,奖励按官方活动规则发放', 'Пригласите коллегу в workspace; бонус начисляется по официальным правилам акции'], + ['邀请朋友加入,奖励按官方活动规则发放', 'Пригласите друга; бонус начисляется по официальным правилам акции'], + ['关闭邀请窗口', 'Закрыть окно приглашения'], + ['发邀账号', 'Аккаунт отправителя'], + ['当前活动', 'Текущая акция'], + ['活动范围', 'Область акции'], + ['个人账号', 'Личный аккаунт'], + ['工作区', 'Workspace'], + ['查询中…', 'Проверка...'], + ['刷新资格', 'Обновить право на приглашения'], + ['正在查询活动资格…', 'Проверка права на участие...'], + ['每位符合条件的邀请奖励', 'Бонус за каждого подходящего приглашённого'], + ['当前账号暂未开放', 'Для текущего аккаунта пока недоступно'], + ['当前账号暂未开放此邀请活动。', 'Для текущего аккаунта эта акция приглашений пока недоступна.'], + ['可发送邮箱', 'Можно отправить email'], + ['未提供', 'Не предоставлено'], + ['接口未提供实际奖励金额,不能根据活动编号推断为 500 或 1000。', 'API не передал фактический размер бонуса; нельзя выводить 500 или 1000 только по номеру акции.'], + ['接口未提供实际奖励金额,不能根据活动编号推断奖励数额。', 'API не передал фактический размер бонуса; нельзя выводить сумму по номеру акции.'], + ['活动奖励不等于当前余额;对方接受邀请并完成官方要求后才会到账。', 'Бонус акции не равен текущему балансу; он поступит только после принятия приглашения и выполнения официальных условий.'], + ['当前奖励名额为 0,已禁止发送邀请。', 'Доступных бонусных мест 0, отправка приглашений отключена.'], + ['填写受邀邮箱', 'Введите email получателей'], + ['奖励名额已用完', 'Бонусные места закончились'], + ['最多发送 ', 'Можно отправить максимум '], + ['受邀邮箱', 'Email получателей'], + ['每行一个邮箱,也可用逗号或分号分隔', 'Один email на строку; можно разделять запятыми или точками с запятой'], + ['我已确认收件人及奖励条件,发送邀请不代表奖励已到账。', 'Я проверил получателей и условия бонуса; отправка приглашения не означает, что бонус уже начислен.'], + ['发送结果', 'Результат отправки'], + ['邀请记录已创建', 'Запись приглашения создана'], + ['未返回已创建的邀请,请查询记录确认。', 'Созданное приглашение не вернулось в ответе; проверьте историю.'], + ['本次提交已结束。需要再次邀请时,请先核对记录,再重新打开此窗口。', 'Эта отправка завершена. Для нового приглашения сначала проверьте историю, затем откройте окно снова.'], + ['邀请记录', 'История приглашений'], + ['查询近 90 天', 'Проверить последние 90 дней'], + ['近 90 天没有邀请记录。', 'За последние 90 дней приглашений нет.'], + ['上游未提供状态', 'Upstream не передал статус'], + ['加载更多', 'Загрузить ещё'], + ['奖励到账以官方记录为准', 'Начисление бонуса определяется официальной историей'], + ['邀请额度', 'Квота приглашений'], + ['刷新邀请额度', 'Обновить квоту приглашений'], + ['正在查询邀请资格…', 'Проверка права на приглашения...'], + ['每位奖励', 'Бонус за человека'], + ['上次数据已过期,请刷新额度', 'Предыдущие данные устарели, обновите квоту'], + ['剩余 ', 'Осталось '], + ['加载路由规则…', 'Загрузка правил маршрутизации...'], + ['删除路由', 'Удалить маршрут'], + ['点击复制完整 session_id', 'Скопировать полный session_id'], + ['✅ 设置已保存', '✅ Настройки сохранены'], + ['❌ 保存失败:', '❌ Не удалось сохранить:'], + ['生成失败:', 'Не удалось создать: '], + ['已切换到 ', 'Переключено на '], + ['已与 Server 一致,无需切换', 'Уже совпадает с Server, переключение не требуется'], + ['使用 ', 'Используется '], + ['远端账号数 ', 'аккаунтов на удалённой стороне: '], + ['推送全部账号到 Server', 'Отправить все аккаунты на Server'], + ['已上传 ', 'Загружено '], + ['从 Server 拉取全部账号', 'Загрузить все аккаунты с Server'], + ['已合并 ', 'Объединено '], + ['从 Server 同步所有 token', 'Синхронизировать все token с Server'], + ['已写 auth.json', 'auth.json записан'], + ['✅ 修复成功!\n\n现在请尝试重新打开 Codex App。', '✅ Исправлено.\n\nТеперь попробуйте снова открыть Codex App.'], + ['保存中...', 'Сохранение...'], + ['保存设置', 'Сохранить настройки'], + ['client 模式:保活由 Server 负责,本机已强制关闭(避免双路刷新撞飞 refresh_token)', 'Режим client: продление выполняет Server, локально оно принудительно отключено, чтобы два процесса не испортили refresh_token'], + ['Server 负责', 'Отвечает Server'], + ['已开启', 'Включено'], + ['已关闭', 'Отключено'], + ['执行中...', 'Выполнение...'], + ['立即重启', 'Перезапустить сейчас'], + ['更换', 'Заменить'], + ['选择绑定账号', 'Выбрать привязанный аккаунт'], + ['隔离属性修复仅适用于 macOS', 'Исправление quarantine-атрибута доступно только на macOS'], + ['修复中...', 'Исправление...'], + ['立即修复', 'Исправить сейчас'], + ['当前没有 ChatGPT 订阅号', 'Сейчас нет ChatGPT-аккаунтов с подпиской'], + ['没有匹配的账号', 'Подходящих аккаунтов нет'], + ['扫描中...', 'Сканирование...'], + ['个人活动', 'Личная акция'], + ['工作区活动', 'Акция workspace'], + ['个使用额度', 'кредитов использования'], + ['个工作区额度', 'кредитов workspace'], + ['次限额重置', 'сбросов лимита'], + ['活动未提供奖励数额', 'Акция не передала размер бонуса'], + ['发送上限', 'Лимит отправки'], + ['奖励名额', 'Бонусных мест'], + ['250 个使用额度', '250 кредитов использования'], + ['可以 отправить email', 'Можно отправить email'], + ['Codex CLI 通过代理连接 OpenAI,支持无中断自动切号和 429 智能重试', + 'Codex CLI подключается к OpenAI через прокси; поддерживает переключение аккаунтов без обрыва и умный повтор при 429'], + ['auto:代理开启时自动走热切(不写 auth.json,不杀进程,仅刷 store.current)。代理关闭时回退到冷切。cold:无论如何都冷切。', + 'auto: при включённом прокси используется горячее переключение (без записи auth.json, без завершения процессов, обновляется только store.current). При выключенном прокси используется холодное переключение. cold: всегда холодное переключение.'], + ['代理开启时自动走热切', 'при включённом прокси используется горячее переключение'], + ['不写 auth.json,不杀进程,仅刷 store.current', 'без записи auth.json, без завершения процессов, обновляется только store.current'], + ['代理关闭时回退到冷切', 'при выключенном прокси используется холодное переключение'], + ['无论如何都冷切', 'всегда холодное переключение'], + ['开启(默认):current 是中转 / Coding Plan / 三方 API 时,遇到 401/429/quota 自动切到健康的订阅号,避免请求卡死。关闭后这类号出错会把错误透传给客户端,不偷换。', + 'Включено по умолчанию: если current это relay / Coding Plan / сторонний API, при 401/429/quota приложение автоматически переключится на исправный подписной аккаунт, чтобы запрос не завис. Если выключить, такие ошибки будут передаваться клиенту без скрытой подмены аккаунта.'], + ['关闭(默认):用订阅号时自动切号 / affinity 不会路由到中转 / Coding Plan / 三方 API,避免偷扣余额。开启后这类号跟订阅号同等参与轮询(量大但要确认你愿意花对应套餐的额度)。', + 'Выключено по умолчанию: при работе с подписным аккаунтом автопереключение и affinity не будут маршрутизировать запросы в relay / Coding Plan / сторонний API, чтобы случайно не списывать баланс. Если включить, эти аккаунты участвуют в выборе наравне с подписками; используйте только если готовы тратить их квоту.'], + ['开启后 HTTP 也跟 WebSocket 同路,本机直接打上游,不再经 Server 转发;access_token 仍从 Server 拉。适合 Server 出口不稳但本机出口稳的场景。', + 'После включения HTTP пойдёт тем же путём, что и WebSocket: этот компьютер обращается к upstream напрямую, без пересылки через Server; access_token всё равно берётся с Server. Полезно, если выход Server нестабилен, а локальный выход стабилен.'], + ['开启后本机自己决定 current(不被 Server `/current` 反向同步覆盖),`~/.codex/auth.json` 也由本机自己写。等价于旧 solo 模式(已并入 client)。', + 'После включения этот компьютер сам выбирает current (Server `/current` не перезаписывает его обратной синхронизацией), и сам пишет `~/.codex/auth.json`. Это эквивалент старого solo-режима, теперь встроенного в client.'], + ['off=独立;server=Server 侧提供 API;client=本机,rt 旋转走 Server。', + 'off = автономно; server = эта сторона предоставляет API; client = этот компьютер, ротация RT идёт через Server.'], + ['老 solo 模式已合并到 client(自动迁移:solo → client + 本机直连上游 + 本机管 current)。', + 'Старый solo-режим объединён с client (автомиграция: solo -> client + локальный прямой upstream + локальное управление current).'], + ['磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。仅 ChatGPT 订阅号可绑定。', + '~/.codex/auth.json на диске всегда следует этому аккаунту, мобильное удалённое подключение Codex.app привязано к нему. При переходе на другие аккаунты disk не меняется, меняется только выход proxy (мобильный bridge не отваливается). Привязать можно только подписной аккаунт ChatGPT.'], + ['磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;', + '~/.codex/auth.json на диске всегда следует этому аккаунту, мобильное удалённое подключение Codex.app привязано к нему; '], + ['切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。', 'при переходе на другие аккаунты disk не меняется, меняется только выход proxy (мобильный bridge не отваливается).'], + ['仅 ChatGPT 订阅号可绑定。', 'Привязать можно только подписной аккаунт ChatGPT.'], + ['原理', 'Принцип'], + ['每次切号前后强制抓 quota 写', 'перед и после каждого переключения принудительно снимается quota и пишется в'], + ['同一窗口内任意两次快照的', 'любые два снимка в одном окне дают'], + ['任何两次快照的', 'любые два снимка дают'], + ['配合期间代理 tokens', 'вместе с proxy tokens за период'], + ['推出该 Plan 总容量', 'оценку общей ёмкости Plan'], + ['推出 Plan 总容量', 'оценку общей ёмкости Plan'], + ['中位', 'Медиана'], + ['是去掉异常值后最稳的估计', ' - самая устойчивая оценка после удаления выбросов'], + ['是去掉异常值后的稳定估计', ' - устойчивая оценка после удаления выбросов'], + ['Δpct<3% 的样本被丢弃', 'Сэмплы с Δpct < 3% отбрасываются'], + ['样本被丢弃', 'семплы отбрасываются'], + ['used_pct 是整数', 'used_pct целочисленный'], + ['量化误差会失真', 'ошибка квантования искажает результат'], + ['样本会随每次切号自动累积', 'семплы автоматически накапливаются при каждом переключении'], + ['样本数低于', 'если семплов меньше'], + ['时估计仍有偏差,多用几小时即可', 'оценка ещё может быть смещена; дайте системе поработать несколько часов'], + ['每号 Token 历史(精确累加 + 估算上限)', 'История токенов по аккаунтам (точная сумма + оценка лимита)'], + ['邮箱 / Plan', 'Email / Plan'], + ['当前 5h', 'Текущие 5ч'], + ['上次 5h', 'Прошлые 5ч'], + ['当前 周', 'Текущая неделя'], + ['上次 周', 'Прошлая неделя'], + ['每格显示', 'В каждой ячейке показано'], + ['实测累加 / 估算上限', 'фактическая сумма / оценка лимита'], + ['实测累加', 'Фактическая сумма'], + ['在该窗口内的精确求和', 'точная сумма в этом окне'], + ['不平均,不打折,无论中间切号几次', 'без усреднения и скидок, независимо от числа переключений внутри'], + ['估算上限', 'Оценка лимита'], + ['用快照里 used_pct 最大的那个算', 'считается по снимку с максимальным used_pct'], + ['用快照里 used_pct 最大那个算', 'считается по снимку с максимальным used_pct'], + ['量化误差最小', 'минимальная ошибка квантования'], + ['标记的 used_pct 偏小,估算误差大', 'помеченный used_pct маловат, оценка менее точная'], + ['窗口内触发过限额切号', 'в окне было переключение из-за лимита'], + ['此时实测累加', 'в этом случае фактическая сумма'], + ['实际窗口配额', 'фактической квоте окна'], + ['点开账号看历史周期,点开周期看 session 明细', 'раскройте аккаунт для истории циклов, затем цикл для деталей session'], + ['最近', 'недавно'], + ['Token 趋势', 'Динамика токенов'], + ['费用趋势', 'Динамика расходов'], + ['切号日志', 'Журнал переключений'], + ['切换路径', 'Путь переключения'], + ['使用时长', 'Время использования'], + ['暂无切号记录', 'Записей переключения пока нет'], + ['Панель Prompt Cache', 'Панель Prompt Cache'], + ['隐藏旧数据', 'Скрыть старые данные'], + ['早期版本没记 account_id 的历史条目', 'Исторические записи старых версий без account_id'], + ['近 24 小时', 'Последние 24 часа'], + ['近 30 天', 'Последние 30 дней'], + ['节省', 'Сэкономлено'], + ['总花费', 'Итоговый расход'], + ['次请求', 'запросов'], + ['按模型 token 分布', 'Распределение токенов по моделям'], + ['Кэшированный вход', 'Кэшированный вход'], + ['Некэшированный вход', 'Некэшированный вход'], + ['Выход', 'Выход'], + ['本字段是这次新增的,旧条目里没有,不能事后追溯', 'Это поле появилось недавно; в старых записях его нет, восстановить задним числом нельзя'], + ['累计 cached tokens', 'Всего кэшированных токенов'], + ['健康', 'Исправен'], + ['失效或封号', 'Недействителен или заблокирован'], + ['创建于', 'Создано'], + ['命中', 'Срабатываний'], + ['从未', 'Никогда'], + ['刚刚', 'Только что'], + ['分钟前', 'мин назад'], + ['小时前', 'ч назад'], + ['昨天', 'Вчера'], + ['天前', 'дн назад'], + ['中转服务', 'Relay-сервис'], + ['选择预设', 'Выберите пресет'], + ['账号名称 *', 'Имя аккаунта *'], + ['例如:unity2-工作', 'Например: unity2-work'], + ['例如:工作 · GLM Coding', 'Например: Работа · GLM Coding'], + ['例如:工作账号、个人账号', 'Например: рабочий аккаунт, личный аккаунт'], + ['可选的备注信息...', 'Необязательная заметка...'], + ['主页', 'Сайт'], + ['名称', 'Название'], + ['备注(可选)', 'Заметка (необязательно)'], + ['默认自动探测', 'Автоопределение по умолчанию'], + ['自动探测(推荐 · new-api / sub2api 都能识别)', 'Автоопределение (рекомендуется, распознаёт new-api / sub2api)'], + ['responses(默认 / Unity2、ChatGPT、OpenAI key)', 'responses (по умолчанию / Unity2, ChatGPT, OpenAI key)'], + ['chat_completions(GLM/MiMo Coding Plan / 通用 OpenAI Chat)', 'chat_completions (GLM/MiMo Coding Plan / универсальный OpenAI Chat)'], + ['留空 = 透传不替换', 'пусто = передавать без замены'], + ['每行 客户端model=中转站model', 'По одной строке: client_model=relay_model'], + ['添加中…', 'Добавление...'], + ['Base URL 已自动填好', 'Base URL заполнен автоматически'], + ['通用中转', 'Универсальный relay'], + ['三方模型', 'Сторонние модели'], + ['自定义', 'Пользовательский'], + ['厂商自家订阅', 'Подписка провайдера'], + ['厂商按量付费 API', 'API с оплатой по расходу'], + ['中转 / Plan / 三方 出问题时切回订阅号', 'При сбое relay / Plan / стороннего API вернуться на подписной аккаунт'], + ['自动选号可挑中 中转 / Plan / 三方', 'Автовыбор может выбирать relay / Plan / сторонний API'], + ['智谱 GLM', 'Zhipu GLM'], + ['通用 Responses 中转(new-api / CLIProxyAPI / sub2api)', 'Универсальный Responses relay (new-api / CLIProxyAPI / sub2api)'], + ['自定义中转站', 'Пользовательский relay'], + ['阿里 DashScope (通义千问)', 'Alibaba DashScope (Qwen)'], + ['火山方舟 (ByteDance Doubao)', 'Volcano Ark (ByteDance Doubao)'], + ['腾讯混元', 'Tencent Hunyuan'], + ['百度千帆 (ERNIE)', 'Baidu Qianfan (ERNIE)'], + ['优云智算 UCloud Modelverse', 'UCloud Modelverse'], + ['阶跃星辰 Stepfun', 'Stepfun'], + ['神马中转 API (Whatai)', 'Whatai relay API'], + ['魔搭 ModelScope', 'ModelScope'], + ['Ollama 本地推理', 'Локальный Ollama'], + ['按量付费', 'оплата по расходу'], + ['中国订阅', 'китайская подписка'], + ['国际订阅', 'международная подписка'], + ['余额自动探测', 'автоопределение баланса'], + ['OpenAI 兼容', 'OpenAI-совместимый'], + ['OpenAI Chat 兼容', 'совместим с OpenAI Chat'], + ['配额用控制台 Cookie', 'квота через Cookie консоли'], + ['MiMo 配额 Cookie', 'Cookie квоты MiMo'], + ['从 platform.xiaomimimo.com Network 复制 Cookie header', 'Скопируйте Cookie header из Network на platform.xiaomimimo.com'], + ['Prompt Cache 面板', 'Панель кэша промптов'], + ['还没有任何 session 命中过 cache', 'Ни одна сессия пока не использовала кэш'], + ['活跃 session 绑定', 'Активные привязки сессий'], + ['活跃 Session 绑定(evidence-based stickiness)', 'Активные привязки сессий (по подтверждённым попаданиям)'], + ['命中率(按小时)', 'Доля попаданий по часам'], + ['按模型 token 分布(cached vs uncached vs output)', 'Распределение токенов по моделям (кэшированный вход / некэшированный вход / выход)'], + ['vs 全价 input', 'к полной цене input'], + ['请求数', 'Запросов'], + ['命中率', 'Доля попаданий'], + ['全部项目', 'Все элементы'], + ['近 90 天', 'Последние 90 дней'], + ['Token 总量', 'Всего token'], + ['总费用', 'Итоговая стоимость'], + ['切号次数', 'Переключений'], + ['使用账号数', 'Использовано аккаунтов'], + ['Plan 配额上限估算(Δpct 反推,不依赖账号打满)', 'Оценка лимита Plan по Δpct без полного расхода аккаунта'], + ['5h 样本', 'Сэмплы 5ч'], + ['5h 中位', 'Медиана 5ч'], + ['5h 平均', 'Среднее 5ч'], + ['5h min–max', 'Мин-макс 5ч'], + ['5h min-max', 'Мин-макс 5ч'], + ['5H MIN-MAX', 'Мин-макс 5ч'], + ['5h MIN-MAX', 'Мин-макс 5ч'], + ['MIN-MAX 周', 'Мин-макс недели'], + ['周 样本', 'Сэмплы недели'], + ['周 中位', 'Медиана недели'], + ['周 平均', 'Среднее недели'], + ['周 min–max', 'Мин-макс недели'], + ['样本会随每次切号自动累积;样本数低于 ~5 时估计仍有偏差,多用几小时即可。', 'Сэмплы автоматически копятся при переключениях; при числе меньше ~5 оценка может быть неточной, дайте системе поработать несколько часов.'], + ['5h 周期', 'Периоды 5ч'], + ['周周期', 'Недельные периоды'], + ['每格显示「实测累加 / 估算上限」。', 'В каждой ячейке показано «фактическая сумма / оценка лимита».'], + ['窗口', 'Окно'], + ['轮数', 'Раундов'], + ['session 数', 'Session'], + ['状态', 'Статус'], + ['进行中', 'Идёт'], + ['正常', 'Норма'], + ['暂无数据', 'Данных нет'], + ['后台任务日志', 'Журнал фоновых задач'], + ['时间', 'Время'], + ['原因', 'Причина'], + ['目标账号', 'Целевой аккаунт'], + ['任务类型', 'Тип задачи'], + ['刷新后额度', 'Квота после обновления'], + ['成功', 'Успешно'], + ['手动', 'Вручную'], + ['自动刷新', 'Автообновление'], + ['后台保活', 'Фоновое продление'], + ['限额', 'Лимит'], + ['配额充足', 'Квоты достаточно'], + ['配额偏低', 'Квота низкая'], + ['加载统计数据失败:', 'Не удалось загрузить статистику:'], + ['Remote Mode(局域网同步)', 'Удалённый режим (синхронизация в LAN)'], + ['工作模式', 'Режим работы'], + ['仅本机', 'Только этот компьютер'], + ['client(本机)', 'client (этот компьютер)'], + ['server(Server 侧)', 'server (на стороне Server)'], + ['solo(本机接管保活)', 'solo (локальное продление)'], + ['Server API 地址(主)', 'Адрес Server API (основной)'], + ['Server API 地址(回退)', 'Адрес Server API (резервный)'], + ['优先尝试,建议填局域网 IP,如 http:', 'Пробуется первым; лучше указать LAN IP, например http:'], + ['主不通时自动切换,建议填 ZeroTier IP,如 http:', 'Если основной недоступен, используется этот; лучше ZeroTier IP, например http:'], + ['共享密钥', 'Общий секрет'], + ['必须与 Server 端一致', 'Должен совпадать с Server'], + ['已生成新密钥,记得保存设置', 'Новый секрет создан, не забудьте сохранить настройки'], + ['监听地址', 'Адрес прослушивания'], + ['监听端口', 'Порт прослушивания'], + ['Server 侧 HTTP API 端口(默认 18081)', 'Порт HTTP API на стороне Server (по умолчанию 18081)'], + ['0.0.0.0 监听所有网卡;建议仅暴露给 ZeroTier 网段', '0.0.0.0 слушает все интерфейсы; лучше открывать только для ZeroTier-сети'], + ['客户端访问需携带 X-Auth-Token;留空则拒绝所有请求', 'Клиент должен передавать X-Auth-Token; пустое значение запрещает все запросы'], + ['测试连通性 / 推送本机账号 / 从 Server 合并', 'Проверить связь / отправить локальные аккаунты / слить с Server'], + ['推送全部', 'Отправить всё'], + ['拉取合并', 'Получить и слить'], + ['同步所有 token', 'Синхронизировать все token'], + ['自动同号', 'Автосовпадение аккаунта'], + ['本机管 current 指针', 'Этот компьютер управляет указателем current'], + ['当前账号只做权威回流;非活跃账号按独占策略保活刷新(保存后生效)', 'Текущий аккаунт только возвращается как authoritative; неактивные аккаунты продлеваются по эксклюзивной стратегии (после сохранения)'], + ['手工拉取 Server 当前账号并热切过去(自动同号关了也可用)', 'Вручную получить текущий аккаунт Server и горячо переключиться на него (работает даже при выключенном автосовпадении)'], + ['立即同号', 'Синхронизировать current'], + ['后台保活与同步', 'Фоновое продление и синхронизация'], + ['非活跃保活阈值(天)', 'Порог неактивного продления (дни)'], + ['当账号 last_refresh 超过该阈值时,调度器才会尝试保活刷新', 'Планировщик будет пытаться продлить аккаунт, когда last_refresh старше этого порога'], + ['调度间隔(分钟)', 'Интервал планировщика (мин)'], + ['自动重载 IDE', 'Автоперезагрузка IDE'], + ['切换账号后自动重载 IDE 以应用新的 Token', 'Автоматически перезагружать IDE после переключения аккаунта, чтобы применить новый токен'], + ['仅重载选中的 IDE', 'Перезагружать только выбранные IDE'], + ['使用杀进程重启', 'Перезапуск через завершение процессов'], + ['使用 pkill 方式重启(Windsurf 推荐,无需权限)', 'Перезапуск через pkill (рекомендуется для Windsurf, без прав администратора)'], + ['修复 Codex App 闪退', 'Исправить сбой запуска Codex App'], + ['移除 macOS 安全隔离属性 (需要管理员权限)', 'Удалить macOS quarantine-атрибут (нужны права администратора)'], + ['这将尝试移除 Codex App 的安全隔离属性。', 'Будет выполнена попытка удалить quarantine-атрибут Codex App.'], + ['系统可能会弹窗要求输入密码以获得权限。是否继续?', 'Система может запросить пароль для получения прав. Продолжить?'], + ['❌ 修复失败:', '❌ Исправление не удалось:'], + ['安装', 'Установить'], + ['仓库已存在', 'Репозиторий уже существует'], + ['读取失败:', 'Не удалось прочитать:'], + ['5H 限额', 'Лимит 5ч'], + ['24H 限额', 'Лимит 24H'], + ['周限额', 'Недельный лимит'], + ['周配额', 'Недельная квота'], + ['5h 配额', 'Квота 5ч'], + ['加载用量...', 'Загрузка usage...'], + ['即将重置', 'Скоро сброс'], + ['后重置', 'до сброса'], + ['小时', 'ч'], + ['分钟', 'мин'], + ['秒', 'с'], + ['未知', 'Неизвестно'], + ['中转账号请用「中转余额刷新」', 'Для relay-аккаунта используйте «обновление баланса relay»'], + ['未设置当前账号', 'Текущий аккаунт не выбран'], + ['网络请求失败,检查代理/网络', 'Сетевой запрос не удался, проверьте прокси/сеть'], + ['刷新失败:网络或服务暂时异常,未判定账号失效', 'Обновление не удалось: временный сбой сети или сервиса, аккаунт не помечен недействительным'], + ['请求超时(OpenAI 端慢/被节流)', 'Тайм-аут запроса (OpenAI медлит или ограничивает)'], + ['refresh_token 刷新失败', 'Не удалось обновить refresh_token'], + ['网络请求失败', 'Сетевой запрос не удался'], + ['已设为手机锚', 'Мобильная привязка включена'], + ['已解除手机锚绑定', 'Мобильная привязка снята'], + ['选择手机锚账号', 'Выберите аккаунт мобильной привязки'], + ['手机锚:磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;切到其他号时 disk 不动、proxy 出口照切', 'Мобильная привязка: ~/.codex/auth.json на диске всегда следует этому аккаунту; телефонное подключение Codex.app привязано к нему. При переходе на другие аккаунты диск не меняется, меняется только выход прокси'], + ['仅 ChatGPT 订阅号可绑定(Codex.app 远程连接需要 chatgpt_account_id 鉴权)', 'Привязать можно только подписной аккаунт ChatGPT, потому что удалённое подключение Codex.app требует chatgpt_account_id'], + ['周期保鲜', 'Продление периода'], + ['保鲜已关', 'Продление выключено'], + ['最近尝试', 'Последняя попытка'], + ['最近成功', 'Последний успех'], + ['最近结果', 'Последний результат'], + ['按 primary_window 实际时长自动识别', 'Автоопределение по фактической длительности primary_window'], + ['接口返回 · 5 小时窗口', 'API вернул окно 5 часов'], + ['接口返回 · 7 天窗口', 'API вернул окно 7 дней'], + ['正在拉取重置次数明细…', 'Загрузка деталей активных сбросов...'], + ['拉取明细失败', 'Не удалось загрузить детали'], + ['Input', 'Вход'], + ['Cached', 'Кэшировано'], + ['Output', 'Выход'], + ['Session Key', 'Ключ сессии'], + ['Cached input', 'Кэшированный вход'], + ['Uncached input', 'Некэшированный вход'], + ['cached / input', 'кэшировано / вход'], + ['owner', 'владелец'], + ['repo', 'репозиторий'], + ['branch', 'ветка'], + ['evidence-based', 'по подтверждённым попаданиям'], + ['evidence-based stickiness', 'привязка по подтверждённым попаданиям'], + ['Email', 'Email'], + ['Free', 'Free'], + ['Plus', 'Plus'], + ['Team', 'Team'], + ['后台服务', 'Фоновые службы'], + ['HTTP 也走本机直连上游', 'HTTP тоже идёт напрямую к upstream с этого компьютера'], + ['IDE 重载', 'Перезагрузка IDE'], + ['主力 IDE', 'Основная IDE'], + ['绑定地址', 'Адрес привязки'], + ['修改端口/绑定/密钥后点此应用(保存设置后)', 'Нажмите после изменения порта, привязки или секрета (после сохранения настроек)'], + ['同步操作', 'Операции синхронизации'], + ['Codex.app 手机锚绑定', 'Телефонная привязка Codex.app'], + ['故障修复', 'Устранение неполадок'], + ['代理服务', 'Прокси-сервис'], + ['代理端口', 'Порт прокси'], + ['开启后监听 `0.0.0.0`,同一局域网内的 Windows 可直接连接这台机器的代理', 'После включения слушает `0.0.0.0`; Windows в той же LAN сможет подключаться к прокси на этом компьютере'], + ['切号模式', 'Режим переключения аккаунтов'], + ['auto(代理开=热切)', 'auto (прокси включен = горячее переключение)'], + ['cold(强制冷切)', 'cold (принудительное холодное переключение)'], + ['Windows 机器可把 `OPENAI_BASE_URL` 指向下面这个地址', 'На Windows укажите `OPENAI_BASE_URL` на этот адрес'], + ['5H', '5ч'], + ['5h', '5ч'], + ['7D', '7д'], + ['7d', '7д'], + ['Spark 5H', 'Spark 5ч'], + ['登录账号 (OpenAI / OTP / 导入)', 'Войти в аккаунт (OpenAI / OTP / импорт)'], + ['用该账号开一个真 codex 终端(隔离 + 直连),可发一句"你好"触发 referral 兑现', 'Открыть настоящий терминал codex для этого аккаунта (изоляция + прямое соединение); можно отправить короткое сообщение, чтобы активировать referral.'], + ['推送到 Server', 'Отправить на Server'], + ['确认删除账号', 'Удалить аккаунт?'], + ['彻底删除', 'Удалить окончательно'], + ['不打开默认浏览器,把授权链接复制到剪贴板,由你粘贴到目标浏览器', 'Не открывать браузер по умолчанию; скопировать ссылку авторизации в буфер, чтобы вставить её в нужный браузер'], + ['刷新额度', 'Обновить квоту'], + ['授权成功', 'Авторизация успешна'], + ['OAuth 授权成功', 'OAuth-авторизация успешна'], + ['无法获取用户目录', 'Не удалось получить домашний каталог пользователя'], + ['设置文件权限失败', 'Не удалось задать права файла'], + ['设置目录权限失败', 'Не удалось задать права каталога'], + ['写入文件失败', 'Не удалось записать файл'], + ['创建目录失败', 'Не удалось создать каталог'], + ['创建临时文件失败', 'Не удалось создать временный файл'], + ['写入临时文件失败', 'Не удалось записать временный файл'], + ['重命名临时文件失败', 'Не удалось переименовать временный файл'], + ['重命名文件失败 (Atomic Write)', 'Не удалось переименовать файл (atomic write)'], + ['读取 auth.json 失败', 'Не удалось прочитать auth.json'], + ['解析 auth.json 失败', 'Не удалось разобрать auth.json'], + ['未找到 Codex auth.json,请先登录 Codex', 'Codex auth.json не найден, сначала войдите в Codex'], + ['账号不存在', 'Аккаунт не найден'], + ['当前账号不存在', 'Текущий аккаунт не найден'], + ['没有激活的账号', 'Нет активного аккаунта'], + ['当前账号缺少 access_token', 'У текущего аккаунта нет access_token'], + ['session 绑定账号不存在', 'Привязанный к session аккаунт не найден'], + ['session 绑定账号缺 access_token', 'У привязанного к session аккаунта нет access_token'], + ['账号到期日格式必须是 YYYY-MM-DD', 'Дата окончания аккаунта должна быть в формате YYYY-MM-DD'], + ['不是中转站账号', 'Это не relay-аккаунт'], + ['无效的 Token 格式', 'Недопустимый формат токена'], + ['Base64 解码失败', 'Не удалось декодировать Base64'], + ['UTF-8 转换失败', 'Не удалось преобразовать UTF-8'], + ['JSON 解析失败', 'Не удалось разобрать JSON'], + ['文件不是 UTF-8 文本', 'Файл не является UTF-8 текстом'], + ['无法识别的 JSON 结构', 'Не удалось распознать структуру JSON'], + ['无法识别的文件格式', 'Не удалось распознать формат файла'], + ['zip 解析失败', 'Не удалось разобрать zip'], + ['缺 email', 'Нет email'], + ['缺 access_token', 'Нет access_token'], + ['缺 refresh_token', 'Нет refresh_token'], + ['无 accounts 数组', 'Нет массива accounts'], + ['缺 credentials', 'Нет credentials'], + ['native: 缺 accounts 对象', 'native: нет объекта accounts'], + ['session JSON 不能为空', 'session JSON не должен быть пустым'], + ['未找到包含 accessToken 的 session 对象', 'Не найден session-объект с accessToken'], + ['缺少 accessToken', 'Нет accessToken'], + ['执行失败', 'Не удалось выполнить'], + ['未找到运行中的 codex 进程', 'Запущенные процессы codex не найдены'], + ['未配置 Server 地址', 'Адрес Server не настроен'], + ['未配置共享密钥', 'Общий секрет не настроен'], + ['共享密钥为空,请先生成', 'Общий секрет пуст, сначала сгенерируйте его'], + ['磁盘账号不在管理列表中,请先导入', 'Аккаунта с диска нет в списке управления, сначала импортируйте его'], + ['手机锚只能设在 ChatGPT 订阅号上', 'Телефонный якорь можно включать только для аккаунта с подпиской ChatGPT'], + ['拒绝同步:身份不匹配', 'Синхронизация отклонена: идентичность не совпадает'], + ['拒绝同步:账号名与 token 邮箱不一致', 'Синхронизация отклонена: имя аккаунта не совпадает с email в токене'], + ['刷新令牌被拒绝', 'Refresh token отклонён'], + ['刷新令牌失败', 'Не удалось обновить refresh token'], + ['发现失败', 'Поиск не удался'], + ['安装失败', 'Установка не удалась'], + ['卸载失败', 'Удаление не удалось'], + ['操作失败', 'Операция не удалась'], + ['同步失败', 'Синхронизация не удалась'], + ['✓ 含 refresh_token', '✓ есть refresh_token'], + ['⚠ 无 refresh_token,到期后需重新导入', '⚠ нет refresh_token, после истечения нужно импортировать заново'], + ['每行一个,自动识别 provider', 'По одному на строку, provider определяется автоматически'], + ['运行中', 'Выполняется'], + ['等待中', 'Ожидает'], + ['Base URL 必须以 http:// 或 https:// 开头', 'Base URL должен начинаться с http:// или https://'], + ['API Key 看起来太短', 'API Key выглядит слишком коротким'], + ['账号名不能为空', 'Имя аккаунта не должно быть пустым'], + ['已封号', 'Заблокирован'], + ['Token 失效', 'Токен недействителен'], + ['日期异常', 'Некорректная дата'], + ['已过期', 'Истёк'], + ['天到期', 'дн до окончания'], + ['启动中…', 'Запуск...'], + ['启动 codex', 'Запустить codex'], + ['周期保鲜最近错误', 'Последняя ошибка продления периода'], + ['周期保鲜会在额度窗口到点后自动发一次最小 Codex 请求', 'После сброса окна квоты продление периода автоматически отправит минимальный запрос Codex'], + ['主动重置', 'Активный сброс'], + ['保活:', 'Продление:'], + ['刷新:', 'Обновление:'], + ['到期:', 'Окончание:'], + ['已选', 'Выбрано'], + ['按账号', 'По аккаунтам'], + ['绑定账号', 'Привязанный аккаунт'], + ['命中次数', 'Попаданий'], + ['年龄', 'Возраст'], + ['花费', 'Расход'], + ['搜索 label / session_id / 账号…', 'Поиск label / session_id / аккаунта...'], + ['搜索 cwd / 首条消息…', 'Поиск cwd / первого сообщения...'], + ['搜索账号…', 'Поиск аккаунта...'], + ['搜索邮箱 / 订阅类型 (如 team / pro / free)…', 'Поиск email / типа подписки (например team / pro / free)...'], + ['模型分布', 'Распределение моделей'], + ['切号原因', 'Причины переключений'], + ['费用', 'Стоимость'], + ['上次', 'Прошлое'], + ['当前', 'Текущий'], + ['周期', 'Период'], + ['订阅', 'Подписка'], + ['代理', 'Прокси'], + ['账号', 'Аккаунт'], + ['额度', 'Квота'], + ['配额', 'Квота'], + ['中转', 'Relay'], + ['保活', 'Продление'], + ['绑定', 'Привязка'], + ['解除', 'Снять'], + ['重试', 'Повтор'], + ['错误', 'Ошибка'], + ['开启', 'Включить'], + ['启用', 'Включено'], + ['禁用', 'Отключено'], + ['已启用', 'Включено'], + ['已禁用', 'Отключено'], + ['已停止', 'Остановлено'], + ['已启动', 'Запущено'], + ['未设置', 'Не задано'], + ['未绑定', 'Не привязано'], + ['自动', 'Авто'], + ['本地', 'Локально'], + ['远程', 'Удалённо'], + ['全量', 'Полный'], + ['备注', 'Заметка'], + ['说明', 'Описание'], + ['来源', 'Источник'], + ['到期', 'Истекает'], + ['邮箱', 'Email'], + ['日期', 'Дата'], + ['当前号', 'текущий аккаунт'], + ['封', 'Блок'], + ['条', 'зап.'], + ['个', 'шт.'], + [',', ', '], + ['。', '. '], + [':', ': '], + ['(', ' ('], + [')', ')'], + ['、', ', '], + [';', '; '], + ['「', '«'], + ['」', '»'], + ['【', '['], + ['】', ']'], +]; + +const sortedReplacements = [...replacements].sort((a, b) => b[0].length - a[0].length); +const exactReplacements = new Map(replacements); +const cjkRe = /[\u4e00-\u9fff]/; +const normalizeWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim(); +const normalizedExactReplacements = new Map( + replacements.map(([source, target]) => [normalizeWhitespace(source), target]), +); +const placeholderRe = /\{[^{}]*\}/g; +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const dynamicReplacements = replacements + .filter(([source]) => /\{[^{}]*\}/.test(source)) + .sort((a, b) => b[0].length - a[0].length) + .map(([source, target]) => { + placeholderRe.lastIndex = 0; + const parts = normalizeWhitespace(source).split(placeholderRe).map(escapeRegExp); + placeholderRe.lastIndex = 0; + return { pattern: new RegExp(`^${parts.join('(.*?)')}$`, 'u'), target }; + }); +const partialReplacements = sortedReplacements.filter(([source]) => ( + cjkRe.test(source) || /[():「」【】、,。;]/.test(source) +)); +export function translateRu(value: string): string { + if (!value) { + return value; + } + const leading = value.match(/^\s*/)?.[0] ?? ''; + const trailing = value.match(/\s*$/)?.[0] ?? ''; + const core = value.trim(); + const exact = exactReplacements.get(core); + if (exact) { + return `${leading}${exact}${trailing}`; + } + const normalizedExact = normalizedExactReplacements.get(normalizeWhitespace(core)); + if (normalizedExact) { + return `${leading}${normalizedExact}${trailing}`; + } + const normalizedCore = normalizeWhitespace(core); + for (const replacement of dynamicReplacements) { + const match = replacement.pattern.exec(normalizedCore); + if (!match) continue; + let capture = 1; + const translated = replacement.target.replace(placeholderRe, () => match[capture++] ?? ''); + return `${leading}${translated}${trailing}`; + } + if (!cjkRe.test(value)) { + return value; + } + + let out = value; + for (const [source, target] of partialReplacements) { + if (out.includes(source)) { + out = out.split(source).join(target); + } + } + out = out + .replace(/(\d+)\s*天后重置/g, '$1 дн до сброса') + .replace(/(\d+)\s*小时(\d+)\s*分钟后重置/g, '$1 ч $2 мин до сброса') + .replace(/(\d+)\s*分钟后重置/g, '$1 мин до сброса') + .replace(/(\d+)\s*天\s*(\d+)\s*小时\s*(\d+)\s*分钟\s*后重置/g, '$1 дн $2 ч $3 мин до сброса') + .replace(/(\d+)\s*小时\s*(\d+)\s*分钟\s*(\d+)\s*秒\s*后重置/g, '$1 ч $2 мин $3 с до сброса') + .replace(/(\d+)\s*分钟\s*(\d+)\s*秒\s*后重置/g, '$1 мин $2 с до сброса') + .replace(/(\d+)\s*秒\s*后重置/g, '$1 с до сброса') + .replace(/(\d+)\s*天到期/g, '$1 дн до окончания') + .replace(/(\d+)\s*天前/g, '$1 дн назад') + .replace(/(\d+)\s*小时前/g, '$1 ч назад') + .replace(/(\d+)\s*分钟前/g, '$1 мин назад') + .replace(/(\d+)\s*个使用额度/g, '$1 кредитов использования') + .replace(/(\d+)\s*个工作区额度/g, '$1 кредитов workspace') + .replace(/(\d+)\s*次限额重置/g, '$1 сбросов лимита') + .replace(/(\d+)\s*个账号/g, '$1 аккаунтов') + .replace(/(\d+)\s*人/g, '$1 чел.') + .replace(/(\d+)\s*条/g, '$1 зап.') + .replace(/(\d+)\s*小\s*ч/g, '$1 ч') + .replace(/(\d+)\s*天/g, '$1 дн') + .replace(/(\d+)\s*小时/g, '$1 ч') + .replace(/(\d+)\s*分钟/g, '$1 мин') + .replace(/(\d+)\s*秒/g, '$1 с') + .replace(/(\d+)\s*时/g, '$1 ч') + .replace(/(\d+)\s*分/g, '$1 мин') + .replace(/(\d+)\s*ч/g, '$1 ч') + .replace(/(\d+)\s*мин/g, '$1 мин') + .replace(/(\d+)\s*с\b/g, '$1 с'); + return out; +} + +export const russianLocale: UiLocale = { + code: 'ru', + title: 'Codex Switcher', + translate: translateRu, +}; + +export function countRussianReplacements(): number { + return replacements.length; +} diff --git a/src/i18n/runtime.ts b/src/i18n/runtime.ts new file mode 100644 index 0000000..e786c51 --- /dev/null +++ b/src/i18n/runtime.ts @@ -0,0 +1,110 @@ +export interface UiLocale { + code: string; + title: string; + translate(value: string): string; +} + +const translatedMark = 'data-ui-locale'; + +function translateTextNode(node: Text, locale: UiLocale) { + const parent = node.parentElement; + if (!parent || parent.closest('script, style, textarea, input, code, pre')) return; + const translated = locale.translate(node.data); + if (translated !== node.data) node.data = translated; +} + +function translateElementAttributes(element: Element, locale: UiLocale) { + for (const attr of ['title', 'placeholder', 'aria-label', 'alt']) { + const value = element.getAttribute(attr); + if (!value) continue; + const translated = locale.translate(value); + if (translated !== value) element.setAttribute(attr, translated); + } +} + +function translateTree(root: ParentNode, locale: UiLocale) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + translateTextNode(node as Text, locale); + node = walker.nextNode(); + } + if (root instanceof Element) translateElementAttributes(root, locale); + root.querySelectorAll?.('[title], [placeholder], [aria-label], [alt]') + .forEach(element => translateElementAttributes(element, locale)); +} + +function wrapDialogs(locale: UiLocale) { + const originalAlert = window.alert.bind(window); + const originalConfirm = window.confirm.bind(window); + const originalPrompt = window.prompt.bind(window); + window.alert = (message?: unknown) => originalAlert(locale.translate(String(message ?? ''))); + window.confirm = (message?: string) => originalConfirm(locale.translate(String(message ?? ''))); + window.prompt = (message?: string, defaultValue?: string) => ( + originalPrompt(locale.translate(String(message ?? '')), defaultValue) + ); +} + +function wrapNotification(locale: UiLocale) { + if (!('Notification' in window)) return; + const OriginalNotification = window.Notification; + const LocalizedNotification = function Notification(title: string, options?: NotificationOptions) { + const localizedOptions = options + ? { ...options, body: options.body ? locale.translate(options.body) : options.body } + : options; + return new OriginalNotification(locale.translate(title), localizedOptions); + } as unknown as typeof Notification; + Object.setPrototypeOf(LocalizedNotification, OriginalNotification); + LocalizedNotification.prototype = OriginalNotification.prototype; + try { + window.Notification = LocalizedNotification; + } catch { + // Some WebView builds expose Notification as read-only. + } +} + +export function installUiLocale(locale: UiLocale) { + document.documentElement.lang = locale.code; + document.title = locale.title; + + const bootStyle = document.createElement('style'); + bootStyle.id = 'ui-locale-boot'; + bootStyle.textContent = `body:not([${translatedMark}="ready"]) { visibility: hidden; }`; + document.head.appendChild(bootStyle); + + wrapDialogs(locale); + wrapNotification(locale); + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'characterData' && mutation.target.nodeType === Node.TEXT_NODE) { + translateTextNode(mutation.target as Text, locale); + continue; + } + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.TEXT_NODE) { + translateTextNode(node as Text, locale); + } else if (node.nodeType === Node.ELEMENT_NODE) { + translateTree(node as Element, locale); + } + } + if (mutation.type === 'attributes' && mutation.target instanceof Element) { + translateElementAttributes(mutation.target, locale); + } + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['title', 'placeholder', 'aria-label', 'alt'], + }); + + queueMicrotask(() => { + translateTree(document.body, locale); + document.body.setAttribute(translatedMark, 'ready'); + bootStyle.remove(); + }); +} diff --git a/src/main.tsx b/src/main.tsx index fc750ba..1b12d5b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,11 +3,23 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { TrayPopup } from "./components/TrayPopup"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { invoke } from "@tauri-apps/api/core"; +import { installAppLocale } from "./i18n"; const label = getCurrentWebviewWindow().label; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - {label === "tray-popup" ? : } - , -); +async function startApp() { + const systemLocale = await invoke("get_system_locale").catch(() => null); + const appLocale = installAppLocale(systemLocale ?? undefined); + await invoke("set_app_locale", { locale: appLocale }).catch((error) => { + console.error("Failed to synchronize the application locale", error); + }); + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {label === "tray-popup" ? : } + , + ); +} + +void startApp();