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 965f65f..fc60650 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/fixtures/i18n-data.html b/scripts/fixtures/i18n-data.html new file mode 100644 index 0000000..f5275e3 --- /dev/null +++ b/scripts/fixtures/i18n-data.html @@ -0,0 +1 @@ +i18n data regression
diff --git a/scripts/fixtures/i18n-data.tsx b/scripts/fixtures/i18n-data.tsx new file mode 100644 index 0000000..f636769 --- /dev/null +++ b/scripts/fixtures/i18n-data.tsx @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { installAppLocale } from '../../src/i18n'; +import '../../src/App.css'; + +installAppLocale('ru-RU'); + +function Fixture() { + const [name, setName] = useState('生产账号A'); + return
+

账号

+

复制 {name}

+

客户备注:测试账号。保持原文。

+

自定义账号,测试。

+ +
; +} + +createRoot(document.getElementById('root')!).render(); diff --git a/scripts/i18n-runtime.test.mjs b/scripts/i18n-runtime.test.mjs new file mode 100644 index 0000000..7c0d854 --- /dev/null +++ b/scripts/i18n-runtime.test.mjs @@ -0,0 +1,86 @@ +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'); +}); + +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 для применения', + ); +}); + +test('runtime leaves opted-out user text and attributes unchanged', () => { + const source = fs.readFileSync('src/i18n/runtime.ts', 'utf8') + + '\nexport { translateTextNode, translateElementAttributes };'; + const loaded = { exports: {} }; + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, { module: loaded, exports: loaded.exports }); + const { translateTextNode, translateElementAttributes } = loaded.exports; + const locale = { translate: translateRu }; + for (const marker of ['[translate="no"]', '[data-i18n-ignore]']) { + const attributes = new Map([['title', '生产账号A']]); + const parent = { + closest: selector => selector.includes(marker) ? parent : null, + getAttribute: key => attributes.get(key), + setAttribute: (key, value) => attributes.set(key, value), + }; + const text = { data: '生产账号A', parentElement: parent }; + translateTextNode(text, locale); + translateElementAttributes(parent, locale); + assert.equal(text.data, '生产账号A'); + assert.equal(attributes.get('title'), '生产账号A'); + } + const text = { data: '账号', parentElement: { closest: () => null } }; + translateTextNode(text, locale); + assert.equal(text.data, 'Аккаунт'); +}); diff --git a/src-tauri/src/i18n/mod.rs b/src-tauri/src/i18n/mod.rs new file mode 100644 index 0000000..0b889f6 --- /dev/null +++ b/src-tauri/src/i18n/mod.rs @@ -0,0 +1,194 @@ +mod ru; +#[allow(dead_code)] +mod zh_cn; + +use ru::RussianLocale; +use std::sync::atomic::{AtomicU8, Ordering}; +use zh_cn::ChineseLocale; + +pub const APP_NAME: &str = "Codex Switcher"; + +trait BackendLocale { + 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; +const LOCALE_AUTO: u8 = 0; +const LOCALE_ZH_CN: u8 = 1; +const LOCALE_RU: u8 = 2; +static ACTIVE_LOCALE: AtomicU8 = AtomicU8::new(LOCALE_AUTO); + +fn active_locale() -> &'static dyn BackendLocale { + match ACTIVE_LOCALE.load(Ordering::Relaxed) { + LOCALE_RU => &RUSSIAN_LOCALE, + LOCALE_ZH_CN => &CHINESE_LOCALE, + _ => { + let language = std::env::var("LANG").unwrap_or_default().to_lowercase(); + if language.starts_with("ru") { + &RUSSIAN_LOCALE + } else { + &CHINESE_LOCALE + } + } + } +} + +#[tauri::command] +pub fn set_app_locale(app: tauri::AppHandle, locale: String) -> Result<(), String> { + let value = match locale.as_str() { + "ru" => LOCALE_RU, + "zh-CN" => LOCALE_ZH_CN, + _ => return Err(format!("Unsupported application locale: {locale}")), + }; + ACTIVE_LOCALE.store(value, Ordering::Relaxed); + 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 b3fe7d5..366667c 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; @@ -6402,6 +6403,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 b41fa6e..7c41802 100644 --- a/src-tauri/src/oauth_server.rs +++ b/src-tauri/src/oauth_server.rs @@ -222,9 +222,7 @@ async fn receive_callback(listener: &TcpListener, expected_state: &str) -> Resul 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 _ = tokio::time::timeout( Duration::from_secs(5), socket.write_all(response.as_bytes()), @@ -233,7 +231,7 @@ async fn receive_callback(listener: &TcpListener, expected_state: &str) -> Resul return Ok(code); } - let response = "HTTP/1.1 400 Bad Request\r\n\r\n授权失败: State 校验不通过或参数缺失"; + let response = crate::i18n::oauth_failure_response(); let _ = tokio::time::timeout( Duration::from_secs(5), socket.write_all(response.as_bytes()), diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs index 6e1a42e..c6215e6 100644 --- a/src-tauri/src/proxy.rs +++ b/src-tauri/src/proxy.rs @@ -2379,8 +2379,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(); }); @@ -2694,8 +2696,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(); }); @@ -2704,7 +2708,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.tsx b/src/App.tsx index cd668db..0fcb642 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -486,7 +486,7 @@ function App() { <>

检测到官方 Codex 插件中存在未同步的 Token 更新。

当前的账号状态与官方文件不一致:

- {conflictAccountName || '当前账号'} + {conflictAccountName || '当前账号'}

直接切换将覆盖官方插件中的当前登录状态,且无法找回这些未同步的更新。

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..7c161c1 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -888,63 +888,65 @@ export function AccountList({ })}
- - {onAddAccount && ( +
- )} - {onAddRelay && ( - + )} + {onAddRelay && ( + + )} + {onRefreshUsage && ( + + )} + - )} - {onRefreshUsage && ( - - )} - +
@@ -1027,6 +1029,7 @@ export function AccountList({ }; return ( 确定要永久删除账号 {accountToDelete?.name} 吗?

此操作不可恢复,删除后有关该账号的本地授权信息将被清除。

} + message={

确定要永久删除账号 {accountToDelete?.name} 吗?

此操作不可恢复,删除后有关该账号的本地授权信息将被清除。

} confirmText="彻底删除" onConfirm={() => { if (accountToDelete) { @@ -1241,7 +1244,7 @@ export function AccountList({

账号到期日

-

{expiryEditor.name}

+

{expiryEditor.name}

@@ -1280,7 +1283,7 @@ export function AccountList({

周期保鲜

-

{primeEditor.name}

+

{primeEditor.name}

@@ -1334,7 +1337,7 @@ export function AccountList({
e.stopPropagation()}>
-

主动重置 · {resetModal.name}

+

主动重置 · {resetModal.name}

@@ -1407,8 +1410,8 @@ export function AccountList({

{isStepFun - ? <>账号:{cookieEditor.name}。登录 platform.stepfun.com 后复制 Oasis-Token,也可粘贴包含它的 Cookie header。 - : <>账号:{cookieEditor.name}。登录 platform.xiaomimimo.com 后,从 Network 请求里复制 Cookie: header。} + ? <>账号:{cookieEditor.name}。登录 platform.stepfun.com 后复制 Oasis-Token,也可粘贴包含它的 Cookie header。 + : <>账号:{cookieEditor.name}。登录 platform.xiaomimimo.com 后,从 Network 请求里复制 Cookie: header。}