From 94fdb6a507fe57c47de3372b3bbeb44bcd04da9c Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Thu, 1 Oct 2026 15:54:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BD=E9=99=85=E5=8C=96=20/=20?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E6=93=8D=E4=BD=9C=20/=20=E6=97=A0=E9=9A=9C?= =?UTF-8?q?=E7=A2=8D=20/=20=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 国际化 - 新增 ui/i18n 双语文案表(中文原文为 key,英文集中映射,tf 支持占位符) - AppSettings 增加 language(跟随系统/中文/English),启动与保存时即时生效 - 全部界面文案(标题栏/侧栏/工作区/发布/订阅/表单/设置/全部对话框/通知)入表 键盘操作 - Ctrl+W 关闭当前标签页;Ctrl+Tab / Ctrl+Shift+Tab 循环切换标签 - Ctrl+F 聚焦当前工作区消息过滤框 无障碍 - 全部纯图标按钮补 accessibility_label(屏幕阅读器 / UIA 自动化可识别) 性能 - 折叠态预览(JSON 美化/压平+截断)改为入环形缓冲时计算一次, 随 MqttRecord 缓存(Arc),渲染热路径对最多数百条可见行 不再逐帧重复 serde_json 解析 --- .github/workflows/release.yml | 12 + Cargo.lock | 1 + README.md | 5 +- gpui-app/Cargo.toml | 1 + gpui-app/src/model/message.rs | 89 ++++ gpui-app/src/model/mod.rs | 1 + gpui-app/src/model/settings.rs | 29 ++ gpui-app/src/mqtt/v4.rs | 1 + gpui-app/src/mqtt/v5.rs | 1 + gpui-app/src/ui/app/actions.rs | 34 +- gpui-app/src/ui/app/events.rs | 11 +- gpui-app/src/ui/app/io.rs | 18 +- gpui-app/src/ui/app/main_area.rs | 7 +- gpui-app/src/ui/app/mod.rs | 80 +++- gpui-app/src/ui/app/sidebar.rs | 41 +- gpui-app/src/ui/app/titlebar.rs | 39 +- gpui-app/src/ui/connection_form.rs | 202 ++++----- gpui-app/src/ui/connection_view/messages.rs | 87 ++-- gpui-app/src/ui/connection_view/mod.rs | 40 +- gpui-app/src/ui/connection_view/publish.rs | 74 ++-- gpui-app/src/ui/connection_view/subscribe.rs | 56 ++- gpui-app/src/ui/connection_view/util.rs | 76 ---- gpui-app/src/ui/connection_view/vars.rs | 23 +- gpui-app/src/ui/dialogs/aliyun_dialog.rs | 49 +-- gpui-app/src/ui/dialogs/presets_dialog.rs | 76 ++-- gpui-app/src/ui/dialogs/resource_dialog.rs | 40 +- gpui-app/src/ui/dialogs/settings_dialog.rs | 136 +++--- gpui-app/src/ui/dialogs/variables_dialog.rs | 11 +- gpui-app/src/ui/i18n.rs | 431 +++++++++++++++++++ gpui-app/src/ui/mod.rs | 1 + gpui-app/src/ui/widgets/kv_editor.rs | 2 +- 31 files changed, 1197 insertions(+), 477 deletions(-) create mode 100644 gpui-app/src/ui/i18n.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e38ba1..d77fbfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,7 +140,10 @@ jobs: needs: [meta, build] runs-on: ubuntu-latest steps: + # fetch-depth: 0 拉取附注 tag 对象,发布正文取自 tag message - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/download-artifact@v4 with: @@ -177,8 +180,17 @@ jobs: VER: ${{ needs.meta.outputs.version }} run: | set -euo pipefail + # 发布正文优先取附注 tag 的 message(剥掉签名块),无附注时回退自动生成 + notes="$(git tag -l --format='%(contents)' "$VER" | sed '/^-----BEGIN PGP SIGNATURE----$/,/^-----END PGP SIGNATURE----$/d' || true)" + notes="$(echo "$notes" | sed -e '/^[[:space:]]*$/d' | sed -e ':a;N;$!ba;s/ +$//')" if gh release view "$VER" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "Release ${VER} 已存在,覆盖上传资产" + if [ -n "$notes" ]; then + gh release edit "$VER" -R "$GITHUB_REPOSITORY" --notes "$notes" + fi + elif [ -n "$notes" ]; then + gh release create "$VER" -R "$GITHUB_REPOSITORY" --title "$VER" --notes "$notes" else gh release create "$VER" -R "$GITHUB_REPOSITORY" --title "$VER" --generate-notes fi diff --git a/Cargo.lock b/Cargo.lock index 0f69a57..b6f9b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4273,6 +4273,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "smol", + "sys-locale", "tokio", "ureq", "uuid", diff --git a/README.md b/README.md index a3b8e09..9db2588 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ - **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接 - **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性 - **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等),按日落盘 `mqttx-YYYY-MM-DD.log`,设置内一键打开日志目录 -- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Ctrl+B` 收起/展开连接侧栏、`Enter` 提交订阅、`Esc` 退出编辑/清空输入 +- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Ctrl+B` 收起/展开连接侧栏、`Ctrl+W` 关闭标签页、`Ctrl+Tab` / `Ctrl+Shift+Tab` 切换标签、`Ctrl+F` 聚焦消息过滤、`Enter` 提交订阅、`Esc` 退出编辑/清空输入 - **抽屉式布局** — 连接侧栏(持久化,Ctrl+B)、订阅面板、底部发布面板均可一键收起为窄条抽屉,最大化消息流空间 +- **国际化** — 中文 / English 双语界面,默认跟随系统语言,设置内可切换即时生效 +- **无障碍** — 全部图标按钮带可访问名称(屏幕阅读器 / UIA 可识别),完整键盘操作路径 +- **性能** — 消息预览(JSON 美化/截断)入缓冲时计算一次并随记录缓存,渲染热路径零重复解析 - **主题** — Linear 风格配色(近黑/纯白扁平底 + 靛蓝 #5e6ad2 主色,14px 紧凑排版、6px 圆角),浅色 / 深色 / 跟随系统 - **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可) - **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录 diff --git a/gpui-app/Cargo.toml b/gpui-app/Cargo.toml index 0f8648a..1931cad 100644 --- a/gpui-app/Cargo.toml +++ b/gpui-app/Cargo.toml @@ -25,6 +25,7 @@ rumqttc = { package = "rumqttc-v5-next", version = "0.34", features = ["websocke rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34", features = ["websocket"] } uuid = { version = "1", features = ["v4"] } +sys-locale = "0.3" chrono = { version = "0.4", features = ["clock"] } dirs = "7" diff --git a/gpui-app/src/model/message.rs b/gpui-app/src/model/message.rs index 438a981..a2794cd 100644 --- a/gpui-app/src/model/message.rs +++ b/gpui-app/src/model/message.rs @@ -107,6 +107,9 @@ pub struct MqttRecord { /// 非文本负载的原始字节(合法 UTF-8 时为 None)。文本化会失真 /// (非法字节被替换为 U+FFFD),详情面板的 Hex/Base64 需要真实报文。 pub raw_bytes: Option>, + /// 折叠态预览文本(JSON 美化/压平 + 截断),入环形缓冲时计算一次。 + /// 渲染热路径每帧对最多数百条可见行直接取用,避免逐帧重复 JSON 解析。 + pub preview: Arc, } impl MqttRecord { @@ -115,6 +118,59 @@ impl MqttRecord { pub fn retained_bytes(&self) -> usize { self.payload.len() + self.raw_bytes.as_ref().map_or(0, |b| b.len()) } + + /// 入环形缓冲前调用:计算并填充折叠态预览(每条消息仅此一次)。 + pub fn compute_preview(&mut self) { + self.preview = Arc::from(compute_preview(&self.payload)); + } +} + +/// 折叠态预览行数与字符上限:JSON 美化后多行完整展示,超大负载兜底截断 +const PREVIEW_MAX_LINES: usize = 40; +const PREVIEW_MAX_CHARS: usize = 4000; + +/// 计算折叠态预览:JSON 负载按原始数据美化展示(缩进多行),非 JSON +/// 压平换行/制表符截 200 字符;含替换字符(U+FFFD)提示切 Hex。 +pub fn compute_preview(payload: &str) -> String { + let parsed = serde_json::from_str::(payload).ok(); + let mut out = match &parsed { + Some(value) => { + let pretty = serde_json::to_string_pretty(value).unwrap_or_default(); + truncate_preview(pretty) + } + None => { + let flat: String = payload + .chars() + .map(|c| match c { + '\n' | '\r' | '\t' => ' ', + _ => c, + }) + .collect(); + let mut s: String = flat.chars().take(200).collect(); + if flat.chars().count() > 200 { + s.push('…'); + } + s + } + }; + if payload.contains('\u{FFFD}') { + out.push_str("(非文本,详情可切 Hex)"); + } + out +} + +/// 截断预览文本:最多 [`PREVIEW_MAX_LINES`] 行、[`PREVIEW_MAX_CHARS`] 字符。 +fn truncate_preview(text: String) -> String { + let mut lines: Vec<&str> = text.lines().take(PREVIEW_MAX_LINES).collect(); + let more = text.lines().count() > PREVIEW_MAX_LINES; + if more { + lines.push("…"); + } + let mut out = lines.join("\n"); + if out.chars().count() > PREVIEW_MAX_CHARS { + out = out.chars().take(PREVIEW_MAX_CHARS).collect::() + "…"; + } + out } /// 单连接的消息环形缓冲:条数上限 + [`MAX_CONNECTION_MESSAGE_BYTES`] @@ -199,3 +255,36 @@ pub fn retained_payload(raw: &[u8]) -> (Arc, Option>, bool) { } // ─── 发布参数 / 发布预设 ────────────────────────────────────────────────────── + +#[cfg(test)] +mod preview_tests { + use super::compute_preview; + + #[test] + fn preview_json_is_pretty_and_complete_for_typical_payloads() { + let payload = r#"{"type":"heartbeat","ts":1790751279869,"data":{"version":"0.1.0","lon":117.229,"lat":31.8206,"uptime":1716450,"rssi":-52}}"#; + let out = compute_preview(payload); + // JSON 被美化(多行缩进)且常规负载完整展示、不带任何加工注释 + assert!(out.contains('\n'), "JSON 应多行展示: {out}"); + assert!(out.contains("\"rssi\": -52"), "常规负载应完整显示: {out}"); + assert!(!out.contains("//"), "预览不应附加注释: {out}"); + assert!(!out.contains('…'), "常规负载不应截断: {out}"); + } + + #[test] + fn preview_truncates_very_deep_json() { + // 50 个元素的数组美化后超过 40 行,应截断并带省略号 + let items: Vec = (0..50).map(|i| i.to_string()).collect(); + let payload = format!("{{\"a\":[{}]}}", items.join(",")); + let deep = compute_preview(&payload); + assert!(deep.lines().count() <= 41, "超行应截断: {deep}"); + assert!(deep.contains('…'), "截断应有省略号: {deep}"); + } + + #[test] + fn preview_non_json_still_flat() { + assert_eq!(compute_preview("hello\nworld"), "hello world"); + assert_eq!(compute_preview("纯文本"), "纯文本"); + assert!(compute_preview("bad\ntext\u{FFFD}").contains("非文本")); + } +} diff --git a/gpui-app/src/model/mod.rs b/gpui-app/src/model/mod.rs index ef4eb11..f2bee45 100644 --- a/gpui-app/src/model/mod.rs +++ b/gpui-app/src/model/mod.rs @@ -339,6 +339,7 @@ mod tests { } else { None }, + preview: Arc::from(""), } } diff --git a/gpui-app/src/model/settings.rs b/gpui-app/src/model/settings.rs index d52daf1..3838864 100644 --- a/gpui-app/src/model/settings.rs +++ b/gpui-app/src/model/settings.rs @@ -49,10 +49,38 @@ impl ThemeModePref { } } +/// 界面语言偏好。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum LanguagePref { + /// 首次启动按系统语言解析(zh 开头 → 中文,其余 → English) + #[default] + System, + /// 中文 + Zh, + /// English + En, +} + +impl LanguagePref { + pub const ALL: [Self; 3] = [Self::System, Self::Zh, Self::En]; + + pub fn label(self) -> &'static str { + match self { + Self::System => "跟随系统 / System", + Self::Zh => "中文", + Self::En => "English", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { #[serde(default)] pub theme: ThemeModePref, + /// 界面语言(跟随系统 / 中文 / English) + #[serde(default)] + pub language: LanguagePref, /// 每条连接在内存中保留的最大消息条数 #[serde(default = "default_max_messages")] pub max_messages: usize, @@ -72,6 +100,7 @@ impl Default for AppSettings { fn default() -> Self { Self { theme: ThemeModePref::default(), + language: LanguagePref::default(), max_messages: default_max_messages(), show_millis: true, auto_check_update: true, diff --git a/gpui-app/src/mqtt/v4.rs b/gpui-app/src/mqtt/v4.rs index 124f536..72997f6 100644 --- a/gpui-app/src/mqtt/v4.rs +++ b/gpui-app/src/mqtt/v4.rs @@ -209,6 +209,7 @@ pub(super) fn spawn_v4_loop( subscription_identifier: None, payload_truncated, raw_bytes, + preview: Arc::from(""), }; engine.log( &id, LogLevel::Info, "publish_received", diff --git a/gpui-app/src/mqtt/v5.rs b/gpui-app/src/mqtt/v5.rs index 10163f3..eced9a3 100644 --- a/gpui-app/src/mqtt/v5.rs +++ b/gpui-app/src/mqtt/v5.rs @@ -159,6 +159,7 @@ fn v5_record(engine: &MqttEngine, seq: u64, conn_id: &str, p: &V5Publish) -> Mqt .and_then(|p| p.subscription_identifiers.first().map(|v| *v as u32)), payload_truncated, raw_bytes, + preview: Arc::from(""), } } diff --git a/gpui-app/src/ui/app/actions.rs b/gpui-app/src/ui/app/actions.rs index e94a7d0..80a407b 100644 --- a/gpui-app/src/ui/app/actions.rs +++ b/gpui-app/src/ui/app/actions.rs @@ -11,6 +11,7 @@ use crate::ui::IconName; use crate::ui::{ connection_form::ConnectionForm, connection_view::ConnectionView, }; use super::*; +use crate::ui::i18n; impl MqttXApp { pub fn save_connection(&mut self, cfg: ConnectionConfig) { @@ -117,6 +118,23 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { cx.notify(); } + /// Ctrl+Tab / Ctrl+Shift+Tab:按方向在打开的标签页间循环切换。 + pub fn cycle_tab(&mut self, dir: i32, cx: &mut Context) { + if self.open_tabs.is_empty() { + return; + } + let cur = self + .active_tab + .as_ref() + .and_then(|a| self.open_tabs.iter().position(|t| t == a)) + .unwrap_or(0); + let len = self.open_tabs.len() as i32; + let next = ((cur as i32 + dir).rem_euclid(len)) as usize; + self.active_tab = Some(self.open_tabs[next].clone()); + self.persist_ui_state(); + cx.notify(); + } + // ── 订阅 ────────────────────────────────────────────────────────────── pub fn add_subscription(&mut self, sub: Subscription) { @@ -171,6 +189,12 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { pub fn save_settings(&mut self, settings: AppSettings, window: &mut Window, cx: &mut App) { apply_theme(settings.theme, cx); + // 语言即时生效(含跟随系统) + crate::ui::i18n::set_lang(match settings.language { + crate::model::LanguagePref::Zh => crate::ui::i18n::Lang::Zh, + crate::model::LanguagePref::En => crate::ui::i18n::Lang::En, + crate::model::LanguagePref::System => crate::ui::i18n::from_system(), + }); self.settings = settings; self.storage.save_settings(&self.settings); // 切主题只更新全局令牌,不触发整窗重绘,未损坏区域会长期滞留旧主题 @@ -204,7 +228,7 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { let testing = form_test.read(cx).is_testing(); dialog .w(px(680.)) - .title("连接配置") + .title(i18n::t("连接配置")) .child(form.clone()) .footer( h_flex() @@ -218,7 +242,7 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { } else { IconName::PlugZap }) - .label(if testing { "测试中…" } else { "测试连接" }) + .label(if testing { i18n::t("测试中…") } else { i18n::t("测试连接") }) .outline() .loading(testing) .on_click(move |_, window, cx| { @@ -228,13 +252,13 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { .child(div().flex_1()) .child( Button::new("form-cancel") - .label("取消") + .label(i18n::t("取消")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("form-ok") - .label("保存") + .label(i18n::t("保存")) .primary() .on_click(move |_, window, cx| { let built = form_ok.read(cx).build(cx); @@ -264,7 +288,7 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { window.close_dialog(cx); if stale { window.push_notification( - Notification::info("配置已保存,重连后生效"), + Notification::info(i18n::t("配置已保存,重连后生效")), cx, ); } diff --git a/gpui-app/src/ui/app/events.rs b/gpui-app/src/ui/app/events.rs index 3bdeb10..020cb42 100644 --- a/gpui-app/src/ui/app/events.rs +++ b/gpui-app/src/ui/app/events.rs @@ -9,6 +9,7 @@ use crate::model::{ ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, use crate::mqtt::EngineEvent; use super::*; +use crate::ui::i18n; impl MqttXApp { pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context) { @@ -84,6 +85,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window subscription_identifier: None, payload_truncated, raw_bytes, + preview: Arc::from(""), }; self.push_message(Arc::from(connection_id.as_str()), record); self.notify_message_view(&connection_id, cx); @@ -109,7 +111,10 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window } else { if let Some(e) = error { window.push_notification( - Notification::error(format!("订阅失败: {e}")), + Notification::error(i18n::tf( + "订阅失败: {e}", + &[("e", &e)], + )), cx, ); } @@ -172,7 +177,9 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window self.seq } - fn push_message(&mut self, connection_id: Arc, record: MqttRecord) { + fn push_message(&mut self, connection_id: Arc, mut record: MqttRecord) { + // 折叠态预览在此唯一入口计算一次;渲染热路径逐帧直接取用 + record.compute_preview(); let cap = self.settings.max_messages; self.messages .entry(connection_id) diff --git a/gpui-app/src/ui/app/io.rs b/gpui-app/src/ui/app/io.rs index f399ee5..f7b10a0 100644 --- a/gpui-app/src/ui/app/io.rs +++ b/gpui-app/src/ui/app/io.rs @@ -7,6 +7,7 @@ use gpui_kit::{ Context, Window, }; use super::*; +use crate::ui::i18n; impl MqttXApp { /// 数据目录(设置对话框展示 / 打开用)。 @@ -76,9 +77,12 @@ impl MqttXApp { let count = app.connections.len(); match crate::store::export_connections_to(&path, &app.connections) { Ok(()) => window.push_notification( - Notification::success(format!( - "已导出 {count} 条连接到 {}", - path.display() + Notification::success(i18n::tf( + "导出 {n} 条连接到 {path}", + &[ + ("n", &count.to_string()), + ("path", &path.display().to_string()), + ], )), cx, ), @@ -129,7 +133,13 @@ impl MqttXApp { app.storage.save_connections(&app.connections); } window.push_notification( - Notification::success(format!("导入 {added} 条,跳过 {skipped} 条")), + Notification::success(i18n::tf( + "导入 {n} 条,跳过 {n2} 条", + &[ + ("n", &added.to_string()), + ("n2", &skipped.to_string()), + ], + )), cx, ); } diff --git a/gpui-app/src/ui/app/main_area.rs b/gpui-app/src/ui/app/main_area.rs index 46b05d6..3a72760 100644 --- a/gpui-app/src/ui/app/main_area.rs +++ b/gpui-app/src/ui/app/main_area.rs @@ -9,6 +9,7 @@ use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, }; use crate::ui::IconName; use super::*; +use crate::ui::i18n; impl MqttXApp { pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -26,18 +27,18 @@ pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context Lang::Zh, + crate::model::LanguagePref::En => Lang::En, + crate::model::LanguagePref::System => i18n::from_system(), + }); // 有界事件队列:引擎侧对高频事件(消息/日志)做丢弃并计数,保证 UI 消费 // 不过来时内存有上界。1024 × 单条最大 128KB ≈ 最坏 128MB,常规消息下几百 KB。 @@ -209,12 +216,12 @@ impl MqttXApp { let staged_consent = staged.clone(); dialog .w(px(440.)) - .title("更新就绪") + .title(i18n::t("更新就绪")) .child( v_flex().gap_2().child( - div().text_sm().child(format!( - "新版本 v{} 已下载完成(已通过 sha256 校验)。", - staged.version + div().text_sm().child(i18n::tf( + "新版本 v{v} 已下载完成(已通过 sha256 校验)。", + &[("v", &staged.version)], )), ), ) @@ -225,7 +232,7 @@ impl MqttXApp { .w_full() .child( Button::new("upd-skip") - .label("暂不更新") + .label(i18n::t("暂不更新")) .outline() .on_click(move |_, window, cx| { // 拒绝本次更新:删除暂存包, @@ -236,7 +243,7 @@ impl MqttXApp { ) .child( Button::new("upd-later") - .label("下次启动安装") + .label(i18n::t("下次启动安装")) .outline() .on_click(move |_, window, cx| { // 同意标记:启动时据此自动安装; @@ -247,7 +254,7 @@ impl MqttXApp { ) .child( Button::new("upd-now") - .label("立即安装") + .label(i18n::t("立即安装")) .primary() .on_click(move |_, window, cx| { window.close_dialog(cx); @@ -255,9 +262,10 @@ impl MqttXApp { update::install_staged(&staged) { window.push_notification( - Notification::error(format!( - "安装失败:{e}" - )), + Notification::error(i18n::tf( + "安装失败:{e}", + &[("e", &e), + ])), cx, ); } else { @@ -284,7 +292,7 @@ impl MqttXApp { .detach(); } - let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…")); + let search = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("搜索连接…"))); // 自验后门:MQTTX_OPEN_RESMON=1 启动时直接打开资源监控 // (与 MQTTX_VERSION_OVERRIDE 同类的测试入口,供自动化验证 UI)。 @@ -323,6 +331,10 @@ impl MqttXApp { KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), KeyBinding::new("ctrl-enter", PublishMessage, None), KeyBinding::new("ctrl-b", ToggleSidebar, None), + KeyBinding::new("ctrl-w", CloseActiveTab, None), + KeyBinding::new("ctrl-tab", NextTab, None), + KeyBinding::new("ctrl-shift-tab", PrevTab, None), + KeyBinding::new("ctrl-f", FocusMessageFilter, None), ]); let weak = cx.entity().downgrade(); // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 @@ -402,6 +414,50 @@ impl MqttXApp { entity.update(cx, |app, cx| app.toggle_sidebar(cx)); }); }); + // Ctrl+W 关闭当前标签页 + let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, _, cx| { + entity.update(cx, |app, cx| { + if let Some(tab) = app.active_tab.clone() { + app.close_tab(&tab, cx); + } + }); + }); + }); + // Ctrl+Tab / Ctrl+Shift+Tab 在打开的标签页间循环切换 + let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, _, cx| { + entity.update(cx, |app, cx| app.cycle_tab(1, cx)); + }); + }); + let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, _, cx| { + entity.update(cx, |app, cx| app.cycle_tab(-1, cx)); + }); + }); + // Ctrl+F 聚焦当前工作区的消息过滤框 + let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, window, cx| { + if window.has_active_dialog(cx) { + return; + } + entity.update(cx, |app, cx| { + if let Some(tab) = app.active_tab.clone() + && let Some(view) = app.views.get(&tab).cloned() + { + view.update(cx, |view, cx| view.focus_filter(window, cx)); + } + }); + }); + }); Self { storage, diff --git a/gpui-app/src/ui/app/sidebar.rs b/gpui-app/src/ui/app/sidebar.rs index 9affcf3..718b6ff 100644 --- a/gpui-app/src/ui/app/sidebar.rs +++ b/gpui-app/src/ui/app/sidebar.rs @@ -13,6 +13,7 @@ use crate::ui::IconName; use crate::ui::widgets::status_color; use super::*; +use crate::ui::i18n; impl MqttXApp { /// 侧栏收起后的窄 rail:抽屉把手(拉回侧栏)+ 新建连接入口。 @@ -33,7 +34,8 @@ impl MqttXApp { .icon(IconName::PanelLeftOpen) .ghost() .small() - .tooltip("展开连接侧栏(Ctrl+B)") + .tooltip(i18n::t("展开连接侧栏(Ctrl+B)")) + .accessibility_label(i18n::t("展开连接侧栏(Ctrl+B)")) .on_click(cx.listener(|this, _, _, cx| this.toggle_sidebar(cx))), ) .child( @@ -41,7 +43,8 @@ impl MqttXApp { .icon(IconName::Plus) .ghost() .small() - .tooltip("新建连接") + .tooltip(i18n::t("新建连接")) + .accessibility_label(i18n::t("新建连接")) .on_click(cx.listener(|this, _, window, cx| { this.open_connection_form(None, window, cx); })), @@ -171,7 +174,7 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -26,9 +27,14 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< .ghost() .small() .tooltip(if self.pinned { - "取消窗口置顶" + i18n::t("取消窗口置顶") } else { - "窗口置顶" + i18n::t("置顶窗口") + }) + .accessibility_label(if self.pinned { + i18n::t("取消窗口置顶") + } else { + i18n::t("置顶窗口") }) .on_click(cx.listener(|this, _, window, cx| { let next = !this.pinned; @@ -37,7 +43,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< cx.notify(); } else { window.push_notification( - Notification::error("置顶操作失败"), + Notification::error(i18n::t("置顶操作失败")), cx, ); } @@ -68,7 +74,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("MQTT 调试客户端"), + .child(i18n::t("MQTT 调试客户端")), ), ) .child( @@ -84,7 +90,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< .child( Button::new("new-connection") .icon(IconName::Plus) - .label("新建连接") + .label(i18n::t("新建连接")) .primary() .small() .on_click(cx.listener(|this, _, window, cx| { @@ -106,21 +112,21 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< let w5 = app_weak.clone(); let w6 = app_weak.clone(); menu.item( - PopupMenuItem::new("阿里云设备") + PopupMenuItem::new(i18n::t("阿里云设备")) .on_click(move |_, window, cx| { if let Some(entity) = w1.upgrade() { crate::ui::dialogs::aliyun_dialog::open(entity, window, cx); } }), ) - .item(PopupMenuItem::new("全局变量").on_click( + .item(PopupMenuItem::new(i18n::t("全局变量")).on_click( move |_, window, cx| { if let Some(entity) = w2.upgrade() { crate::ui::dialogs::variables_dialog::open(entity, window, cx); } }, )) - .item(PopupMenuItem::new("资源监控").on_click( + .item(PopupMenuItem::new(i18n::t("资源监控")).on_click( move |_, window, cx| { if let Some(entity) = w6.upgrade() { crate::ui::dialogs::resource_dialog::open(entity, window, cx); @@ -128,7 +134,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< }, )) .separator() - .item(PopupMenuItem::new("导出连接").on_click( + .item(PopupMenuItem::new(i18n::t("导出连接")).on_click( move |_, window, cx| { if let Some(entity) = w4.upgrade() { entity.update(cx, |app, cx| { @@ -137,7 +143,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< } }, )) - .item(PopupMenuItem::new("导入连接").on_click( + .item(PopupMenuItem::new(i18n::t("导入连接")).on_click( move |_, window, cx| { if let Some(entity) = w5.upgrade() { entity.update(cx, |app, cx| { @@ -147,7 +153,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< }, )) .separator() - .item(PopupMenuItem::new("设置").on_click( + .item(PopupMenuItem::new(i18n::t("设置")).on_click( move |_, window, cx| { if let Some(entity) = w3.upgrade() { crate::ui::dialogs::settings_dialog::open(entity, window, cx); @@ -166,7 +172,16 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context< .icon(if dark { IconName::Sun } else { IconName::Moon }) .ghost() .small() - .tooltip(if dark { "切换为浅色" } else { "切换为深色" }) + .tooltip(if dark { + i18n::t("切换为浅色") + } else { + i18n::t("切换为深色") + }) + .accessibility_label(if dark { + i18n::t("切换为浅色") + } else { + i18n::t("切换为深色") + }) .on_click(cx.listener(|this, _, window, cx| { let next = if cx.theme().mode == ThemeMode::Dark { ThemeModePref::Light diff --git a/gpui-app/src/ui/connection_form.rs b/gpui-app/src/ui/connection_form.rs index 5fd11a5..b455193 100644 --- a/gpui-app/src/ui/connection_form.rs +++ b/gpui-app/src/ui/connection_form.rs @@ -15,6 +15,7 @@ use gpui_kit::component::{ }; use gpui_kit::prelude::FluentBuilder as _; use crate::ui::IconName; +use crate::ui::i18n; use gpui_kit::{ div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, @@ -25,11 +26,11 @@ use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, SslConfig, Trans use crate::mqtt::MqttEngine; use crate::ui::widgets::{field, field_ex, make_select, OptionDelegate}; -const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"]; -const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"]; -const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; +fn protocol_labels() -> [&'static str; 2] { [i18n::t("MQTT 5.0"), "MQTT 3.1.1"] } +const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"]; // 语言无关 +const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; // 语言无关 /// 分组下拉的首项:选择它表示不归属任何分组。 -const NO_GROUP: &str = "无分组"; +fn no_group_label() -> &'static str { i18n::t("无分组") } pub struct ConnectionForm { engine: Arc, @@ -142,28 +143,28 @@ impl ConnectionForm { chrono::Local::now().timestamp() }); - let name = input(&c.name, "连接名称", window, cx); - let host = input(&c.host, "broker.example.com", window, cx); - let port = input(&c.port.to_string(), "端口", window, cx); - let path = input(&c.path, "/mqtt", window, cx); - let client_id = input(&c.client_id, "Client ID", window, cx); - let username = input(&c.username, "用户名(可选)", window, cx); + let name = input(&c.name, i18n::t("连接名称"), window, cx); + let host = input(&c.host, i18n::t("broker.example.com"), window, cx); + let port = input(&c.port.to_string(), i18n::t(i18n::t("端口")), window, cx); + let path = input(&c.path, i18n::t("/mqtt"), window, cx); + let client_id = input(&c.client_id, i18n::t("Client ID"), window, cx); + let username = input(&c.username, i18n::t("用户名(可选)"), window, cx); let password = cx.new(|cx| { InputState::new(window, cx) - .placeholder("密码(可选)") + .placeholder(i18n::t("密码(可选)")) .default_value(&c.password) .masked(true) }); - let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx); + let keep_alive = input(&c.keep_alive.to_string(), i18n::t("60"), window, cx); let conn_timeout = input( &c.connection_timeout_secs.to_string(), - "秒(默认 10)", + i18n::t("秒(默认 10)"), window, cx, ); let max_reconnect = input( &c.max_reconnect_times.to_string(), - "0=无限重连", + i18n::t("0=无限重连"), window, cx, ); @@ -173,44 +174,44 @@ impl ConnectionForm { } else { String::new() }, - "秒(0=会话随连接结束)", + i18n::t("秒(0=会话随连接结束)"), window, cx, ); - let receive_max = optional_input(c.receive_maximum.map(u32::from), "服务端接收上限", window, cx); - let max_packet = optional_input(c.maximum_packet_size, "最大报文长度", window, cx); - let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), "主题别名上限", window, cx); + let receive_max = optional_input(c.receive_maximum.map(u32::from), i18n::t("服务端接收上限"), window, cx); + let max_packet = optional_input(c.maximum_packet_size, i18n::t("最大报文长度"), window, cx); + let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), i18n::t("主题别名上限"), window, cx); - let ssl_ca = input(&c.ssl.ca_file, "CA 证书 PEM 路径(可选)", window, cx); - let ssl_client_cert = input(&c.ssl.client_cert_file, "客户端证书 PEM 路径(可选)", window, cx); - let ssl_client_key = input(&c.ssl.client_key_file, "客户端私钥 PEM 路径(可选)", window, cx); + let ssl_ca = input(&c.ssl.ca_file, i18n::t("CA 证书 PEM 路径(可选)"), window, cx); + let ssl_client_cert = input(&c.ssl.client_cert_file, i18n::t("客户端证书 PEM 路径(可选)"), window, cx); + let ssl_client_key = input(&c.ssl.client_key_file, i18n::t("客户端私钥 PEM 路径(可选)"), window, cx); - let group_new = input("", "填写后优先于左侧所选分组", window, cx); + let group_new = input("", i18n::t("填写后优先于左侧所选分组"), window, cx); let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx, will_content_type, will_response_topic) = match &c.last_will { Some(w) => ( - input(&w.topic, "遗嘱主题", window, cx), - input(&w.payload, "遗嘱消息", window, cx), + input(&w.topic, i18n::t("遗嘱主题"), window, cx), + input(&w.payload, i18n::t("遗嘱消息"), window, cx), true, w.retain, w.qos as usize, - input(w.content_type.as_deref().unwrap_or(""), "内容类型(可选)", window, cx), - input(w.response_topic.as_deref().unwrap_or(""), "响应主题(可选)", window, cx), + input(w.content_type.as_deref().unwrap_or(""), i18n::t("内容类型(可选)"), window, cx), + input(w.response_topic.as_deref().unwrap_or(""), i18n::t("响应主题(可选)"), window, cx), ), None => ( - input("", "遗嘱主题", window, cx), - input("", "遗嘱消息", window, cx), + input("", i18n::t("遗嘱主题"), window, cx), + input("", i18n::t("遗嘱消息"), window, cx), false, false, 0, - input("", "内容类型(可选)", window, cx), - input("", "响应主题(可选)", window, cx), + input("", i18n::t("内容类型(可选)"), window, cx), + input("", i18n::t("响应主题(可选)"), window, cx), ), }; let protocol = make_select( - &PROTOCOLS, + &protocol_labels(), if c.protocol.is_v5() { 0 } else { 1 }, window, cx, @@ -222,7 +223,7 @@ impl ConnectionForm { let transport = make_select(&TRANSPORTS, transport_idx, window, cx); let will_qos = make_select(&QOS, will_qos_idx, window, cx); // 分组下拉:首项固定为「无分组」,其后是已有分组 - let mut group_labels = vec![NO_GROUP]; + let mut group_labels = vec![no_group_label()]; group_labels.extend(groups.iter().map(|s| s.as_str())); let group_idx = c .group @@ -398,39 +399,39 @@ impl ConnectionForm { r.map_err(|e| self.fail(key, e)) } - fn parse_u16(s: &str, label: &str) -> Result { + fn parse_u16(s: &str, label: &'static str) -> Result { s.trim() .parse::() - .map_err(|_| format!("{label} 需要是 0~65535 的整数")) + .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("需要是 0~65535 的整数"))) } /// 端口:1~65535。0 不是可连接端口,放行只会在 CONNECT 阶段报出难以理解的错误。 fn parse_port(s: &str) -> Result { match s.trim().parse::() { - Ok(0) => Err("端口需要是 1~65535 的整数".into()), + Ok(0) => Err(i18n::t("端口需要是 1~65535 的整数").to_string()), Ok(p) => Ok(p), - Err(_) => Err("端口需要是 1~65535 的整数".into()), + Err(_) => Err(i18n::t("端口需要是 1~65535 的整数").to_string()), } } /// 同 `parse_opt_u32`,但 0 视为非法:MQTT 5 规范把 Receive Maximum / /// Maximum Packet Size 为 0 定义为 Protocol Error,broker 会直接断开连接, /// 且该值会被持久化,表现为"每次连接都失败"。 - fn parse_opt_positive_u32(s: &str, label: &str) -> Result, String> { + fn parse_opt_positive_u32(s: &str, label: &'static str) -> Result, String> { match Self::parse_opt_u32(s, label)? { - Some(0) => Err(format!("{label} 必须大于 0(0 是 MQTT 5 协议错误)")), + Some(0) => Err(format!("{} {}", i18n::t(label), i18n::t("必须大于 0(0 是 MQTT 5 协议错误)"))), other => Ok(other), } } - fn parse_opt_u32(s: &str, label: &str) -> Result, String> { + fn parse_opt_u32(s: &str, label: &'static str) -> Result, String> { let s = s.trim(); if s.is_empty() { return Ok(None); } s.parse::() .map(Some) - .map_err(|_| format!("{label} 需要是非负整数")) + .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("需要是非负整数"))) } pub fn build(&self, cx: &App) -> Result { @@ -450,28 +451,28 @@ impl ConnectionForm { let mut name = self.val(&self.name, cx); if name.trim().is_empty() { if !for_test { - return Err(self.fail("name", "连接名称不能为空")); + return Err(self.fail("name", i18n::t("连接名称不能为空"))); } - name = "测试连接".into(); + name = i18n::t("测试连接").into(); } // 判空已按 trim,保存也存 trim 后的值,避免首尾空白入库 let name = name.trim().to_string(); let host = self.val(&self.host, cx); if host.trim().is_empty() { - return Err(self.fail("host", "主机地址不能为空")); + return Err(self.fail("host", i18n::t("主机地址不能为空"))); } let host = host.trim().to_string(); let port = self.fail_as("port", Self::parse_port(&self.val(&self.port, cx)))?; let mut client_id = self.val(&self.client_id, cx); if client_id.trim().is_empty() { if !for_test { - return Err(self.fail("client_id", "Client ID 不能为空")); + return Err(self.fail("client_id", i18n::t("Client ID 不能为空"))); } client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); } let client_id = client_id.trim().to_string(); let keep_alive = - self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive"))?; + self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), i18n::t("Keep Alive")))?; // 空串视为 0(会话随连接结束);非空必须是合法非负整数,不再静默吞掉 -1/abc let session_expiry = { let raw = self.val(&self.session_expiry, cx); @@ -482,23 +483,23 @@ impl ConnectionForm { t.parse::().map_err(|_| { self.fail( "session_expiry", - "会话过期间隔需要是非负整数(0=会话随连接结束)", + i18n::t("会话过期间隔需要是非负整数(0=会话随连接结束)"), ) })? } }; let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, String> { - Ok(Self::parse_opt_positive_u32(&self.val(&self.receive_max, cx), "接收上限")? - .map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string())) + Ok(Self::parse_opt_positive_u32(&self.val(&self.receive_max, cx), i18n::t("接收上限"))? + .map(|v| u16::try_from(v).map_err(|_| i18n::t("接收上限不能超过 65535").to_string())) .transpose()) })())??; let maximum_packet_size = self.fail_as( "max_packet", - Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), "最大报文长度"), + Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), i18n::t("最大报文长度")), )?; let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, String> { - Ok(Self::parse_opt_positive_u32(&self.val(&self.topic_alias, cx), "主题别名")? - .map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string())) + Ok(Self::parse_opt_positive_u32(&self.val(&self.topic_alias, cx), i18n::t("主题别名"))? + .map(|v| u16::try_from(v).map_err(|_| i18n::t("主题别名上限不能超过 65535").to_string())) .transpose()) })())??; @@ -511,7 +512,7 @@ impl ConnectionForm { .parse::() .ok() .filter(|v| *v >= 1) - .ok_or_else(|| self.fail("conn_timeout", "连接超时需要是 1~65535 的整数(秒)"))? + .ok_or_else(|| self.fail("conn_timeout", i18n::t("连接超时需要是 1~65535 的整数(秒)")))? }; let max_reconnect_raw = self.val(&self.max_reconnect, cx); let max_reconnect_times = if max_reconnect_raw.trim().is_empty() { @@ -520,7 +521,7 @@ impl ConnectionForm { max_reconnect_raw .trim() .parse::() - .map_err(|_| self.fail("max_reconnect", "最大重连次数需要是非负整数(0=无限)"))? + .map_err(|_| self.fail("max_reconnect", i18n::t("最大重连次数需要是非负整数(0=无限)")))? }; // 分组:新分组名非空时优先,否则取下拉所选(首项=无分组) @@ -558,7 +559,7 @@ impl ConnectionForm { // WS/WSS 依赖 path 建立升级请求,缺失时连接必然失败,保存前拦截 let path = self.val(&self.path, cx).trim().to_string(); if transport.is_websocket() && path.is_empty() { - return Err(self.fail("path", "WS/WSS 传输下 WS 路径不能为空")); + return Err(self.fail("path", i18n::t("WS/WSS 传输下 WS 路径不能为空"))); } let ssl = SslConfig { @@ -571,7 +572,7 @@ impl ConnectionForm { if transport.is_tls() && ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty() { - return Err(self.fail("ssl_client_cert", "客户端证书与客户端密钥必须同时填写")); + return Err(self.fail("ssl_client_cert", i18n::t("客户端证书与客户端密钥必须同时填写"))); } // 证书文件不存在时握手才报错太晚,保存前给出字段级提示 if transport.is_tls() { @@ -581,7 +582,7 @@ impl ConnectionForm { ("ssl_client_key", "客户端私钥", &ssl.client_key_file), ] { if !p.is_empty() && !std::path::Path::new(p).exists() { - return Err(self.fail(key, format!("{label}路径不存在:{p}"))); + return Err(self.fail(key, i18n::tf("{label}路径不存在:{p}", &[("label", &i18n::t(label)), ("p", &p)]))); } } } @@ -590,13 +591,13 @@ impl ConnectionForm { // 与其它字段一致:trim 后入库 let topic = self.val(&self.will_topic, cx).trim().to_string(); if topic.is_empty() { - return Err(self.fail("will_topic", "遗嘱主题不能为空")); + return Err(self.fail("will_topic", i18n::t("遗嘱主题不能为空"))); } // 通配符只用于订阅过滤器;出现在 PUBLISH 主题上会被 broker 直接拒绝 if topic.contains('+') || topic.contains('#') { return Err(self.fail( "will_topic", - "遗嘱主题不能包含通配符 + 或 #", + i18n::t("遗嘱主题不能包含通配符 + 或 #"), )); } let opt_str = |v: String| { @@ -702,7 +703,7 @@ impl ConnectionForm { ) .child( Button::new(id) - .label("浏览…") + .label(i18n::t("浏览…")) .outline() .small() .on_click(cx.listener(move |this, _, window, cx| { @@ -722,7 +723,7 @@ impl ConnectionForm { let (tx, rx) = smol::channel::bounded::>(1); std::thread::spawn(move || { let picked = rfd::FileDialog::new() - .set_title("选择证书 / 密钥文件") + .set_title(i18n::t("选择证书 / 密钥文件")) .add_filter("证书 / 密钥 (PEM)", &["pem", "crt", "cer", "key", "der"]) .pick_file(); let _ = tx.send_blocking(picked); @@ -877,39 +878,40 @@ impl Render for ConnectionForm { .iter() .filter(|on| **on) .count(); - let ssl_hint = (ssl_count > 0).then(|| SharedString::from(format!("已配置 {ssl_count} 项"))); + let ssl_hint = (ssl_count > 0).then(|| SharedString::from(i18n::tf("已配置 {n} 项", &[("n", &ssl_count.to_string())]))); let mqtt5_hint = - (mqtt5_count > 0).then(|| SharedString::from(format!("已配置 {mqtt5_count} 项"))); + (mqtt5_count > 0).then(|| SharedString::from(i18n::tf("已配置 {n} 项", &[("n", &mqtt5_count.to_string())]))); v_flex() .gap_3() .max_h(px(560.)) .overflow_y_scrollbar() .pr_1() // ── 基本信息(始终展开) ── - .child(section("基本信息", border, card_bg, + .child(section(i18n::t("基本信息"), border, card_bg, v_flex().gap_2().child( // 主机与端口是一对,放同一行;名称与协议/传输同行 h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field_ex("主机", Input::new(&self.host), true, err("host"), danger))) - .child(div().w(px(120.)).child(field_ex("端口", Input::new(&self.port), true, err("port"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("主机"), Input::new(&self.host), true, err("host"), danger))) + .child(div().w(px(120.)).child(field_ex(i18n::t(i18n::t("端口")), Input::new(&self.port), true, err("port"), danger))) ) .child(h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field_ex("名称", Input::new(&self.name), true, err("name"), danger))) - .child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol)))) - .child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("名称"), Input::new(&self.name), true, err("name"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("协议"), Select::new(&self.protocol)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("传输"), Select::new(&self.transport)))) ) .child(h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("分组", Select::new(&self.group_select)))) - .child(div().flex_1().min_w(px(0.)).child(field("新建分组", Input::new(&self.group_new)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("分组"), Select::new(&self.group_select)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("新建分组"), Input::new(&self.group_new)))) ) .child(h_flex().gap_2().w_full().items_end() - .child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("Client ID"), Input::new(&self.client_id), true, err("client_id"), danger))) // 一键换一个随机 Client ID,与 ConnectionConfig::new 的生成逻辑一致 .child( Button::new("cid-regen") .icon(IconName::RefreshCw) .ghost() - .tooltip("重新生成 Client ID") + .tooltip(i18n::t("重新生成 Client ID")) + .accessibility_label(i18n::t("重新生成 Client ID")) .on_click(cx.listener(|this, _, window, cx| { let cid = crate::model::generate_client_id(); this.client_id @@ -919,48 +921,48 @@ impl Render for ConnectionForm { ) // WS 路径仅对 WebSocket/WSS 有意义,按需显示;该传输下必填 .when(is_ws, |w| w.child( - div().flex_1().min_w(px(0.)).child(field_ex("WS 路径", Input::new(&self.path), true, err("path"), danger)) + div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("WS 路径"), Input::new(&self.path), true, err("path"), danger)) )) ) )) // ── 认证与心跳 ── .child(self.collapsible_section( - "sec-auth", "认证与心跳", None, false, self.show_auth, + "sec-auth", i18n::t("认证与心跳"), None, false, self.show_auth, |f| f.show_auth = !f.show_auth, border, card_bg, v_flex().gap_2() .child(h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username)))) - .child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle()))) - .child(div().w(px(110.)).child(field_ex("Keep Alive (秒)", Input::new(&self.keep_alive), true, err("keep_alive"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("用户名"), Input::new(&self.username)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("密码"), Input::new(&self.password).mask_toggle()))) + .child(div().w(px(110.)).child(field_ex(i18n::t("Keep Alive (秒)"), Input::new(&self.keep_alive), true, err("keep_alive"), danger))) ) .child(h_flex().gap_2().w_full() - .child(div().w(px(150.)).child(field_ex("连接超时 (秒)", Input::new(&self.conn_timeout), false, err("conn_timeout"), danger))) - .child(div().w(px(170.)).child(field_ex("最大重连次数 (0=无限)", Input::new(&self.max_reconnect), false, err("max_reconnect"), danger))) + .child(div().w(px(150.)).child(field_ex(i18n::t("连接超时 (秒)"), Input::new(&self.conn_timeout), false, err("conn_timeout"), danger))) + .child(div().w(px(170.)).child(field_ex(i18n::t("最大重连次数 (0=无限)"), Input::new(&self.max_reconnect), false, err("max_reconnect"), danger))) ) - .child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx)) - .child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx)) - .child(self.switch_row("auto_resubscribe", "连接后自动恢复订阅", self.auto_resubscribe, cx)) - .child(self.switch_row("auto_connect", "启动时自动连接", self.auto_connect, cx)), + .child(self.switch_row("clean_start", i18n::t("Clean Start / Clean Session"), self.clean_start, cx)) + .child(self.switch_row("auto_reconnect", i18n::t("断线自动重连"), self.auto_reconnect, cx)) + .child(self.switch_row("auto_resubscribe", i18n::t("连接后自动恢复订阅"), self.auto_resubscribe, cx)) + .child(self.switch_row("auto_connect", i18n::t("启动时自动连接"), self.auto_connect, cx)), cx, )) // ── SSL/TLS(仅 TLS/WSS 显示) ── .when(is_tls, |w| w.child(self.collapsible_section( - "sec-ssl", "SSL/TLS", ssl_hint, true, self.show_ssl, + "sec-ssl", i18n::t("SSL/TLS"), ssl_hint, true, self.show_ssl, |f| f.show_ssl = !f.show_ssl, border, card_bg, v_flex().gap_2() .child(self.cert_path_row( - "ssl-ca-browse", "CA 证书 (PEM)", self.ssl_ca.clone(), + "ssl-ca-browse", i18n::t("CA 证书 (PEM)"), self.ssl_ca.clone(), err("ssl_ca"), danger, cx, )) .child(self.cert_path_row( - "ssl-cert-browse", "客户端证书 (PEM)", self.ssl_client_cert.clone(), + "ssl-cert-browse", i18n::t("客户端证书 (PEM)"), self.ssl_client_cert.clone(), err("ssl_client_cert"), danger, cx, )) .child(self.cert_path_row( - "ssl-key-browse", "客户端密钥 (PEM)", self.ssl_client_key.clone(), + "ssl-key-browse", i18n::t("客户端密钥 (PEM)"), self.ssl_client_key.clone(), err("ssl_client_key"), danger, cx, )) - .child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)), + .child(self.switch_row("ssl_ignore_ca", i18n::t("忽略 CA 校验(不验证服务器证书)"), self.ssl_ignore_ca, cx)), cx, ))) // ── MQTT 5 高级属性(仅 v5 协议显示) ── @@ -968,29 +970,29 @@ impl Render for ConnectionForm { "sec-mqtt5", "MQTT 5 属性", mqtt5_hint, true, self.show_mqtt5, |f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg, h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field_ex("会话过期间隔(秒)", Input::new(&self.session_expiry), false, err("session_expiry"), danger))) - .child(div().flex_1().min_w(px(0.)).child(field_ex("接收最大值", Input::new(&self.receive_max), false, err("receive_max"), danger))) - .child(div().flex_1().min_w(px(0.)).child(field_ex("最大报文长度", Input::new(&self.max_packet), false, err("max_packet"), danger))) - .child(div().flex_1().min_w(px(0.)).child(field_ex("主题别名上限", Input::new(&self.topic_alias), false, err("topic_alias"), danger))), + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("会话过期间隔(秒)"), Input::new(&self.session_expiry), false, err("session_expiry"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("接收最大值"), Input::new(&self.receive_max), false, err("receive_max"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("最大报文长度"), Input::new(&self.max_packet), false, err("max_packet"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("主题别名上限"), Input::new(&self.topic_alias), false, err("topic_alias"), danger))), cx, ))) // ── 遗嘱 ── .child(self.collapsible_section( - "sec-will", "遗嘱消息 (Last Will)", - self.will_enabled.then(|| "已启用".into()), true, self.show_will, + "sec-will", i18n::t("遗嘱消息 (Last Will)"), + self.will_enabled.then(|| i18n::t("已启用").into()), true, self.show_will, |f| f.show_will = !f.show_will, border, card_bg, v_flex().gap_2() - .child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx)) + .child(self.switch_row("will_enabled", i18n::t("启用遗嘱消息"), self.will_enabled, cx)) .when(self.will_enabled, |w| w .child(h_flex().gap_2() - .child(div().flex_1().min_w(px(0.)).child(field_ex("遗嘱主题", Input::new(&self.will_topic), true, err("will_topic"), danger))) - .child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("遗嘱主题"), Input::new(&self.will_topic), true, err("will_topic"), danger))) + .child(div().w(px(130.)).child(field(i18n::t("QoS"), Select::new(&self.will_qos)))) ) - .child(field("遗嘱内容", Input::new(&self.will_payload))) + .child(field(i18n::t("遗嘱内容"), Input::new(&self.will_payload))) // v5 遗嘱属性;3.1.1 连接会忽略这两项 .child(h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("内容类型 (Content Type)", Input::new(&self.will_content_type)))) - .child(div().flex_1().min_w(px(0.)).child(field("响应主题 (Response Topic)", Input::new(&self.will_response_topic)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("内容类型 (Content Type)"), Input::new(&self.will_content_type)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("响应主题 (Response Topic)"), Input::new(&self.will_response_topic)))) ) .child(self.switch_row("will_retain", "Retain", self.will_retain, cx)) ), diff --git a/gpui-app/src/ui/connection_view/messages.rs b/gpui-app/src/ui/connection_view/messages.rs index 1da7393..b6401f5 100644 --- a/gpui-app/src/ui/connection_view/messages.rs +++ b/gpui-app/src/ui/connection_view/messages.rs @@ -12,6 +12,7 @@ use crate::model::{ render_will_templates, ConnectionStatus, Direction, LogLevel use crate::ui::widgets::format_time; use super::*; +use crate::ui::i18n; impl ConnectionView { pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement { @@ -31,7 +32,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement let connecting = matches!(status, ConnectionStatus::Connecting); let _ = is_v5; - let status_text = status.label(); + let status_text = i18n::t(status.label()); let status_tone = match status { ConnectionStatus::Connected => cx.theme().success, ConnectionStatus::Connecting => cx.theme().warning, @@ -91,11 +92,11 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement IconName::PlugZap }) .label(if connected { - "断开" + i18n::t("断开") } else if connecting { - "连接中…" + i18n::t("连接中") } else { - "连接" + i18n::t("连接") }) .when(connected, |b| b.danger().ghost()) .when(!connected, |b| b.primary().ghost()) @@ -150,7 +151,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement let border = cx.theme().border; let muted = cx.theme().muted_foreground; let mono = cx.theme().mono_font_family.clone(); - let direction_label = if received { "接收" } else { "发布" }; + let direction_label = if received { i18n::t("接收") } else { i18n::t("发布") }; let header = h_flex() .gap_2() @@ -245,7 +246,8 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement .text_color(cx.theme().foreground) .pt_0p5() .font_family(mono.clone()) - .child(preview_payload(&record.payload)), + // 预览在入环形缓冲时已算好(JSON 美化/压平+截断),此处零解析 + .child(SharedString::from(record.preview.clone())), ); if expanded { @@ -265,7 +267,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement seq, f.index() ))) - .label(f.label()) + .label(i18n::t(f.label())) .small() .selected(f == fmt) }), @@ -289,40 +291,43 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement .child( Button::new(SharedString::from(format!("msg-copy-topic-{}", seq))) .icon(IconName::Copy) - .label("主题") + .label(i18n::t("主题")) .ghost() .xsmall() - .tooltip("复制主题") + .tooltip(i18n::t("复制主题")) + .accessibility_label(i18n::t("复制主题")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_topic.to_string())); - window.push_notification(Notification::success("已复制主题"), cx); + window.push_notification(Notification::success(i18n::t("已复制主题")), cx); }), ) .child( Button::new(SharedString::from(format!("msg-copy-payload-{}", seq))) .icon(IconName::Copy) - .label("负载") + .label(i18n::t("负载")) .ghost() .xsmall() - .tooltip("复制负载原文") + .tooltip(i18n::t("复制负载原文")) + .accessibility_label(i18n::t("复制负载原文")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_payload.to_string())); - window.push_notification(Notification::success("已复制负载"), cx); + window.push_notification(Notification::success(i18n::t("已复制负载")), cx); }), ) .child( Button::new(SharedString::from(format!("msg-copy-detail-{}", seq))) .icon(IconName::Copy) - .label("详情") + .label(i18n::t("详情")) .ghost() .xsmall() - .tooltip("复制完整详情(含 v5 属性)") + .tooltip(i18n::t("复制完整详情(含 v5 属性)")) + .accessibility_label(i18n::t("复制完整详情(含 v5 属性)")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_detail.clone())); - window.push_notification(Notification::success("已复制详情"), cx); + window.push_notification(Notification::success(i18n::t("已复制详情")), cx); }), ); details = details.child(detail_toolbar).child( @@ -345,7 +350,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement .collect::>() .join("\n"); details = details - .child(div().text_xs().font_semibold().child("用户属性")) + .child(div().text_xs().font_semibold().child(i18n::t("用户属性"))) .child( div() .font_family(mono.clone()) @@ -358,10 +363,10 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement ("Response Topic", record.response_topic.clone()), ("Correlation Data", record.correlation_data.clone()), ( - "消息过期", + i18n::t("消息过期"), record .message_expiry_interval - .map(|v| format!("{v} 秒")), + .map(|v| i18n::tf("{n} 秒", &[("n", &v.to_string())])), ), ( "订阅标识符", @@ -508,9 +513,9 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .text_sm() .text_color(cx.theme().muted_foreground) .child(if filtering { - "没有匹配的消息" + i18n::t("没有匹配的消息") } else { - "暂无消息,订阅主题后消息会显示在这里" + i18n::t("暂无消息,订阅主题后消息会显示在这里") }), ), ); @@ -538,7 +543,7 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement self.conn_id, d.index() ))) - .label(d.label()) + .label(i18n::t(d.label())) .small() .selected(d == dir) }, @@ -580,7 +585,8 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .icon(IconName::Close) .ghost() .xsmall() - .tooltip("清除订阅过滤") + .tooltip(i18n::t("清除订阅过滤")) + .accessibility_label(i18n::t("清除订阅过滤")) .on_click(cx.listener(|this, _, _, cx| { this.sub_filter = None; cx.notify(); @@ -629,7 +635,7 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .px_1p5() .text_color(cx.theme().muted_foreground) // 计数显示过滤命中总数,不受渲染截断影响 - .child(format!("{} 条", matched_total)), + .child(i18n::tf("{n} 条", &[("n", &matched_total.to_string())])), ) .when(matched_total > records.len(), |h| { h.child( @@ -637,7 +643,10 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .text_xs() .px_1p5() .text_color(cx.theme().muted_foreground) - .child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)), + .child(i18n::tf( + "仅显示最新 {n} 条", + &[("n", &MAX_RENDERED_MESSAGES.to_string())], + )), ) }) .child({ @@ -653,14 +662,19 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement } else { IconName::Pause }) - .label(if paused { "恢复" } else { "暂停" }) + .label(if paused { i18n::t("恢复") } else { i18n::t("暂停") }) .ghost() .small() .when(paused, |b| b.selected(true)) .tooltip(if paused { - "恢复实时滚动" + i18n::t("恢复实时滚动") } else { - "暂停消息流滚动(消息仍在后台接收)" + i18n::t("暂停消息流滚动(消息仍在后台接收)") + }) + .accessibility_label(if paused { + i18n::t("恢复") + } else { + i18n::t("暂停") }) .on_click(cx.listener(|this, _, _, cx| { this.paused = !this.paused; @@ -675,11 +689,12 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement let empty = total == 0; Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id))) .icon(IconName::Eraser) - .label("清空") + .label(i18n::t("清空")) .ghost() .small() .disabled(empty) - .tooltip("清空当前连接的消息") + .tooltip(i18n::t("清空当前连接的消息")) + .accessibility_label(i18n::t("清空当前连接的消息")) .on_click(move |_, window, cx| { // 清空不可撤销,先弹二次确认 let weak = weak.clone(); @@ -688,13 +703,13 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement let weak = weak.clone(); let clear_id = clear_id.clone(); alert - .title("清空消息") - .description("确定清空当前连接的全部消息吗?此操作不可撤销。") + .title(i18n::t("清空消息")) + .description(i18n::t("确定清空当前连接的全部消息吗?此操作不可撤销。")) .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text("取消") - .ok_text("清空") + .cancel_text(i18n::t("取消")) + .ok_text(i18n::t("清空")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, _, cx| { @@ -754,7 +769,7 @@ pub(super) fn render_logs(&self, cx: &mut Context) -> impl IntoElement { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("暂无日志"), + .child(i18n::t("暂无日志")), ), ); } @@ -815,7 +830,7 @@ pub(super) fn render_logs(&self, cx: &mut Context) -> impl IntoElement { .text_xs() .text_color(muted) .font_family(cx.theme().mono_font_family.clone()) - .child("连接日志"), + .child(i18n::t("连接日志")), ), ) .child(body) diff --git a/gpui-app/src/ui/connection_view/mod.rs b/gpui-app/src/ui/connection_view/mod.rs index 224ab0b..85f6058 100644 --- a/gpui-app/src/ui/connection_view/mod.rs +++ b/gpui-app/src/ui/connection_view/mod.rs @@ -25,6 +25,7 @@ use gpui_kit::{ }; use crate::model::ConnectionConfig; +use crate::ui::i18n; use crate::mqtt::MqttEngine; use crate::ui::app::MqttXApp; use crate::ui::widgets::{make_select, KvEditor, OptionDelegate}; @@ -39,7 +40,9 @@ use util::*; const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; /// Retain Handling 0/1/2 的下拉文案 -const RETAIN_HANDLING: [&str; 3] = ["0 每次发送", "1 仅新订阅", "2 不发送"]; +fn retain_handling_labels() -> [&'static str; 3] { + [i18n::t("0 每次发送"), i18n::t("1 仅新订阅"), i18n::t("2 不发送")] +} const MAX_RENDERED_MESSAGES: usize = 300; /// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致 const MAX_RENDERED_LOGS: usize = 3000; @@ -205,29 +208,29 @@ impl ConnectionView { ) -> Self { let sub_topic = cx.new(|cx| { - InputState::new(window, cx).placeholder("订阅主题,支持 # +,多个用逗号/空格分隔") + InputState::new(window, cx).placeholder(i18n::t("订阅主题,支持 # +,多个用逗号/空格分隔")) }); let sub_qos = make_select(&QOS, 0, window, cx); - let sub_alias = cx.new(|cx| InputState::new(window, cx).placeholder("别名(可选)")); - let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder("订阅标识符")); - let sub_retain_handling = make_select(&RETAIN_HANDLING, 0, window, cx); - let pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder("发布主题")); + let sub_alias = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("别名(可选)"))); + let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("订阅标识符"))); + let sub_retain_handling = make_select(&retain_handling_labels(), 0, window, cx); + let pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("发布主题"))); let payload = cx.new(|cx| { TextareaState::new(window, cx) - .placeholder("输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}") + .placeholder(i18n::t("输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}")) }); let pub_qos = make_select(&QOS, 0, window, cx); let payload_format = make_select(&PAYLOAD_FORMATS, 0, window, cx); - let content_type = cx.new(|cx| InputState::new(window, cx).placeholder("Content-Type(可选)")); - let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder("秒,如 60")); + let content_type = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("Content-Type(可选)"))); + let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("秒,如 60"))); let response_topic = - cx.new(|cx| InputState::new(window, cx).placeholder("Response Topic(可选)")); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("Response Topic(可选)"))); let correlation_data = - cx.new(|cx| InputState::new(window, cx).placeholder("Correlation Data(可选)")); - let filter = cx.new(|cx| InputState::new(window, cx).placeholder("过滤主题或内容…")); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("Correlation Data(可选)"))); + let filter = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("过滤主题或内容…"))); let user_props = cx.new(|cx| KvEditor::new("pub", &[], window, cx)); let preset_name = - cx.new(|cx| InputState::new(window, cx).placeholder("预设名称,如 温度上报")); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("预设名称,如 温度上报"))); Self { conn_id, @@ -290,6 +293,13 @@ impl ConnectionView { self.paused } + /// Ctrl+F:把焦点移到消息过滤框(键盘直达过滤入口)。 + pub fn focus_filter(&self, window: &mut Window, cx: &mut App) { + let handle = gpui_kit::component::input::AnyInputState::from(self.filter.clone()) + .focus_handle(cx); + window.focus(&handle, cx); + } + } @@ -322,7 +332,7 @@ impl Render for ConnectionView { .selected_index(if panel == Panel::Messages { 0 } else { 1 }) .child( Tab::new() - .label("消息流") + .label(i18n::t("消息流")) .selected(panel == Panel::Messages) .on_click(cx.listener(|this, _, _, cx| { this.panel = Panel::Messages; @@ -331,7 +341,7 @@ impl Render for ConnectionView { ) .child( Tab::new() - .label("日志") + .label(i18n::t("日志")) .selected(panel == Panel::Logs) .on_click(cx.listener(|this, _, _, cx| { this.panel = Panel::Logs; diff --git a/gpui-app/src/ui/connection_view/publish.rs b/gpui-app/src/ui/connection_view/publish.rs index 2a753ea..ba3bad1 100644 --- a/gpui-app/src/ui/connection_view/publish.rs +++ b/gpui-app/src/ui/connection_view/publish.rs @@ -16,6 +16,7 @@ use crate::model::{ render_template, PayloadFormat, PublishParams, }; use crate::ui::widgets::{ field, KvEditor }; use super::*; +use crate::ui::i18n; impl ConnectionView { /// 发布栏收起后的细条:抽屉把手。 @@ -35,7 +36,8 @@ impl ConnectionView { .icon(IconName::PanelBottomOpen) .ghost() .small() - .tooltip("展开发布面板") + .tooltip(i18n::t("展开发布面板")) + .accessibility_label(i18n::t("展开发布面板")) .on_click(cx.listener(|this, _, _, cx| { this.publish_collapsed = false; cx.notify(); @@ -45,7 +47,7 @@ impl ConnectionView { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("发布面板已收起"), + .child(i18n::t("发布面板已收起")), ) } @@ -54,18 +56,18 @@ impl ConnectionView { pub(crate) fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 if !self.engine.is_connected(&self.conn_id) { - window.push_notification(Notification::warning("未连接:消息未发送"), cx); + window.push_notification(Notification::warning(i18n::t("未连接:消息未发送")), cx); return; } let mut params = self.collect_publish_params(cx); if params.topic.trim().is_empty() { - window.push_notification(Notification::warning("发布主题不能为空"), cx); + window.push_notification(Notification::warning(i18n::t("发布主题不能为空")), cx); return; } // 过期秒数输入非法时 collect 会静默得到 None,这里显式拦截避免误发 let expiry_raw = self.msg_expiry.read(cx).value().to_string(); if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() { - window.push_notification(Notification::warning("消息过期须为非负整数(秒)"), cx); + window.push_notification(Notification::warning(i18n::t("消息过期须为非负整数(秒)")), cx); return; } @@ -74,7 +76,7 @@ impl ConnectionView { params.payload = render_template(¶ms.payload, &vars); // {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次 if params.topic.trim().is_empty() { - window.push_notification(Notification::warning("发布主题不能为空"), cx); + window.push_notification(Notification::warning(i18n::t("发布主题不能为空")), cx); return; } // {{变量}} 同样作用于用户属性的 key/value @@ -91,7 +93,7 @@ impl ConnectionView { if let PayloadFormat::Json = params.payload_format && let Err(e) = serde_json::from_str::(¶ms.payload) { window.push_notification( - Notification::warning(format!("JSON 格式无效: {e}")), + Notification::warning(i18n::tf("JSON 格式无效: {e}", &[("e", &e.to_string())])), cx, ); return; @@ -127,7 +129,7 @@ impl ConnectionView { self.engine.publish(self.conn_id.clone(), params); if hidden { window.push_notification( - Notification::success("已发送(被当前过滤条件隐藏,消息流中不显示)"), + Notification::success(i18n::t("已发送(被当前过滤条件隐藏,消息流中不显示)")), cx, ); } @@ -277,10 +279,14 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .small() .when(show_vars, |b| b.selected(true)) .tooltip(if count > 0 { - format!("变量注入({count} 个占位符)") + i18n::tf( + "变量注入({n} 个占位符)", + &[("n", &count.to_string())], + ) } else { - "变量注入".to_string() + i18n::t("变量注入").to_string() }) + .accessibility_label(i18n::t("变量注入").to_string()) .on_click(cx.listener(|this, _, window, cx| { if this.show_vars { this.show_vars = false; @@ -309,7 +315,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .ghost() .small() .when(show_props, |b| b.selected(true)) - .tooltip("MQTT 5 属性") + .tooltip(i18n::t("MQTT 5 属性")) + .accessibility_label(i18n::t("MQTT 5 属性")) .on_click(cx.listener(|this, _, _, cx| { this.show_props = !this.show_props; cx.notify(); @@ -319,12 +326,12 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .child( Button::new(SharedString::from(format!("publish-{}", self.conn_id))) .icon(IconName::SendHorizontal) - .label("发送") + .label(i18n::t("发送")) .primary() .small() // 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底 .disabled(!connected) - .when(!connected, |b| b.tooltip("未连接,无法发送")) + .when(!connected, |b| b.tooltip(i18n::t("未连接,无法发送"))) .on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))), ) // 发布栏抽屉把手:收起整个底部面板 @@ -333,7 +340,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .icon(IconName::PanelBottom) .ghost() .small() - .tooltip("收起发布面板") + .tooltip(i18n::t("收起发布面板")) + .accessibility_label(i18n::t("收起发布面板")) .on_click(cx.listener(|this, _, _, cx| { this.publish_collapsed = true; cx.notify(); @@ -412,7 +420,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .icon(IconName::Bookmark) .ghost() .small() - .tooltip("消息预设") + .tooltip(i18n::t("消息预设")) + .accessibility_label(i18n::t("消息预设")) .dropdown_menu(move |mut menu, window, cx| { // 应用预设 for p in &presets { @@ -436,7 +445,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into // 保存当前 let weak_save = weak_view.clone(); let name_input = preset_name.clone(); - menu = menu.item(PopupMenuItem::new("保存当前为预设…").on_click( + menu = menu.item(PopupMenuItem::new(i18n::t("保存当前为预设…")).on_click( move |_ev, window, cx| { if let Some(view) = weak_save.upgrade() { view.update(cx, |view, cx| { @@ -447,7 +456,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into )); // 打开预设管理对话框(新建 / 编辑 / 删除) let manage_app = app_weak.clone(); - menu = menu.item(PopupMenuItem::new("管理预设…").on_click( + menu = menu.item(PopupMenuItem::new(i18n::t("管理预设…")).on_click( move |_ev, window, cx| { if let Some(app) = manage_app.upgrade() { crate::ui::dialogs::presets_dialog::open(app, window, cx); @@ -468,7 +477,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into let weak2 = weak_del.clone(); let id = p.id.clone(); let name = p.name.clone(); - let label = format!("删除「{}」", p.name); + let label = i18n::tf("删除「{name}」", &[("name", &p.name)]); submenu = submenu.item( PopupMenuItem::new(SharedString::from(label)).on_click( move |_ev, window, cx| { @@ -480,15 +489,16 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into let weak = weak.clone(); let pid = pid.clone(); alert - .title("删除预设") - .description(format!( - "确定删除预设「{pname}」吗?此操作不可撤销。" + .title(i18n::t("删除预设")) + .description(i18n::tf( + "确定删除预设「{name}」吗?此操作不可撤销。", + &[("name", &pname)], )) .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text("取消") - .ok_text("删除") + .cancel_text(i18n::t("取消")) + .ok_text(i18n::t("删除")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, _, cx| { @@ -538,9 +548,9 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(420.)) - .title("保存消息预设") + .title(i18n::t("保存消息预设")) .child(v_flex().gap_3().child( - crate::ui::widgets::field("预设名称", Input::new(&name_for_ok)), + crate::ui::widgets::field(i18n::t("预设名称"), Input::new(&name_for_ok)), )) .footer( h_flex() @@ -549,13 +559,13 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .w_full() .child( Button::new("preset-save-cancel") - .label("取消") + .label(i18n::t("取消")) .outline() .on_click(move |_, window, cx| window.close_dialog(cx)), ) .child( Button::new("preset-save-ok") - .label("保存") + .label(i18n::t("保存")) .primary() .on_click({ let name_input = name_for_ok.clone(); @@ -568,7 +578,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .to_string(); if name.is_empty() { window.push_notification( - Notification::warning("请填写预设名称"), + Notification::warning(i18n::t("请填写预设名称")), cx, ); return; @@ -594,9 +604,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into && params.message_expiry_interval.is_none() { window.push_notification( - Notification::warning( - "消息过期须为非负整数(秒)", - ), + Notification::warning(i18n::t("消息过期须为非负整数(秒)")), cx, ); return; @@ -613,9 +621,9 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into cx.notify(); }); let msg = if existed { - format!("已覆盖预设「{name}」") + i18n::tf("已覆盖预设「{name}」", &[("name", &name)]) } else { - "预设已保存".to_string() + i18n::t("预设已保存").to_string() }; window.push_notification( Notification::success(msg), diff --git a/gpui-app/src/ui/connection_view/subscribe.rs b/gpui-app/src/ui/connection_view/subscribe.rs index 1c5800e..f67812e 100644 --- a/gpui-app/src/ui/connection_view/subscribe.rs +++ b/gpui-app/src/ui/connection_view/subscribe.rs @@ -15,6 +15,7 @@ use crate::model::{ SubscribeOptions, Subscription, }; use crate::ui::widgets::field; use super::*; +use crate::ui::i18n; impl ConnectionView { /// 订阅面板收起后的窄 rail:抽屉把手。 @@ -34,7 +35,8 @@ impl ConnectionView { .icon(IconName::PanelLeftOpen) .ghost() .small() - .tooltip("展开订阅面板") + .tooltip(i18n::t("展开订阅面板")) + .accessibility_label(i18n::t("展开订阅面板")) .on_click(cx.listener(|this, _, _, cx| { this.subs_collapsed = false; cx.notify(); @@ -167,7 +169,7 @@ impl ConnectionView { let raw = self.sub_topic.read(cx).value().to_string(); let topics = split_topic_list(&raw); if topics.is_empty() { - window.push_notification(Notification::warning("订阅主题不能为空"), cx); + window.push_notification(Notification::warning(i18n::t("订阅主题不能为空")), cx); return; } let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; @@ -187,7 +189,7 @@ impl ConnectionView { Ok(v) if v > 0 => sub_identifier = Some(v), _ => { window.push_notification( - Notification::warning("订阅标识符须为正整数"), + Notification::warning(i18n::t("订阅标识符须为正整数")), cx, ); return; @@ -301,9 +303,9 @@ impl ConnectionView { .map(|c| c.auto_resubscribe) .unwrap_or(true) { - "未连接:订阅已保存,连接后自动恢复" + i18n::t("未连接:订阅已保存,连接后自动恢复") } else { - "未连接:订阅已保存" + i18n::t("未连接:订阅已保存") }; window.push_notification(Notification::warning(hint), cx); } @@ -362,7 +364,7 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In .bg(cx.theme().warning.alpha(0.15)) .text_xs() .text_color(cx.theme().warning) - .child(format!("正在编辑:{topic}")), + .child(i18n::tf("正在编辑:{topic}", &[("topic", &topic)])), ) }) .child( @@ -401,11 +403,12 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In h.child( Button::new(SharedString::from(format!("sub-adv-{}", self.conn_id))) .icon(IconName::SlidersHorizontal) - .label("高级") + .label(i18n::t("高级")) .ghost() .small() .when(show_advanced, |b| b.selected(true)) - .tooltip("MQTT 5 订阅选项") + .tooltip(i18n::t("MQTT 5 订阅选项")) + .accessibility_label(i18n::t("MQTT 5 订阅选项")) .on_click(cx.listener(|this, _, _, cx| { this.sub_show_advanced = !this.sub_show_advanced; cx.notify(); @@ -415,7 +418,7 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In .child( Button::new(SharedString::from(format!("subscribe-{}", self.conn_id))) .icon(if editing { IconName::Check } else { IconName::Plus }) - .label(if editing { "更新" } else { "订阅" }) + .label(if editing { i18n::t("更新") } else { i18n::t("订阅") }) // 编辑用 warning 色与新增的 primary 区分 .when(editing, |b| b.warning()) .when(!editing, |b| b.primary()) @@ -428,7 +431,8 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In .icon(IconName::Close) .ghost() .small() - .tooltip("取消编辑") + .tooltip(i18n::t("取消编辑")) + .accessibility_label(i18n::t("取消编辑")) .on_click(cx.listener(|this, _, window, cx| { this.editing = None; // 清空回填到输入框的主题/别名/高级字段,回到新增态 @@ -466,7 +470,7 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In cx.notify(); }), )) - .child(div().text_xs().child("No Local")), + .child(div().text_xs().child(i18n::t("No Local"))), ) .child( h_flex() @@ -479,11 +483,11 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In cx.notify(); }), )) - .child(div().text_xs().child("Retain As Published")), + .child(div().text_xs().child(i18n::t("Retain As Published"))), ) .child( div().w(px(150.)).child(field( - "Retain Handling", + i18n::t("Retain Handling"), Select::new(&sub_retain_handling).small(), )), ), @@ -523,7 +527,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .text_xs() .text_color(cx.theme().muted_foreground) .text_center() - .child("订阅主题后,消息会显示在右侧"), + .child(i18n::t("订阅主题后,消息会显示在右侧")), ), ); } @@ -544,7 +548,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl Button::new(trigger_id) .ghost() .xsmall() - .tooltip("订阅颜色") + .tooltip(i18n::t("订阅颜色")) + .accessibility_label(i18n::t("订阅颜色")) .child( div() .size(px(11.)) @@ -557,7 +562,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl Button::new(trigger_id) .ghost() .xsmall() - .tooltip("订阅颜色") + .tooltip(i18n::t("订阅颜色")) + .accessibility_label(i18n::t("订阅颜色")) .child( div() .size(px(11.)) @@ -605,7 +611,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl menu = menu.separator(); let weak_rand = weak_menu.clone(); let sid_rand = sid.clone(); - menu = menu.item(PopupMenuItem::new("随机颜色").on_click(move |_, _, cx| { + menu = menu.item(PopupMenuItem::new(i18n::t("随机颜色")).on_click(move |_, _, cx| { let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32; weak_rand .update(cx, |view, cx| { @@ -615,7 +621,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl })); let weak_clear = weak_menu.clone(); let sid_clear = sid.clone(); - menu = menu.item(PopupMenuItem::new("清除颜色").on_click(move |_, _, cx| { + menu = menu.item(PopupMenuItem::new(i18n::t("清除颜色")).on_click(move |_, _, cx| { weak_clear .update(cx, |view, cx| { view.set_subscription_color(&sid_clear, None, cx); @@ -631,7 +637,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .icon(if enabled { IconName::Eye } else { IconName::EyeOff }) .ghost() .xsmall() - .tooltip(if enabled { "停用订阅" } else { "启用订阅" }) + .tooltip(if enabled { i18n::t("停用订阅") } else { i18n::t("启用订阅") }) + .accessibility_label(if enabled { i18n::t("停用订阅") } else { i18n::t("启用订阅") }) .on_click(cx.listener(move |this, _, _, cx| { this.toggle_subscription_enabled(&eye_sid, cx); })); @@ -641,7 +648,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .icon(IconName::Pencil) .ghost() .xsmall() - .tooltip("编辑订阅") + .tooltip(i18n::t("编辑订阅")) + .accessibility_label(i18n::t("编辑订阅")) .on_click(cx.listener(move |this, _, window, cx| { let sub = this .with_app(cx, |app| { @@ -662,7 +670,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .icon(IconName::Close) .ghost() .xsmall() - .tooltip("取消订阅") + .tooltip(i18n::t("取消订阅")) + .accessibility_label(i18n::t("取消订阅")) .on_click(cx.listener(move |this, _, _, cx| { // 若删除的是当前过滤主题,一并清掉消息过滤 if this.sub_filter.as_deref() == Some(del_topic.as_str()) { @@ -769,13 +778,14 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .px_3() .items_center() .justify_between() - .child(div().text_sm().font_semibold().child("订阅")) + .child(div().text_sm().font_semibold().child(i18n::t("订阅"))) .child( Button::new("subs-collapse") .icon(IconName::PanelLeftClose) .ghost() .xsmall() - .tooltip("收起订阅面板") + .tooltip(i18n::t("收起订阅面板")) + .accessibility_label(i18n::t("收起订阅面板")) .on_click(cx.listener(|this, _, _, cx| { this.subs_collapsed = true; cx.notify(); diff --git a/gpui-app/src/ui/connection_view/util.rs b/gpui-app/src/ui/connection_view/util.rs index bb71cc8..072266d 100644 --- a/gpui-app/src/ui/connection_view/util.rs +++ b/gpui-app/src/ui/connection_view/util.rs @@ -157,54 +157,6 @@ pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> Strin out } -/// 折叠态预览行数与字符上限:JSON 美化后多行完整展示,超大负载兜底截断 -const PREVIEW_MAX_LINES: usize = 40; -const PREVIEW_MAX_CHARS: usize = 4000; - -/// 折叠态预览:JSON 负载按原始数据美化展示(缩进多行),非 JSON -/// 压平换行/制表符截 200 字符;含替换字符(U+FFFD)提示切 Hex。 -pub(super) fn preview_payload(payload: &str) -> String { - let parsed = serde_json::from_str::(payload).ok(); - let mut out = match &parsed { - Some(value) => { - let pretty = serde_json::to_string_pretty(value).unwrap_or_default(); - truncate_preview(pretty) - } - None => { - let flat: String = payload - .chars() - .map(|c| match c { - '\n' | '\r' | '\t' => ' ', - _ => c, - }) - .collect(); - let mut s: String = flat.chars().take(200).collect(); - if flat.chars().count() > 200 { - s.push('…'); - } - s - } - }; - if payload.contains('\u{FFFD}') { - out.push_str("(非文本,详情可切 Hex)"); - } - out -} - -/// 截断预览文本:最多 [`PREVIEW_MAX_LINES`] 行、[`PREVIEW_MAX_CHARS`] 字符。 -fn truncate_preview(text: String) -> String { - let mut lines: Vec<&str> = text.lines().take(PREVIEW_MAX_LINES).collect(); - let more = text.lines().count() > PREVIEW_MAX_LINES; - if more { - lines.push("…"); - } - let mut out = lines.join("\n"); - if out.chars().count() > PREVIEW_MAX_CHARS { - out = out.chars().take(PREVIEW_MAX_CHARS).collect::() + "…"; - } - out -} - /// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; /// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, /// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 @@ -233,7 +185,6 @@ pub(super) fn contains_ignore_case(hay: &str, needle: &str) -> bool { #[cfg(test)] mod tests { use super::extract_placeholder_keys; - use super::preview_payload; // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 #[test] @@ -255,31 +206,4 @@ mod tests { assert_eq!(keys, vec!["名称"]); } - #[test] - fn preview_json_is_pretty_and_complete_for_typical_payloads() { - let payload = r#"{"type":"heartbeat","ts":1790751279869,"data":{"version":"0.1.0","lon":117.229,"lat":31.8206,"uptime":1716450,"rssi":-52}}"#; - let out = preview_payload(payload); - // JSON 被美化(多行缩进)且常规负载完整展示、不带任何加工注释 - assert!(out.contains('\n'), "JSON 应多行展示: {out}"); - assert!(out.contains("\"rssi\": -52"), "常规负载应完整显示: {out}"); - assert!(!out.contains("//"), "预览不应附加注释: {out}"); - assert!(!out.contains('…'), "常规负载不应截断: {out}"); - } - - #[test] - fn preview_truncates_very_deep_json() { - // 50 个元素的数组美化后超过 40 行,应截断并带省略号 - let items: Vec = (0..50).map(|i| i.to_string()).collect(); - let payload = format!("{{\"a\":[{}]}}", items.join(",")); - let deep = preview_payload(&payload); - assert!(deep.lines().count() <= 41, "超行应截断: {deep}"); - assert!(deep.contains('…'), "截断应有省略号: {deep}"); - } - - #[test] - fn preview_non_json_still_flat() { - assert_eq!(preview_payload("hello\nworld"), "hello world"); - assert_eq!(preview_payload("纯文本"), "纯文本"); - assert!(preview_payload("bad\ntext\u{FFFD}").contains("非文本")); - } } diff --git a/gpui-app/src/ui/connection_view/vars.rs b/gpui-app/src/ui/connection_view/vars.rs index e9ea033..2d7966d 100644 --- a/gpui-app/src/ui/connection_view/vars.rs +++ b/gpui-app/src/ui/connection_view/vars.rs @@ -10,6 +10,7 @@ use gpui_kit::{ div, px, Context, IntoElement, Window, }; use crate::model::{ render_template, GlobalVariable, }; use super::*; +use crate::ui::i18n; impl ConnectionView { /// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。 @@ -20,7 +21,7 @@ pub(super) fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoEleme div() .text_xs() .font_semibold() - .child(format!("变量({})", self.var_rows.len())), + .child(i18n::tf( + "变量({n})", + &[("n", &self.var_rows.len().to_string())], + )), ) .child(div().flex_1()) .child( @@ -125,7 +129,8 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme .icon(IconName::RefreshCw) .ghost() .xsmall() - .tooltip("重新提取占位符") + .tooltip(i18n::t("重新提取占位符")) + .accessibility_label(i18n::t("重新提取占位符")) .on_click(cx.listener(|this, _, window, cx| { this.sync_var_rows(window, cx); })), @@ -135,7 +140,8 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme .icon(IconName::ChevronUp) .ghost() .xsmall() - .tooltip("收起") + .tooltip(i18n::t("收起")) + .accessibility_label(i18n::t("收起")) .on_click(cx.listener(|this, _, _, cx| { this.show_vars = false; cx.notify(); @@ -147,13 +153,16 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme div() .text_xs() .text_color(muted) - .child("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"), + .child(i18n::t("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入")), ) .child( div() .text_xs() .text_color(muted) - .child(format!("预览:{topic_rendered} | {payload_preview}")), + .child(i18n::tf( + "预览:{tp} | {pl}", + &[("tp", &topic_rendered), ("pl", &payload_preview)], + )), ) } diff --git a/gpui-app/src/ui/dialogs/aliyun_dialog.rs b/gpui-app/src/ui/dialogs/aliyun_dialog.rs index 64d3b70..69778f5 100644 --- a/gpui-app/src/ui/dialogs/aliyun_dialog.rs +++ b/gpui-app/src/ui/dialogs/aliyun_dialog.rs @@ -18,6 +18,7 @@ use crate::aliyun::{AliyunAuthMode, AliyunPreset}; use crate::model::{render_will_templates, ConnectionConfig}; use crate::ui::app::MqttXApp; use crate::ui::widgets::{field, make_select, OptionDelegate}; +use crate::ui::i18n; const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"]; @@ -59,10 +60,10 @@ impl AliyunDialog { app, presets, selected, - name: text_field!(cx, window, &first.name, "预设名称"), - group: text_field!(cx, window, &first.group, "分组(可选)"), + name: text_field!(cx, window, &first.name, i18n::t("预设名称")), + group: text_field!(cx, window, &first.group, i18n::t("分组(可选)")), instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"), - region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"), + region: text_field!(cx, window, &first.region, i18n::t("如 cn-shanghai(可留空)")), auth_mode: make_select( &AUTH_MODES, match first.auth_mode { @@ -80,12 +81,12 @@ impl AliyunDialog { .masked(true) }), group_id: text_field!(cx, window, &first.group_id, "GID-xxx"), - device_id: text_field!(cx, window, &first.device_id, "设备 ID"), - product_key: text_field!(cx, window, &first.product_key, "一机一密:ProductKey"), + device_id: text_field!(cx, window, &first.device_id, i18n::t("设备 ID")), + product_key: text_field!(cx, window, &first.product_key, i18n::t("一机一密:ProductKey")), device_secret: cx.new(|cx| { InputState::new(window, cx) .default_value(&first.device_secret) - .placeholder("一机一密:DeviceSecret") + .placeholder(i18n::t("一机一密:DeviceSecret")) .masked(true) }), } @@ -185,7 +186,7 @@ impl AliyunDialog { fn save_preset(&mut self, window: &mut Window, cx: &mut Context) { let preset = self.collect(cx); if preset.name.trim().is_empty() { - window.push_notification(Notification::warning("请填写预设名称"), cx); + window.push_notification(Notification::warning(i18n::t("请填写预设名称")), cx); return; } match self.selected { @@ -197,7 +198,7 @@ impl AliyunDialog { a.save_aliyun(saved); cx.notify(); }); - window.push_notification(Notification::success("预设已保存"), cx); + window.push_notification(Notification::success(i18n::t("预设已保存")), cx); } fn connect(&self, window: &mut Window, cx: &mut Context) { @@ -216,7 +217,7 @@ impl AliyunDialog { window.close_dialog(cx); } Err(e) => { - window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx); + window.push_notification(Notification::error(i18n::tf("无法生成连接: {e}", &[("e", &e.to_string())])), cx); } } } @@ -232,7 +233,7 @@ impl Render for AliyunDialog { v_flex() .w(px(160.)) .gap_1() - .child(div().text_xs().font_semibold().child("已保存预设")) + .child(div().text_xs().font_semibold().child(i18n::t("已保存预设"))) .children( self.presets .iter() @@ -256,7 +257,7 @@ impl Render for AliyunDialog { .child( Button::new("aliyun-new") .icon(IconName::Plus) - .label("新建") + .label(i18n::t("新建")) .ghost() .small() .on_click(cx.listener(|this, _, window, cx| { @@ -270,36 +271,36 @@ impl Render for AliyunDialog { .flex_1() .min_w(px(0.)) .gap_2() - .child(field("预设名称", Input::new(&self.name))) + .child(field(i18n::t("预设名称"), Input::new(&self.name))) .child( h_flex() .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - "实例 ID", + i18n::t("实例 ID"), Input::new(&self.instance_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - "地域", + i18n::t("地域"), Input::new(&self.region), )), ), ) - .child(field("鉴权方式", Select::new(&self.auth_mode))) + .child(field(i18n::t("鉴权方式"), Select::new(&self.auth_mode))) .child( h_flex() .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - "AccessKey ID", + i18n::t("AccessKey ID"), Input::new(&self.access_key_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - "AccessKey Secret", + i18n::t("AccessKey Secret"), Input::new(&self.access_key_secret).mask_toggle(), )), ), @@ -309,13 +310,13 @@ impl Render for AliyunDialog { .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - "Group ID", + i18n::t("Group ID"), Input::new(&self.group_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - "设备 ID", + i18n::t("设备 ID"), Input::new(&self.device_id), )), ), @@ -325,13 +326,13 @@ impl Render for AliyunDialog { .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - "ProductKey(一机一密)", + i18n::t("ProductKey(一机一密)"), Input::new(&self.product_key), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - "DeviceSecret(一机一密)", + i18n::t("DeviceSecret(一机一密)"), Input::new(&self.device_secret).mask_toggle(), )), ), @@ -348,7 +349,7 @@ impl Render for AliyunDialog { .justify_end() .child( Button::new("aliyun-save") - .label("保存预设") + .label(i18n::t("保存预设")) .outline() .on_click(cx.listener(|this, _, window, cx| { this.save_preset(window, cx) @@ -356,7 +357,7 @@ impl Render for AliyunDialog { ) .child( Button::new("aliyun-connect") - .label("生成并连接") + .label(i18n::t("生成并连接")) .primary() .on_click(cx.listener(|this, _, window, cx| { this.connect(window, cx) @@ -378,7 +379,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(760.)) - .title("阿里云 IoT 设备") + .title(i18n::t("阿里云 IoT 设备")) .child(dialog_view.clone()) .footer(gpui_kit::div()) }); diff --git a/gpui-app/src/ui/dialogs/presets_dialog.rs b/gpui-app/src/ui/dialogs/presets_dialog.rs index 742ea8c..d2006f3 100644 --- a/gpui-app/src/ui/dialogs/presets_dialog.rs +++ b/gpui-app/src/ui/dialogs/presets_dialog.rs @@ -12,6 +12,7 @@ use gpui_kit::component::{ }; use gpui_kit::prelude::FluentBuilder as _; use crate::ui::IconName; +use crate::ui::i18n; use gpui_kit::{ div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, @@ -80,17 +81,17 @@ impl PresetsDialog { let name_value = first.as_ref().map(|p| p.name.clone()).unwrap_or_default(); let name = cx.new(|cx| { InputState::new(window, cx) - .placeholder("预设名称,如 温度上报") + .placeholder(i18n::t("预设名称,如 温度上报")) .default_value(name_value) }); let topic = cx.new(|cx| { InputState::new(window, cx) - .placeholder("发布主题,支持 {{变量名}}") + .placeholder(i18n::t("发布主题,支持 {{变量名}}")) .default_value(params.topic.clone()) }); let payload = cx.new(|cx| { TextareaState::new(window, cx) - .placeholder("消息负载,支持 {{变量名}}") + .placeholder(i18n::t("消息负载,支持 {{变量名}}")) .default_value(params.payload.clone()) }); let qos = make_select(&QOS, params.qos.min(2) as usize, window, cx); @@ -101,11 +102,11 @@ impl PresetsDialog { let payload_format = make_select(&PAYLOAD_FORMATS, fmt_idx, window, cx); let content_type = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Content-Type(可选)") + .placeholder(i18n::t("Content-Type(可选)")) .default_value(params.content_type.clone().unwrap_or_default()) }); let msg_expiry = cx.new(|cx| { - InputState::new(window, cx).placeholder("秒,如 60").default_value( + InputState::new(window, cx).placeholder(i18n::t("秒,如 60")).default_value( params .message_expiry_interval .map(|v| v.to_string()) @@ -114,12 +115,12 @@ impl PresetsDialog { }); let response_topic = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Response Topic(可选)") + .placeholder(i18n::t("Response Topic(可选)")) .default_value(params.response_topic.clone().unwrap_or_default()) }); let correlation_data = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Correlation Data(可选)") + .placeholder(i18n::t("Correlation Data(可选)")) .default_value(params.correlation_data.clone().unwrap_or_default()) }); let user_props = @@ -267,10 +268,10 @@ impl PresetsDialog { /// 「新建」:创建默认名称预设(重名自动加序号)并立即落盘、进入编辑。 fn create_new(&mut self, window: &mut Window, cx: &mut Context) { - let mut name = "新预设".to_string(); + let mut name = i18n::t("新预设").to_string(); let mut n = 2; while self.presets.iter().any(|p| p.name == name) { - name = format!("新预设 {n}"); + name = i18n::tf("新预设 {n}", &[("n", &n.to_string())]); n += 1; } self.presets.push(PublishPreset::new(name, PublishParams::default())); @@ -282,12 +283,12 @@ impl PresetsDialog { /// 「保存」:name 校验 + 冲突检查通过后按 id 更新,再落盘。 fn save(&mut self, window: &mut Window, cx: &mut Context) { let Some(idx) = self.selected else { - window.push_notification(Notification::warning("请先选择或新建预设"), cx); + window.push_notification(Notification::warning(i18n::t("请先选择或新建预设")), cx); return; }; let name = self.name.read(cx).value().trim().to_string(); if name.is_empty() { - window.push_notification(Notification::warning("请填写预设名称"), cx); + window.push_notification(Notification::warning(i18n::t("请填写预设名称")), cx); return; } // 与其他预设(不含自身)重名 → 报错且不落盘 @@ -298,7 +299,10 @@ impl PresetsDialog { .any(|p| p.id != own_id && p.name == name) { window.push_notification( - Notification::error(format!("预设名称「{name}」已存在")), + Notification::error(i18n::tf( + "预设名称「{name}」已存在", + &[("name", &name)], + )), cx, ); return; @@ -308,7 +312,7 @@ impl PresetsDialog { let params = self.collect(cx); if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() { window.push_notification( - Notification::warning("消息过期须为非负整数(秒)"), + Notification::warning(i18n::t("消息过期须为非负整数(秒)")), cx, ); return; @@ -319,13 +323,16 @@ impl PresetsDialog { p.params = params; } self.persist(cx); - window.push_notification(Notification::success(format!("预设「{name}」已保存")), cx); + window.push_notification(Notification::success(i18n::tf( + "预设「{name}」已保存", + &[("name", &name)], + )), cx); } /// 「删除」:`open_alert_dialog` 二次确认后从列表移除并落盘。 fn ask_delete(&mut self, window: &mut Window, cx: &mut Context) { let Some(idx) = self.selected else { - window.push_notification(Notification::warning("请先选择要删除的预设"), cx); + window.push_notification(Notification::warning(i18n::t("请先选择要删除的预设")), cx); return; }; let pid = self.presets[idx].id.clone(); @@ -336,13 +343,16 @@ impl PresetsDialog { let weak = weak.clone(); let pid = pid.clone(); alert - .title("删除预设") - .description(format!("确定删除预设「{pname}」吗?此操作不可撤销。")) + .title(i18n::t("删除预设")) + .description(i18n::tf( + "确定删除预设「{name}」吗?此操作不可撤销。", + &[("name", &pname)], + )) .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text("取消") - .ok_text("删除") + .cancel_text(i18n::t("取消")) + .ok_text(i18n::t("删除")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, window, cx| { @@ -388,7 +398,7 @@ impl Render for PresetsDialog { .text_xs() .text_color(muted) .text_center() - .child("暂无发布预设,点击上方「新建」创建"), + .child(i18n::t("暂无发布预设,点击上方「新建」创建")), ), ); } else { @@ -450,10 +460,10 @@ impl Render for PresetsDialog { .flex_1() .min_w(px(0.)) .gap_2() - .child(field("预设名称", Input::new(&self.name))) - .child(field("发布主题", Input::new(&self.topic))) + .child(field(i18n::t("预设名称"), Input::new(&self.name))) + .child(field(i18n::t("发布主题"), Input::new(&self.topic))) .child(field( - "Payload(原文保存,发送时渲染变量)", + i18n::t("Payload(原文保存,发送时渲染变量)"), Textarea::new(&self.payload).h(px(96.)), )) .child( @@ -484,13 +494,13 @@ impl Render for PresetsDialog { .flex_1() .min_w(px(0.)) .child(field( - "Payload 格式", + i18n::t("Payload 格式"), Select::new(&self.payload_format).small(), )), ), ) .child(div().h(px(1.)).bg(cx.theme().border).w_full()) - .child(div().text_xs().font_semibold().child("MQTT 5 属性")) + .child(div().text_xs().font_semibold().child(i18n::t("MQTT 5 属性"))) .child( h_flex() .gap_2() @@ -502,7 +512,7 @@ impl Render for PresetsDialog { ) .child( div().w(px(120.)).child(field( - "消息过期(秒)", + i18n::t("消息过期(秒)"), Input::new(&self.msg_expiry).small(), )), ), @@ -523,7 +533,7 @@ impl Render for PresetsDialog { )), ), ) - .child(field("用户属性", self.user_props.clone())); + .child(field(i18n::t("用户属性"), self.user_props.clone())); h_flex() .gap_3() @@ -538,11 +548,11 @@ impl Render for PresetsDialog { h_flex() .justify_between() .items_center() - .child(div().text_sm().font_semibold().child("发布预设")) + .child(div().text_sm().font_semibold().child(i18n::t("发布预设"))) .child( Button::new("preset-new") .icon(IconName::Plus) - .label("新建") + .label(i18n::t("新建")) .ghost() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -574,7 +584,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { let has_selection = dialog_view.read(cx).selected.is_some(); dialog .w(px(760.)) - .title("管理发布预设") + .title(i18n::t("管理发布预设")) .child(body) .footer( h_flex() @@ -583,7 +593,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .child( Button::new("preset-delete") .icon(IconName::Trash) - .label("删除") + .label(i18n::t("删除")) .danger() .outline() .disabled(!has_selection) @@ -596,13 +606,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .gap_2() .child( Button::new("preset-close") - .label("关闭") + .label(i18n::t("关闭")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("preset-save") - .label("保存") + .label(i18n::t("保存")) .primary() .disabled(!has_selection) .on_click(move |_, window, cx| { diff --git a/gpui-app/src/ui/dialogs/resource_dialog.rs b/gpui-app/src/ui/dialogs/resource_dialog.rs index eb2de99..6b68d1a 100644 --- a/gpui-app/src/ui/dialogs/resource_dialog.rs +++ b/gpui-app/src/ui/dialogs/resource_dialog.rs @@ -19,6 +19,7 @@ use gpui_kit::{ }; use crate::model::MAX_CONNECTION_MESSAGE_BYTES; +use crate::ui::i18n; use crate::sysmon::{Sample, Sampler}; use crate::ui::app::MqttXApp; use crate::ui::IconName; @@ -89,7 +90,7 @@ impl ResourceMonitorDialog { .get(&c.id) .copied() .map(|s| s.label()) - .unwrap_or("未连接"), + .unwrap_or(i18n::t("未连接")), connected: app.engine.is_connected(&c.id), messages, bytes, @@ -246,7 +247,7 @@ impl ResourceMonitorDialog { div() .text_xs() .text_color(muted) - .child(format!("{} 条消息", c.messages)), + .child(i18n::tf("{n} 条消息", &[("n", &c.messages.to_string())])), ), ) .child( @@ -309,7 +310,7 @@ impl Render for ResourceMonitorDialog { cx.theme().primary, cx.theme().primary_foreground, )) - .child(div().text_base().font_semibold().child("资源监控")) + .child(div().text_base().font_semibold().child(i18n::t("资源监控"))) .child( div() .text_xs() @@ -318,7 +319,7 @@ impl Render for ResourceMonitorDialog { .rounded_full() .bg(cx.theme().muted) .text_color(muted) - .child(format!("运行 {uptime_text}")), + .child(i18n::tf("运行 {t}", &[("t", &uptime_text)])), ), ) .child( @@ -333,7 +334,7 @@ impl Render for ResourceMonitorDialog { div() .text_xs() .text_color(muted) - .child("自动刷新"), + .child(i18n::t("自动刷新")), ) .child( Switch::new("resmon-auto") @@ -347,7 +348,7 @@ impl Render for ResourceMonitorDialog { .child( Button::new("resmon-refresh") .icon(IconName::RefreshCw) - .label("立即刷新") + .label(i18n::t("立即刷新")) .outline() .xsmall() .on_click(cx.listener(|this, _, _, cx| { @@ -366,36 +367,39 @@ impl Render for ResourceMonitorDialog { .child(self.tile( "tile-cpu", IconName::Cpu, - "CPU 占用", + i18n::t("CPU 占用"), format!("{cpu:.1}%"), - Some("按可用核数归一".into()), + Some(i18n::t("按可用核数归一").into()), Some(cpu), cx, )) .child(self.tile( "tile-ws", IconName::MemoryStick, - "工作集内存", + i18n::t("工作集内存"), Self::fmt_bytes(ws), - Some(format!("峰值 {}", Self::fmt_bytes(s.peak_working_set))), + Some(i18n::tf( + "峰值 {v}", + &[("v", &Self::fmt_bytes(s.peak_working_set))], + )), Some(ws as f32 / peak as f32 * 100.), cx, )) .child(self.tile( "tile-private", IconName::HardDrive, - "提交内存", + i18n::t("提交内存"), Self::fmt_bytes(s.private_bytes), - Some("进程独占的物理页承诺".into()), + Some(i18n::t("进程独占的物理页承诺").into()), None, cx, )) .child(self.tile( "tile-threads", IconName::Layers, - "线程 · 句柄", + i18n::t("线程 · 句柄"), format!("{} / {}", s.threads, s.handles), - Some("tokio 引擎 + GPUI 渲染".into()), + Some(i18n::t("tokio 引擎 + GPUI 渲染").into()), None, cx, )); @@ -412,7 +416,7 @@ impl Render for ResourceMonitorDialog { .border_1() .border_color(cx.theme().border) .child(Icon::new(IconName::Info).size_4().text_color(muted)) - .child(div().text_sm().text_color(muted).child("暂无连接")), + .child(div().text_sm().text_color(muted).child(i18n::t("暂无连接"))), ); } for (i, c) in self.conns.iter().enumerate() { @@ -427,7 +431,7 @@ impl Render for ResourceMonitorDialog { .child( v_flex() .gap_2() - .child(self.section_header(IconName::Database, "消息与连接", cx)) + .child(self.section_header(IconName::Database, i18n::t("消息与连接"), cx)) .child( div() .text_xs() @@ -502,7 +506,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(640.)) - .title("资源监控") + .title(i18n::t("资源监控")) .child(dialog_view.clone()) .footer( h_flex() @@ -510,7 +514,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("res-close") - .label("关闭") + .label(i18n::t("关闭")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ), diff --git a/gpui-app/src/ui/dialogs/settings_dialog.rs b/gpui-app/src/ui/dialogs/settings_dialog.rs index b2917f8..9713ea1 100644 --- a/gpui-app/src/ui/dialogs/settings_dialog.rs +++ b/gpui-app/src/ui/dialogs/settings_dialog.rs @@ -17,14 +17,20 @@ use gpui_kit::{ Styled as _, Window, }; -use crate::model::{AppSettings, ThemeModePref, MAX_CONNECTION_MESSAGE_BYTES}; +use crate::model::{AppSettings, LanguagePref, ThemeModePref, MAX_CONNECTION_MESSAGE_BYTES}; use crate::mqtt::MqttEngine; use crate::ui::app::MqttXApp; use crate::ui::widgets::{field, make_select, OptionDelegate}; use crate::ui::IconName; +use crate::ui::i18n; use crate::update::{self, UpdateInfo}; -const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"]; +fn theme_labels() -> [&'static str; 3] { + [i18n::t("跟随系统"), i18n::t("浅色"), i18n::t("深色")] +} +fn language_labels() -> [&'static str; 3] { + [i18n::t("跟随系统 / System"), "中文", "English"] +} /// 消息缓存条数的合法范围:太少不够回看,太多占内存 const MAX_MESSAGES_MIN: usize = 100; @@ -49,6 +55,7 @@ enum UpdateUi { struct SettingsDialog { engine: Arc, theme: Entity>, + language: Entity>, max_messages: Entity, show_millis: bool, auto_check_update: bool, @@ -71,9 +78,15 @@ impl SettingsDialog { ThemeModePref::Light => 1, ThemeModePref::Dark => 2, }; + let lang_idx = match initial.language { + LanguagePref::System => 0, + LanguagePref::Zh => 1, + LanguagePref::En => 2, + }; Self { engine, - theme: make_select(&THEMES, theme_idx, window, cx), + theme: make_select(&theme_labels(), theme_idx, window, cx), + language: make_select(&language_labels(), lang_idx, window, cx), max_messages: cx.new(|cx| { InputState::new(window, cx).default_value(initial.max_messages.to_string()) }), @@ -166,32 +179,32 @@ impl SettingsDialog { match &self.update { UpdateUi::Idle => row( Button::new("check-update") - .label("检查更新") + .label(i18n::t("检查更新")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - "检查 GitHub Releases 上的新版本".into_any_element(), + i18n::t("检查 GitHub Releases 上的新版本").into_any_element(), false, ), UpdateUi::Checking => row( Button::new("check-update") - .label("检查中…") + .label(i18n::t("检查中…")) .outline() .xsmall() .loading(true) .into_any_element(), - "正在检查更新…".into_any_element(), + i18n::t("正在检查更新…").into_any_element(), false, ), UpdateUi::UpToDate => row( Button::new("check-update") - .label("重新检查") + .label(i18n::t("重新检查")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - "已是最新版本".into_any_element(), + i18n::t("已是最新版本").into_any_element(), false, ), UpdateUi::Available(info) => { @@ -199,14 +212,21 @@ impl SettingsDialog { let info_for_click = info.clone(); row( Button::new("apply-update") - .label(format!("立即更新到 v{}", ver)) + .label(i18n::tf( + "立即更新到 v{v}", + &[("v", &ver)], + )) .primary() .xsmall() .on_click(cx.listener(move |this, _, window, cx| { this.run_update(&info_for_click, window, cx) })) .into_any_element(), - format!("发现新版本 v{},下载后自动替换重启", ver).into_any_element(), + i18n::tf( + "发现新版本 v{v},下载后自动替换重启", + &[("v", &ver)], + ) + .into_any_element(), true, ) } @@ -219,9 +239,9 @@ impl SettingsDialog { div() .text_xs() .text_color(cx.theme().accent) - .child(format!( - "新版本 v{} 已下载并通过校验,等待安装", - ver + .child(i18n::tf( + "新版本 v{v} 已下载并通过校验,等待安装", + &[("v", &ver)], )), ) .child( @@ -229,7 +249,7 @@ impl SettingsDialog { .gap_1p5() .child( Button::new("upd-now") - .label("立即安装") + .label(i18n::t("立即安装")) .primary() .xsmall() .on_click(move |_, window, cx| { @@ -247,7 +267,7 @@ impl SettingsDialog { ) .child( Button::new("upd-later") - .label("下次启动安装") + .label(i18n::t("下次启动安装")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -258,9 +278,9 @@ impl SettingsDialog { } this.update = UpdateUi::Idle; window.push_notification( - Notification::info( + Notification::info(i18n::t( "已暂存,下次启动时将自动安装", - ), + )), cx, ); cx.notify(); @@ -268,7 +288,7 @@ impl SettingsDialog { ) .child( Button::new("upd-discard") - .label("放弃此次更新") + .label(i18n::t("放弃此次更新")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -277,7 +297,7 @@ impl SettingsDialog { } this.update = UpdateUi::Idle; window.push_notification( - Notification::info("已删除暂存的更新包"), + Notification::info(i18n::t("已删除暂存的更新包")), cx, ); cx.notify(); @@ -292,14 +312,19 @@ impl SettingsDialog { 0. }; let status = if *total > 0 { - format!( - "下载中 {:.0}%({} / {})", - pct * 100., - Self::fmt_mb(*downloaded), - Self::fmt_mb(*total) + i18n::tf( + "下载中 {pct}%({done} / {total})", + &[ + ("pct", &format!("{:.0}", pct * 100.)), + ("done", &Self::fmt_mb(*downloaded)), + ("total", &Self::fmt_mb(*total)), + ], ) } else { - format!("下载中 {}", Self::fmt_mb(*downloaded)) + i18n::tf( + "下载中 {done}", + &[("done", &Self::fmt_mb(*downloaded))], + ) }; v_flex() .gap_1() @@ -316,12 +341,12 @@ impl SettingsDialog { } UpdateUi::Failed(e) => row( Button::new("check-update") - .label("重试") + .label(i18n::t("重试")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - format!("更新失败:{e}").into_any_element(), + i18n::tf("更新失败:{e}", &[("e", &e)]).into_any_element(), false, ), } @@ -334,6 +359,11 @@ impl SettingsDialog { Some(2) => ThemeModePref::Dark, _ => ThemeModePref::System, }; + let language = match self.language.read(cx).selected_value() { + Some(1) => LanguagePref::Zh, + Some(2) => LanguagePref::En, + _ => LanguagePref::System, + }; // 显式校验范围而非静默钳制:越界直接报错,不落盘 let max_messages = self .max_messages @@ -352,6 +382,7 @@ impl SettingsDialog { // 四个字段全部显式写出,避免 ..Default::default() 把开关项重置为默认值 Ok(AppSettings { theme, + language, max_messages, show_millis: self.show_millis, auto_check_update: self.auto_check_update, @@ -370,7 +401,7 @@ fn open_in_file_manager(path: &Path, window: &mut Window, cx: &mut App) { cmd.arg(path); if let Err(e) = cmd.spawn() { - window.push_notification(Notification::error(format!("打开目录失败: {e}")), cx); + window.push_notification(Notification::error(i18n::tf("打开目录失败: {e}", &[("e", &e.to_string())])), cx); } } @@ -384,24 +415,27 @@ impl Render for SettingsDialog { v_flex() .gap_4() .w(px(500.)) - .child(field("主题", Select::new(&self.theme))) + .child(field(i18n::t("界面主题"), Select::new(&self.theme))) + .child(field(i18n::t("界面语言"), Select::new(&self.language))) .child(field( - "每条连接内存中保留的消息条数(100~100000)", + i18n::t("每条连接内存中保留的消息条数(100~100000)"), Input::new(&self.max_messages), )) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "另有每连接 {}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐", - MAX_CONNECTION_MESSAGE_BYTES / (1024 * 1024) + .child(i18n::tf( + "另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐", + &[ + ("n", &format!("{}", MAX_CONNECTION_MESSAGE_BYTES / (1024 * 1024))), + ], )), ) .child( h_flex() .justify_between() - .child(div().text_sm().child("时间戳显示毫秒")) + .child(div().text_sm().child(i18n::t("时间戳显示毫秒"))) .child( Switch::new("show-millis") .checked(self.show_millis) @@ -414,7 +448,7 @@ impl Render for SettingsDialog { .child( h_flex() .justify_between() - .child(div().text_sm().child("自动检查更新(启动时)")) + .child(div().text_sm().child(i18n::t("自动检查更新(启动时)"))) .child( Switch::new("auto-check-update") .checked(self.auto_check_update) @@ -426,7 +460,7 @@ impl Render for SettingsDialog { ) .child(div().h(px(1.)).bg(cx.theme().border).w_full()) .child(field( - "数据目录", + i18n::t("数据目录"), h_flex() .gap_1() .w_full() @@ -445,7 +479,7 @@ impl Render for SettingsDialog { .child( Button::new("open-data-dir") .icon(IconName::FolderOpen) - .label("打开目录") + .label(i18n::t("打开目录")) .outline() .xsmall() .on_click(move |_, window, cx| { @@ -454,7 +488,7 @@ impl Render for SettingsDialog { ), )) .child(field( - "日志目录", + i18n::t("日志目录"), h_flex() .gap_1() .w_full() @@ -472,14 +506,14 @@ impl Render for SettingsDialog { .child( Button::new("open-log-dir") .icon(IconName::FolderOpen) - .label("打开日志目录") + .label(i18n::t("打开日志目录")) .outline() .xsmall() .on_click(move |_, window, cx| { // 日志目录可能尚未创建(从未产生过日志) if let Err(e) = std::fs::create_dir_all(&log_dir_for_open) { window.push_notification( - Notification::error(format!("创建日志目录失败: {e}")), + Notification::error(i18n::tf("创建日志目录失败: {e}", &[("e", &e.to_string())])), cx, ); return; @@ -493,25 +527,27 @@ impl Render for SettingsDialog { v_flex().gap_1().child( v_flex() .gap_1() - .child(div().text_sm().font_semibold().child("关于")) + .child(div().text_sm().font_semibold().child(i18n::t("关于"))) .child( h_flex() .gap_2() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "版本 {}(更新源 {})", - update::current_version(), - update::UPDATE_SOURCE + .child(i18n::tf( + "版本 {v}(更新源 {src})", + &[ + ("v", &update::current_version().to_string()), + ("src", update::UPDATE_SOURCE), + ], )) - .child("Apache-2.0 许可证"), + .child(i18n::t("Apache-2.0 许可证")), ) .child(self.render_update_row(window, cx)) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端"), + .child(i18n::t("对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端")), ) .child( h_flex().gap_1().child( @@ -569,13 +605,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("settings-cancel") - .label("取消") + .label(i18n::t("取消")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("settings-ok") - .label("保存") + .label(i18n::t("保存")) .primary() .on_click(move |_, window, cx| { let result = for_collect.read(cx).collect(cx); diff --git a/gpui-app/src/ui/dialogs/variables_dialog.rs b/gpui-app/src/ui/dialogs/variables_dialog.rs index 63e1216..8c78974 100644 --- a/gpui-app/src/ui/dialogs/variables_dialog.rs +++ b/gpui-app/src/ui/dialogs/variables_dialog.rs @@ -8,6 +8,7 @@ use gpui_kit::{ use crate::model::GlobalVariable; use crate::ui::app::MqttXApp; +use crate::ui::i18n; use crate::ui::widgets::KvEditor; pub fn open(app: Entity, window: &mut Window, cx: &mut App) { @@ -32,7 +33,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { let app_ok = app.clone(); dialog .w(px(560.)) - .title("全局变量") + .title(i18n::t("全局变量")) .child( v_flex() .gap_2() @@ -40,8 +41,8 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { v_flex() .text_sm() .gap_1() - .child("在发布主题、消息负载中使用 {{变量名}} 引用。") - .child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"), + .child(i18n::t("在发布主题、消息负载中使用 {{变量名}} 引用。")) + .child(i18n::t("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}")), ) .child(editor_body), ) @@ -52,13 +53,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("vars-cancel") - .label("取消") + .label(i18n::t("取消")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("vars-ok") - .label("保存") + .label(i18n::t("保存")) .primary() .on_click(move |_, window, cx| { let vars = for_ok diff --git a/gpui-app/src/ui/i18n.rs b/gpui-app/src/ui/i18n.rs new file mode 100644 index 0000000..d8d3dc8 --- /dev/null +++ b/gpui-app/src/ui/i18n.rs @@ -0,0 +1,431 @@ +//! 极简 i18n:以中文原文为 key 的双语文案表。 +//! +//! 设计取舍: +//! - 中文是基准语言,`t("订阅")` 直接返回原文,代码里不引入间接 key, +//! 漏翻时回落中文而不是出现裸 key; +//! - 英文表集中在本文件一个 `match`,新增文案先写中文、补翻英文即可; +//! - 带参数的文案用 [`tf`],占位符 `{name}` 在中英模式中同名替换; +//! - 语言是进程级全局(UI 渲染热路径每帧取用,用原子量免锁)。 + +use std::sync::atomic::{AtomicU8, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lang { + Zh, + En, +} + +/// 0 = 中文,1 = English。默认中文(基准语言)。 +static LANG: AtomicU8 = AtomicU8::new(0); + +pub fn set_lang(lang: Lang) { + LANG.store(lang as u8, Ordering::Relaxed); +} + +pub fn lang() -> Lang { + if LANG.load(Ordering::Relaxed) == 1 { + Lang::En + } else { + Lang::Zh + } +} + +/// 按系统语言解析初始语言(AppSettings::System 时使用)。 +pub fn from_system() -> Lang { + match sys_locale::get_locale().unwrap_or_default().to_lowercase() { + l if l.starts_with("zh") => Lang::Zh, + _ => Lang::En, + } +} + +/// 取当前语言的界面文案。中文原文即 key,英文缺失时回退中文。 +pub fn t(zh: &'static str) -> &'static str { + if lang() == Lang::Zh { + return zh; + } + en(zh) +} + +/// 带参数的界面文案:中英模式均为 `{name}` 占位符,按同名替换。 +pub fn tf(zh: &'static str, args: &[(&str, &str)]) -> String { + let pattern = if lang() == Lang::Zh { + zh.to_string() + } else { + en(zh).to_string() + }; + let mut out = pattern; + for (name, value) in args { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +/// 英文文案表。key = 中文原文(与调用处字面量一一对应)。 +fn en(zh: &'static str) -> &'static str { + match zh { + // ── 通用 ── + "保存" => "Save", + "取消" => "Cancel", + "删除" => "Delete", + "确定" => "OK", + "全部" => "All", + "清空" => "Clear", + "发送" => "Send", + "连接" => "Connect", + "断开" => "Disconnect", + "订阅" => "Subscriptions", + "编辑" => "Edit", + "高级" => "Advanced", + "错误" => "Error", + "未连接" => "Disconnected", + "连接中" => "Connecting", + "连接中…" => "Connecting…", + "已连接" => "Connected", + "暂停" => "Pause", + "恢复" => "Resume", + "恢复实时滚动" => "Resume live scrolling", + "暂停消息流滚动(消息仍在后台接收)" => { + "Pause message stream (messages keep arriving in background)" + } + "MQTT 调试客户端" => "MQTT debug client", + "新建连接" => "New Connection", + + // ── 标题栏 ── + "置顶窗口" => "Pin window on top", + "取消窗口置顶" => "Unpin window", + "置顶操作失败" => "Failed to set always-on-top", + "切换为浅色" => "Switch to light theme", + "切换为深色" => "Switch to dark theme", + "阿里云设备" => "Alibaba Cloud Devices", + "全局变量" => "Global Variables", + "资源监控" => "Resource Monitor", + "导出连接" => "Export Connections", + "导入连接" => "Import Connections", + "设置" => "Settings", + + // ── 侧边栏 ── + "搜索连接…" => "Search connections…", + "还没有连接,点击上方 + 新建" => "No connections yet. Click + above to create one", + "没有匹配的连接" => "No matching connections", + "收起侧栏(Ctrl+B)" => "Collapse sidebar (Ctrl+B)", + "展开连接侧栏(Ctrl+B)" => "Expand sidebar (Ctrl+B)", + "复制连接" => "Duplicate", + "删除连接" => "Delete Connection", + "未分组" => "Ungrouped", + + // ── 主区 / 标签页 ── + "开始使用 MQTTX" => "Get started with MQTTX", + "从左侧选择一个连接,或新建连接开始调试" => { + "Pick a connection on the left, or create a new one to start debugging" + } + "消息流" => "Messages", + "日志" => "Logs", + "关闭标签页(Ctrl+W)" => "Close tab (Ctrl+W)", + + // ── 连接工作区 ── + "收起侧栏" => "Collapse sidebar", + "收起订阅面板" => "Collapse subscriptions panel", + "展开订阅面板" => "Expand subscriptions panel", + "订阅主题后,消息会显示在右侧" => "Subscribe to a topic and messages will show up here", + "订阅颜色" => "Subscription color", + "随机颜色" => "Random color", + "清除颜色" => "Clear color", + "停用订阅" => "Disable subscription", + "启用订阅" => "Enable subscription", + "编辑订阅" => "Edit subscription", + "取消订阅" => "Unsubscribe", + "订阅主题,支持 # +,多个用逗号/空格分隔" => { + "Topic filter, supports # +; separate multiple with commas/spaces" + } + "别名(可选)" => "Alias (optional)", + "订阅标识符" => "Subscription Identifier", + "No Local" => "No Local", + "Retain As Published" => "Retain As Published", + "Retain Handling" => "Retain Handling", + "0 每次发送" => "0 Send at every subscribe", + "1 仅新订阅" => "1 Send only for new subscription", + "2 不发送" => "2 Do not send", + "正在编辑:{topic}" => "Editing: {topic}", + "订阅主题不能为空" => "Topic filter cannot be empty", + "订阅标识符须为正整数" => "Subscription identifier must be a positive integer", + "未连接:订阅已保存,连接后自动恢复" => { + "Offline: subscription saved, restored on connect" + } + "未连接:订阅已保存" => "Offline: subscription saved", + "QoS 0" => "QoS 0", + "QoS 1" => "QoS 1", + "QoS 2" => "QoS 2", + "更新" => "Update", + + // ── 消息流 ── + "过滤主题或内容…" => "Filter by topic or content…", + "{n} 条" => "{n} msgs", + "仅显示最新 {n} 条" => "Showing latest {n} only", + "清空当前连接的消息" => "Clear messages of this connection", + "清空消息" => "Clear Messages", + "确定清空当前连接的全部消息吗?此操作不可撤销。" => { + "Clear all messages of this connection? This cannot be undone." + } + "接收" => "Received", + "发布" => "Published", + "没有匹配的消息" => "No matching messages", + "暂无消息,订阅主题后消息会显示在这里" => { + "No messages yet — they will appear here once you subscribe" + } + "retain" => "retain", + "(非文本,详情可切 Hex)" => " (binary — switch to Hex in details)", + "用户属性" => "User Properties", + "Content-Type" => "Content-Type", + "Response Topic" => "Response Topic", + "Correlation Data" => "Correlation Data", + "消息过期" => "Message Expiry", + "{n} 秒" => "{n}s", + "复制主题" => "Copy topic", + "复制负载原文" => "Copy raw payload", + "复制完整详情(含 v5 属性)" => "Copy full details (incl. MQTT 5 props)", + "已复制主题" => "Topic copied", + "已复制负载" => "Payload copied", + "已复制详情" => "Details copied", + "主题" => "Topic", + "界面主题" => "Theme", + "负载" => "Payload", + "详情" => "Details", + "自动" => "Auto", + "文本" => "Text", + "连接日志" => "Connection Logs", + "暂无日志" => "No logs yet", + + // ── 发布面板 ── + "发布主题" => "Publish topic", + "输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}" => { + "Message payload; supports {{variables}} and {{$ts}} {{$uuid}}" + } + "消息预设" => "Message Presets", + "保存当前为预设…" => "Save current as preset…", + "管理预设…" => "Manage presets…", + "删除预设" => "Delete Preset", + "确定删除预设「{name}」吗?此操作不可撤销。" => { + "Delete preset \"{name}\"? This cannot be undone." + } + "预设已保存" => "Preset saved", + "已覆盖预设「{name}」" => "Preset \"{name}\" overwritten", + "请填写预设名称" => "Preset name is required", + "预设名称,如 温度上报" => "Preset name, e.g. temperature-report", + "保存消息预设" => "Save Message Preset", + "预设名称" => "Preset name", + "变量注入" => "Variable injection", + "变量注入({n} 个占位符)" => "Variable injection ({n} placeholders)", + "MQTT 5 属性" => "MQTT 5 Properties", + "MQTT 5 订阅选项" => "MQTT 5 Subscription Options", + "未连接:消息未发送" => "Offline: message not sent", + "发布主题不能为空" => "Publish topic cannot be empty", + "消息过期须为非负整数(秒)" => "Message expiry must be a non-negative integer (seconds)", + "JSON 格式无效: {e}" => "Invalid JSON: {e}", + "订阅失败: {e}" => "Subscribe failed: {e}", + "Keep Alive" => "Keep Alive", + "接收上限" => "Receive Maximum", + "主题别名" => "Topic Alias", + "需要是 0~65535 的整数" => "must be an integer 0-65535", + "必须大于 0(0 是 MQTT 5 协议错误)" => "must be > 0 (0 is an MQTT 5 protocol error)", + "需要是非负整数" => "must be a non-negative integer", + "已启用" => "Enabled", + "添加属性" => "Add property", + "删除此属性" => "Remove this property", + "收起" => "Collapse", + "变量值" => "Value", + "主题/负载中没有 {{变量}} 引用" => "No {{variable}} reference in topic/payload", + "重新提取占位符" => "Re-extract placeholders", + "内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入" => "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}} · injected on publish", + "预览:{tp} | {pl}" => "Preview: {tp} | {pl}", + "变量({n})" => "Variables ({n})", + "清除订阅过滤" => "Clear subscription filter", + "取消编辑" => "Cancel editing", + "鉴权方式" => "Auth mode", + "地域" => "Region", + "实例 ID" => "Instance ID", + "已保存预设" => "Saved presets", + "保存预设" => "Save preset", + "生成并连接" => "Generate & connect", + "无法生成连接: {e}" => "Failed to build connection: {e}", + "分组(可选)" => "Group (optional)", + "如 cn-shanghai(可留空)" => "e.g. cn-shanghai (optional)", + "设备 ID" => "Device ID", + "一机一密:ProductKey" => "Triple: ProductKey", + "一机一密:DeviceSecret" => "Triple: DeviceSecret", + "阿里云 IoT 设备" => "Alibaba Cloud IoT", + "按可用核数归一" => "Normalized by core count", + "峰值 {v}" => "Peak {v}", + "进程独占的物理页承诺" => "Physical pages committed by the process", + "tokio 引擎 + GPUI 渲染" => "tokio engine + GPUI renderer", + "暂无连接" => "No connections", + "消息与连接" => "Messages & Connections", + "{n} 条消息" => "{n} msgs", + "运行 {t}" => "Up {t}", + "立即刷新" => "Refresh now", + "发布主题,支持 {{变量名}}" => "Publish topic; supports {{variables}}", + "消息负载,支持 {{变量名}}" => "Message payload; supports {{variables}}", + "Payload(原文保存,发送时渲染变量)" => "Payload (stored raw; variables rendered on send)", + "Payload 格式" => "Payload format", + "暂无发布预设,点击上方「新建」创建" => "No presets yet — click \"New\" above to create one", + "请先选择或新建预设" => "Select or create a preset first", + "请先选择要删除的预设" => "Select a preset to delete first", + "预设「{name}」已保存" => "Preset \"{name}\" saved", + "预设名称「{name}」已存在" => "Preset name \"{name}\" already exists", + "新预设 {n}" => "New preset {n}", + "新预设" => "New preset", + "管理发布预设" => "Manage Publish Presets", + "在发布主题、消息负载中使用 {{变量名}} 引用。" => "Reference {{variables}} in publish topics and payloads.", + "内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}" => "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}}", + "测试任务丢失" => "Test task lost", + "Hex 长度必须为偶数(每两个字符一个字节)" => "Hex length must be even (two chars per byte)", + "Hex 解码失败: 含非十六进制字符" => "Hex decode failed: non-hex character present", + "Hex 解码失败: {e}" => "Hex decode failed: {e}", + "Base64 解码失败: {e}" => "Base64 decode failed: {e}", + "已发送(被当前过滤条件隐藏,消息流中不显示)" => { + "Sent (hidden by current filter — not shown in the stream)" + } + "收起发布面板" => "Collapse publish panel", + "展开发布面板" => "Expand publish panel", + "发布面板已收起" => "Publish panel collapsed", + "Content-Type(可选)" => "Content-Type (optional)", + "秒,如 60" => "seconds, e.g. 60", + "Response Topic(可选)" => "Response Topic (optional)", + "Correlation Data(可选)" => "Correlation Data (optional)", + "消息过期(秒)" => "Expiry (s)", + + // ── 变量面板 ── + "检测到模板变量,发布时注入全局变量值" => { + "Template variables detected — global values injected on publish" + } + + // ── 连接表单 ── + "连接配置" => "Connection Settings", + "基本信息" => "Basics", + "主机" => "Host", + "端口" => "Port", + "名称" => "Name", + "协议" => "Protocol", + "传输" => "Transport", + "分组" => "Group", + "新建分组" => "New group", + "填写后优先于左侧所选分组" => "Overrides the selected group when filled", + "Client ID" => "Client ID", + "重新生成 Client ID" => "Regenerate Client ID", + "WS 路径" => "WS Path", + "broker.example.com" => "broker.example.com", + "连接名称" => "Connection name", + "无分组" => "No group", + "认证与心跳" => "Auth & Keep-alive", + "用户名" => "Username", + "密码" => "Password", + "用户名(可选)" => "Username (optional)", + "密码(可选)" => "Password (optional)", + "Keep Alive (秒)" => "Keep Alive (s)", + "Keep Alive (秒) 需要是 0~65535 的整数" => "Keep Alive must be an integer 0-65535", + "连接超时 (秒)" => "Connect timeout (s)", + "最大重连次数 (0=无限)" => "Max reconnects (0 = unlimited)", + "Clean Start / Clean Session" => "Clean Start / Clean Session", + "断线自动重连" => "Auto reconnect on disconnect", + "连接后自动恢复订阅" => "Auto-resubscribe on connect", + "启动时自动连接" => "Connect on app start", + "SSL/TLS" => "SSL/TLS", + "CA 证书 (PEM)" => "CA certificate (PEM)", + "客户端证书 (PEM)" => "Client certificate (PEM)", + "客户端密钥 (PEM)" => "Client key (PEM)", + "浏览…" => "Browse…", + "选择证书 / 密钥文件" => "Select certificate / key file", + "忽略 CA 校验(不验证服务器证书)" => "Skip CA verification (trust any server cert)", + "已配置 {n} 项" => "{n} configured", + "MQTT 5 属性已配置" => "MQTT 5 properties configured", + "遗嘱消息 (Last Will)" => "Last Will", + "启用遗嘱消息" => "Enable last will", + "遗嘱主题" => "Will topic", + "遗嘱内容" => "Will payload", + "遗嘱主题不能为空" => "Will topic cannot be empty", + "遗嘱主题不能包含通配符 + 或 #" => "Will topic cannot contain wildcards + or #", + "遗嘱消息已启用" => "Last will enabled", + "Retain" => "Retain", + "内容类型 (Content Type)" => "Content Type", + "响应主题 (Response Topic)" => "Response Topic", + "连接名称不能为空" => "Connection name cannot be empty", + "主机地址不能为空" => "Host cannot be empty", + "Client ID 不能为空" => "Client ID cannot be empty", + "会话过期间隔(秒)" => "Session expiry (s)", + "接收最大值" => "Receive Maximum", + "最大报文长度" => "Maximum Packet Size", + "主题别名上限" => "Topic Alias Maximum", + "测试连接" => "Test Connection", + "测试中…" => "Testing…", + "连接测试成功" => "Connection test succeeded", + "配置已保存,重连后生效" => "Saved. Takes effect after reconnect", + + // ── 设置 ── + "跟随系统" => "Follow system", + "浅色" => "Light", + "深色" => "Dark", + "界面语言" => "Language", + "每条连接内存中保留的消息条数(100~100000)" => { + "Messages kept in memory per connection (100-100000)" + } + "另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐" => { + "Plus a per-connection byte budget of {n} MB: under heavy traffic, oldest messages are evicted by size first" + } + "时间戳显示毫秒" => "Show milliseconds in timestamps", + "自动检查更新(启动时)" => "Check for updates on start", + "数据目录" => "Data directory", + "日志目录" => "Log directory", + "打开目录" => "Open folder", + "打开日志目录" => "Open log folder", + "打开目录失败: {e}" => "Failed to open folder: {e}", + "创建日志目录失败: {e}" => "Failed to create log folder: {e}", + "关于" => "About", + "版本 {v}(更新源 {src})" => "Version {v} (update source: {src})", + "Apache-2.0 许可证" => "Apache-2.0 license", + "对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端" => { + "A cross-platform MQTT debug client built with GPUI, aiming at MQTTX" + } + "检查更新" => "Check for updates", + "检查 GitHub Releases 上的新版本" => "Check GitHub Releases for a new version", + "检查中…" => "Checking…", + "正在检查更新…" => "Checking for updates…", + "重新检查" => "Check again", + "已是最新版本" => "You are up to date", + "立即更新到 v{v}" => "Update to v{v} now", + "发现新版本 v{v},下载后自动替换重启" => { + "New version v{v} found — downloads, replaces itself and restarts" + } + "新版本 v{v} 已下载并通过校验,等待安装" => { + "Version v{v} downloaded and verified, ready to install" + } + "立即安装" => "Install now", + "下次启动安装" => "Install on next start", + "已暂存,下次启动时将自动安装" => "Staged — it will install on next start", + "放弃此次更新" => "Discard this update", + "已删除暂存的更新包" => "Staged update discarded", + "下载中 {pct}%({done} / {total})" => "Downloading {pct}% ({done} / {total})", + "下载中 {done}" => "Downloading {done}", + "重试" => "Retry", + "更新失败:{e}" => "Update failed: {e}", + "更新就绪" => "Update Ready", + "新版本 v{v} 已下载完成(已通过 sha256 校验)。" => { + "Version v{v} has been downloaded (sha256 verified)." + } + "暂不更新" => "Not now", + "安装失败:{e}" => "Install failed: {e}", + + // ── 导入 / 导出 / 通知 ── + "导出 {n} 条连接到 {path}" => "Exported {n} connections to {path}", + "导入 {n} 条,跳过 {n2} 条" => "Imported {n}, skipped {n2}", + "已保存" => "Saved", + + // ── 预设管理 ── + "发布预设" => "Publish Presets", + "新建预设" => "New Preset", + "预设列表" => "Presets", + + // ── 资源监控 / 其他对话框 ── + "自身进程资源采样" => "Self-process resource sampling", + + _ => zh, + } +} diff --git a/gpui-app/src/ui/mod.rs b/gpui-app/src/ui/mod.rs index f7b32e5..370e6f0 100644 --- a/gpui-app/src/ui/mod.rs +++ b/gpui-app/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod app; pub mod connection_form; pub mod connection_view; pub mod dialogs; +pub mod i18n; pub mod logo; pub mod widgets; diff --git a/gpui-app/src/ui/widgets/kv_editor.rs b/gpui-app/src/ui/widgets/kv_editor.rs index 135d342..9762c99 100644 --- a/gpui-app/src/ui/widgets/kv_editor.rs +++ b/gpui-app/src/ui/widgets/kv_editor.rs @@ -108,7 +108,7 @@ impl Render for KvEditor { .child( Button::new(SharedString::from(format!("kv-add-{prefix}"))) .icon(IconName::Plus) - .label("添加属性") + .label(crate::ui::i18n::t("添加属性")) .ghost() .xsmall() .on_click(cx.listener(|this, _, window, cx| {