From 35e2b6fb60abbe66c83f5b94d05f2db218c34ac5 Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Sun, 4 Oct 2026 12:27:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(i18n):=20=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=20rust-i18n=20=E8=AF=AD=E4=B9=89=E9=94=AE=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=EF=BC=88ns.key=EF=BC=89=EF=BC=8C=E5=BA=9F=E5=BC=83=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=8E=9F=E6=96=87=E4=BD=9C=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现的问题(按用户反馈): - 中文原文作 key:文案措辞与代码耦合,一词多义撞 key("主题" Topic/Theme) - 巨型 match 文案表:重复键靠编译警告暴露;漏翻静默回退,无任何校验 - tf 参数到处 &n.to_string() 噪音 新体系(gpui-kit 官方方案): - locales/ui.yml 语言包(rust-i18n v2,en/zh-CN 双语并排),key 为 语义标识符(common.save / conn.connect / status.connected…), 与自然语言彻底解耦;改措辞不动代码 - crate 根 i18n! 编译期内嵌;init_i18n() 经 extend! 并入 gpui-kit 组件内置文案(按钮/空态等随语言切换) - t/tf 薄门面查 _rust_i18n_translate(运行时 key),模型 label() 改返回语义 key;tf 参数只需 Display - 四个强制单测:语言包 en/zh-CN 完整、占位符 {x} 两侧一致、 全源码 t()/tf() 字面量必须存在词条、模型 label 必须有真实译文 - 语言中性文案("QoS"、"/mqtt" 等)去掉 t() 包裹 - 顺带补翻 30 条漏网文案(资源监控统计行/表单校验/阿里云说明段等) 42 测试全绿;中英双语实机截图验证零裸 key。 --- Cargo.lock | 1 + gpui-app/Cargo.toml | 1 + gpui-app/locales/ui.yml | 1035 ++++++++++++++++++ gpui-app/src/lib.rs | 11 + gpui-app/src/main.rs | 2 + gpui-app/src/model/message.rs | 4 +- gpui-app/src/model/settings.rs | 14 +- gpui-app/src/ui/app/actions.rs | 10 +- gpui-app/src/ui/app/io.rs | 8 +- gpui-app/src/ui/app/main_area.rs | 6 +- gpui-app/src/ui/app/mod.rs | 10 +- gpui-app/src/ui/app/sidebar.rs | 42 +- gpui-app/src/ui/app/titlebar.rs | 34 +- gpui-app/src/ui/connection_form.rs | 188 ++-- gpui-app/src/ui/connection_view/messages.rs | 80 +- gpui-app/src/ui/connection_view/mod.rs | 48 +- gpui-app/src/ui/connection_view/publish.rs | 68 +- gpui-app/src/ui/connection_view/subscribe.rs | 66 +- gpui-app/src/ui/connection_view/util.rs | 2 +- gpui-app/src/ui/connection_view/vars.rs | 16 +- gpui-app/src/ui/dialogs/aliyun_dialog.rs | 56 +- gpui-app/src/ui/dialogs/presets_dialog.rs | 60 +- gpui-app/src/ui/dialogs/resource_dialog.rs | 79 +- gpui-app/src/ui/dialogs/settings_dialog.rs | 64 +- gpui-app/src/ui/dialogs/variables_dialog.rs | 10 +- gpui-app/src/ui/i18n.rs | 637 +++++------ gpui-app/src/ui/widgets/kv_editor.rs | 2 +- 27 files changed, 1749 insertions(+), 805 deletions(-) create mode 100644 gpui-app/locales/ui.yml diff --git a/Cargo.lock b/Cargo.lock index 34a756a..31b2404 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4264,6 +4264,7 @@ dependencies = [ "rfd", "rumqttc-v4-next", "rumqttc-v5-next", + "rust-i18n", "rustls", "rustls-native-certs", "sentry", diff --git a/gpui-app/Cargo.toml b/gpui-app/Cargo.toml index 20653d2..b4890b2 100644 --- a/gpui-app/Cargo.toml +++ b/gpui-app/Cargo.toml @@ -26,6 +26,7 @@ rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34", features = ["webso uuid = { version = "1", features = ["v4"] } sys-locale = "0.3" +rust-i18n = "4.2" chrono = { version = "0.4", features = ["clock"] } dirs = "7" diff --git a/gpui-app/locales/ui.yml b/gpui-app/locales/ui.yml new file mode 100644 index 0000000..80a4263 --- /dev/null +++ b/gpui-app/locales/ui.yml @@ -0,0 +1,1035 @@ +# MQTTX 应用文案(rust-i18n v2 格式,key = 语义标识符 ns.name)。 +# 规则:en/zh-CN 必填、占位符 {x} 两侧一致、调用点全覆盖——ui/i18n.rs 单测强制。 +_version: 2 +"aliyun.access_key_id": + en: "AccessKey ID" + zh-CN: "AccessKey ID" +"aliyun.access_key_secret": + en: "AccessKey Secret" + zh-CN: "AccessKey Secret" +"aliyun.auth_mode": + en: "Auth mode" + zh-CN: "鉴权方式" +"aliyun.auth_token_mode": + en: "Group token" + zh-CN: "Group 令牌 (Token)" +"aliyun.auth_triple_mode": + en: "Per-device secret" + zh-CN: "一机一密" +"aliyun.build_failed": + en: "Failed to build connection: {e}" + zh-CN: "无法生成连接: {e}" +"aliyun.device_id": + en: "Device ID" + zh-CN: "设备 ID" +"aliyun.device_secret": + en: "DeviceSecret (per-device)" + zh-CN: "DeviceSecret(一机一密)" +"aliyun.device_secret_ph": + en: "Triple: DeviceSecret" + zh-CN: "一机一密:DeviceSecret" +"aliyun.generate_connect": + en: "Generate & connect" + zh-CN: "生成并连接" +"aliyun.group": + en: "Group (optional)" + zh-CN: "分组(可选)" +"aliyun.group_id": + en: "Group ID" + zh-CN: "Group ID" +"aliyun.help": + en: "Token mode (Alibaba Cloud MQTT): endpoint {实例ID}.mqtt.aliyuncs.com, clientId={GroupId}@@@{设备ID}, password is the HMAC-SHA1 signature. Per-device secret (IoT Platform): endpoint {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com, signed per the official triple spec." + zh-CN: "Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名" +"aliyun.instance_id": + en: "Instance ID" + zh-CN: "实例 ID" +"aliyun.preset_name": + en: "Preset name" + zh-CN: "预设名称" +"aliyun.product_key": + en: "ProductKey (per-device)" + zh-CN: "ProductKey(一机一密)" +"aliyun.product_key_ph": + en: "Triple: ProductKey" + zh-CN: "一机一密:ProductKey" +"aliyun.region": + en: "Region" + zh-CN: "地域" +"aliyun.region_ph": + en: "e.g. cn-shanghai (optional)" + zh-CN: "如 cn-shanghai(可留空)" +"aliyun.save_preset": + en: "Save preset" + zh-CN: "保存预设" +"aliyun.saved_presets": + en: "Saved presets" + zh-CN: "已保存预设" +"aliyun.title": + en: "Alibaba Cloud IoT" + zh-CN: "阿里云 IoT 设备" +"color.blue": + en: "Blue" + zh-CN: "蓝" +"color.cyan": + en: "Cyan" + zh-CN: "青" +"color.green": + en: "Green" + zh-CN: "绿" +"color.indigo": + en: "Indigo" + zh-CN: "靛" +"color.magenta": + en: "Magenta" + zh-CN: "品红" +"color.orange": + en: "Orange" + zh-CN: "橙" +"color.purple": + en: "Purple" + zh-CN: "紫" +"color.red": + en: "Red" + zh-CN: "红" +"color.rose": + en: "Rose" + zh-CN: "玫红" +"color.yellow": + en: "Yellow" + zh-CN: "黄" +"common.advanced": + en: "Advanced" + zh-CN: "高级" +"common.all": + en: "All" + zh-CN: "全部" +"common.auto": + en: "Auto" + zh-CN: "自动" +"common.browse": + en: "Browse…" + zh-CN: "浏览…" +"common.cancel": + en: "Cancel" + zh-CN: "取消" +"common.clear": + en: "Clear" + zh-CN: "清空" +"common.close": + en: "Close" + zh-CN: "关闭" +"common.collapse": + en: "Collapse" + zh-CN: "收起" +"common.delete": + en: "Delete" + zh-CN: "删除" +"common.edit": + en: "Edit" + zh-CN: "编辑" +"common.new": + en: "New" + zh-CN: "新建" +"common.ok": + en: "OK" + zh-CN: "确定" +"common.pause": + en: "Pause" + zh-CN: "暂停" +"common.resume": + en: "Resume" + zh-CN: "恢复" +"common.retain": + en: "Retain" + zh-CN: "Retain" +"common.retry": + en: "Retry" + zh-CN: "重试" +"common.save": + en: "Save" + zh-CN: "保存" +"common.saved": + en: "Saved" + zh-CN: "已保存" +"common.send": + en: "Send" + zh-CN: "发送" +"common.text": + en: "Text" + zh-CN: "文本" +"common.update": + en: "Update" + zh-CN: "更新" +"connview.connecting": + en: "Connecting…" + zh-CN: "连接中…" +"connview.logs": + en: "Logs" + zh-CN: "日志" +"connview.messages": + en: "Messages" + zh-CN: "消息流" +"connview.subscriptions": + en: "Subscriptions" + zh-CN: "订阅" +"dir.published": + en: "Published" + zh-CN: "发布" +"dir.received": + en: "Received" + zh-CN: "接收" +"encode.base64_failed": + en: "Base64 decode failed: {e}" + zh-CN: "Base64 解码失败: {e}" +"encode.hex_failed": + en: "Hex decode failed: {e}" + zh-CN: "Hex 解码失败: {e}" +"encode.hex_invalid": + en: "Hex decode failed: non-hex character present" + zh-CN: "Hex 解码失败: 含非十六进制字符" +"encode.hex_odd": + en: "Hex length must be even (two chars per byte)" + zh-CN: "Hex 长度必须为偶数(每两个字符一个字节)" +"form.auth_section": + en: "Auth & Keep-alive" + zh-CN: "认证与心跳" +"form.auto_connect": + en: "Connect on app start" + zh-CN: "启动时自动连接" +"form.auto_reconnect": + en: "Auto reconnect on disconnect" + zh-CN: "断线自动重连" +"form.auto_resub": + en: "Auto-resubscribe on connect" + zh-CN: "连接后自动恢复订阅" +"form.basics": + en: "Basics" + zh-CN: "基本信息" +"form.cert_pair_required": + en: "Client certificate and key must be provided together" + zh-CN: "客户端证书与客户端密钥必须同时填写" +"form.clean_start": + en: "Clean Start / Clean Session" + zh-CN: "Clean Start / Clean Session" +"form.client_id": + en: "Client ID" + zh-CN: "Client ID" +"form.client_id_required": + en: "Client ID cannot be empty" + zh-CN: "Client ID 不能为空" +"form.conn_timeout": + en: "Connect timeout (s)" + zh-CN: "连接超时 (秒)" +"form.conn_timeout_invalid": + en: "Connect timeout must be an integer 1-65535 (seconds)" + zh-CN: "连接超时需要是 1~65535 的整数(秒)" +"form.content_type_ph": + en: "Content type (optional)" + zh-CN: "内容类型(可选)" +"form.err_positive": + en: "must be > 0 (0 is an MQTT 5 protocol error)" + zh-CN: "必须大于 0(0 是 MQTT 5 协议错误)" +"form.err_range": + en: "must be an integer 0-65535" + zh-CN: "需要是 0~65535 的整数" +"form.err_uint": + en: "must be a non-negative integer" + zh-CN: "需要是非负整数" +"form.group": + en: "Group" + zh-CN: "分组" +"form.host": + en: "Host" + zh-CN: "主机" +"form.host_required": + en: "Host cannot be empty" + zh-CN: "主机地址不能为空" +"form.keep_alive": + en: "Keep Alive (s)" + zh-CN: "Keep Alive (秒)" +"form.keep_alive_invalid": + en: "Keep Alive must be an integer 0-65535" + zh-CN: "Keep Alive (秒) 需要是 0~65535 的整数" +"form.keep_alive_short": + en: "Keep Alive" + zh-CN: "Keep Alive" +"form.max_packet": + en: "Maximum Packet Size" + zh-CN: "最大报文长度" +"form.max_reconnects": + en: "Max reconnects (0 = unlimited)" + zh-CN: "最大重连次数 (0=无限)" +"form.max_reconnects_invalid": + en: "Max reconnects must be a non-negative integer (0 = unlimited)" + zh-CN: "最大重连次数需要是非负整数(0=无限)" +"form.max_reconnects_ph": + en: "0 = unlimited" + zh-CN: "0=无限重连" +"form.name": + en: "Name" + zh-CN: "名称" +"form.name_ph": + en: "Connection name" + zh-CN: "连接名称" +"form.name_required": + en: "Connection name cannot be empty" + zh-CN: "连接名称不能为空" +"form.new_group": + en: "New group" + zh-CN: "新建分组" +"form.new_group_hint": + en: "Overrides the selected group when filled" + zh-CN: "填写后优先于左侧所选分组" +"form.no_group": + en: "No group" + zh-CN: "无分组" +"form.password": + en: "Password" + zh-CN: "密码" +"form.password_ph": + en: "Password (optional)" + zh-CN: "密码(可选)" +"form.path_missing": + en: "{label}: path not found: {p}" + zh-CN: "{label}路径不存在:{p}" +"form.port": + en: "Port" + zh-CN: "端口" +"form.port_invalid": + en: "Port must be an integer 1-65535" + zh-CN: "端口需要是 1~65535 的整数" +"form.protocol": + en: "Protocol" + zh-CN: "协议" +"form.receive_max": + en: "Receive Maximum" + zh-CN: "接收最大值" +"form.receive_max_invalid": + en: "Receive Maximum cannot exceed 65535" + zh-CN: "接收上限不能超过 65535" +"form.receive_max_ph": + en: "Server receive maximum" + zh-CN: "服务端接收上限" +"form.receive_max_short": + en: "Receive Maximum" + zh-CN: "接收上限" +"form.regen_client_id": + en: "Regenerate Client ID" + zh-CN: "重新生成 Client ID" +"form.response_topic_ph": + en: "Response topic (optional)" + zh-CN: "响应主题(可选)" +"form.saved_reconnect": + en: "Saved. Takes effect after reconnect" + zh-CN: "配置已保存,重连后生效" +"form.session_expiry": + en: "Session expiry (s)" + zh-CN: "会话过期间隔(秒)" +"form.session_expiry_invalid": + en: "Session expiry must be a non-negative integer (0 = session ends with connection)" + zh-CN: "会话过期间隔需要是非负整数(0=会话随连接结束)" +"form.session_expiry_ph": + en: "seconds (0 = session ends with connection)" + zh-CN: "秒(0=会话随连接结束)" +"form.test": + en: "Test Connection" + zh-CN: "测试连接" +"form.test_ok": + en: "Connection test succeeded" + zh-CN: "连接测试成功" +"form.testing": + en: "Testing…" + zh-CN: "测试中…" +"form.timeout_ph": + en: "seconds (default 10)" + zh-CN: "秒(默认 10)" +"form.title": + en: "Connection Settings" + zh-CN: "连接配置" +"form.topic_alias": + en: "Topic Alias" + zh-CN: "主题别名" +"form.topic_alias_invalid": + en: "Topic Alias Maximum cannot exceed 65535" + zh-CN: "主题别名上限不能超过 65535" +"form.topic_alias_max": + en: "Topic Alias Maximum" + zh-CN: "主题别名上限" +"form.transport": + en: "Transport" + zh-CN: "传输" +"form.username": + en: "Username" + zh-CN: "用户名" +"form.username_ph": + en: "Username (optional)" + zh-CN: "用户名(可选)" +"form.will_content_type": + en: "Content Type" + zh-CN: "内容类型 (Content Type)" +"form.will_enable": + en: "Enable last will" + zh-CN: "启用遗嘱消息" +"form.will_payload": + en: "Will payload" + zh-CN: "遗嘱内容" +"form.will_payload_ph": + en: "Will message" + zh-CN: "遗嘱消息" +"form.will_response_topic": + en: "Response Topic" + zh-CN: "响应主题 (Response Topic)" +"form.will_section": + en: "Last Will" + zh-CN: "遗嘱消息 (Last Will)" +"form.will_topic": + en: "Will topic" + zh-CN: "遗嘱主题" +"form.ws_path": + en: "WS Path" + zh-CN: "WS 路径" +"form.ws_path_required": + en: "WS path is required for WS/WSS transport" + zh-CN: "WS/WSS 传输下 WS 路径不能为空" +"io.exported": + en: "Exported {n} connections to {path}" + zh-CN: "导出 {n} 条连接到 {path}" +"io.imported": + en: "Imported {n}, skipped {n2}" + zh-CN: "导入 {n} 条,跳过 {n2} 条" +"kv.add": + en: "Add property" + zh-CN: "添加属性" +"kv.remove": + en: "Remove this property" + zh-CN: "删除此属性" +"msg.binary_hint": + en: " (binary — switch to Hex in details)" + zh-CN: "(非文本,详情可切 Hex)" +"msg.clear_confirm": + en: "Clear all messages of this connection? This cannot be undone." + zh-CN: "确定清空当前连接的全部消息吗?此操作不可撤销。" +"msg.clear_sub_filter": + en: "Clear subscription filter" + zh-CN: "清除订阅过滤" +"msg.clear_title": + en: "Clear Messages" + zh-CN: "清空消息" +"msg.clear_tooltip": + en: "Clear messages of this connection" + zh-CN: "清空当前连接的消息" +"msg.copy_details": + en: "Copy full details (incl. MQTT 5 props)" + zh-CN: "复制完整详情(含 v5 属性)" +"msg.copy_payload": + en: "Copy raw payload" + zh-CN: "复制负载原文" +"msg.copy_topic": + en: "Copy topic" + zh-CN: "复制主题" +"msg.count": + en: "{n} msgs" + zh-CN: "{n} 条" +"msg.details": + en: "Details" + zh-CN: "详情" +"msg.details_copied": + en: "Details copied" + zh-CN: "已复制详情" +"msg.empty": + en: "No messages yet — they will appear here once you subscribe" + zh-CN: "暂无消息,订阅主题后消息会显示在这里" +"msg.expiry": + en: "Message Expiry" + zh-CN: "消息过期" +"msg.filter_placeholder": + en: "Filter by topic or content…" + zh-CN: "过滤主题或内容…" +"msg.latest_only": + en: "Showing latest {n} only" + zh-CN: "仅显示最新 {n} 条" +"msg.log_title": + en: "Connection Logs" + zh-CN: "连接日志" +"msg.no_logs": + en: "No logs yet" + zh-CN: "暂无日志" +"msg.no_match": + en: "No matching messages" + zh-CN: "没有匹配的消息" +"msg.pause_tooltip": + en: "Pause message stream (messages keep arriving in background)" + zh-CN: "暂停消息流滚动(消息仍在后台接收)" +"msg.payload": + en: "Payload" + zh-CN: "负载" +"msg.payload_copied": + en: "Payload copied" + zh-CN: "已复制负载" +"msg.resume_tooltip": + en: "Resume live scrolling" + zh-CN: "恢复实时滚动" +"msg.seconds": + en: "{n}s" + zh-CN: "{n} 秒" +"msg.topic": + en: "Topic" + zh-CN: "主题" +"msg.topic_copied": + en: "Topic copied" + zh-CN: "已复制主题" +"msg.truncated": + en: "Truncated {n}KB" + zh-CN: "已截断 {n}KB" +"preset.default_name": + en: "New preset" + zh-CN: "新预设" +"preset.default_name_n": + en: "New preset {n}" + zh-CN: "新预设 {n}" +"preset.empty": + en: "No presets yet — click \"New\" above to create one" + zh-CN: "暂无发布预设,点击上方「新建」创建" +"preset.list": + en: "Presets" + zh-CN: "预设列表" +"preset.manage_title": + en: "Manage Publish Presets" + zh-CN: "管理发布预设" +"preset.name_exists": + en: "Preset name \"{name}\" already exists" + zh-CN: "预设名称「{name}」已存在" +"preset.new": + en: "New Preset" + zh-CN: "新建预设" +"preset.payload_field": + en: "Payload (stored raw; variables rendered on send)" + zh-CN: "Payload(原文保存,发送时渲染变量)" +"preset.payload_format": + en: "Payload format" + zh-CN: "Payload 格式" +"preset.pick_delete_first": + en: "Select a preset to delete first" + zh-CN: "请先选择要删除的预设" +"preset.pick_first": + en: "Select or create a preset first" + zh-CN: "请先选择或新建预设" +"preset.saved": + en: "Preset \"{name}\" saved" + zh-CN: "预设「{name}」已保存" +"preset.section": + en: "Publish Presets" + zh-CN: "发布预设" +"preset.user_props": + en: "User Properties" + zh-CN: "用户属性" +"pub.collapse": + en: "Collapse publish panel" + zh-CN: "收起发布面板" +"pub.collapsed_hint": + en: "Publish panel collapsed" + zh-CN: "发布面板已收起" +"pub.content_type_ph": + en: "Content-Type (optional)" + zh-CN: "Content-Type(可选)" +"pub.correlation_ph": + en: "Correlation Data (optional)" + zh-CN: "Correlation Data(可选)" +"pub.delete_confirm": + en: "Delete preset \"{name}\"? This cannot be undone." + zh-CN: "确定删除预设「{name}」吗?此操作不可撤销。" +"pub.delete_item": + en: "Delete \"{name}\"" + zh-CN: "删除「{name}」" +"pub.delete_title": + en: "Delete Preset" + zh-CN: "删除预设" +"pub.expand": + en: "Expand publish panel" + zh-CN: "展开发布面板" +"pub.expiry_field": + en: "Expiry (s)" + zh-CN: "消息过期(秒)" +"pub.expiry_invalid": + en: "Message expiry must be a non-negative integer (seconds)" + zh-CN: "消息过期须为非负整数(秒)" +"pub.json_invalid": + en: "Invalid JSON: {e}" + zh-CN: "JSON 格式无效: {e}" +"pub.manage_presets": + en: "Manage presets…" + zh-CN: "管理预设…" +"pub.mqtt5_props": + en: "MQTT 5 Properties" + zh-CN: "MQTT 5 属性" +"pub.offline_not_sent": + en: "Offline: message not sent" + zh-CN: "未连接:消息未发送" +"pub.payload_placeholder": + en: "Message payload; supports {{variables}} and {{$ts}} {{$uuid}}" + zh-CN: "输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}" +"pub.payload_preset_ph": + en: "Message payload; supports {{variables}}" + zh-CN: "消息负载,支持 {{变量名}}" +"pub.preset_menu": + en: "Message Presets" + zh-CN: "消息预设" +"pub.preset_name_hint": + en: "Preset name, e.g. temperature-report" + zh-CN: "预设名称,如 温度上报" +"pub.preset_name_required": + en: "Preset name is required" + zh-CN: "请填写预设名称" +"pub.preset_overwritten": + en: "Preset \"{name}\" overwritten" + zh-CN: "已覆盖预设「{name}」" +"pub.preset_saved": + en: "Preset saved" + zh-CN: "预设已保存" +"pub.response_topic_ph": + en: "Response Topic (optional)" + zh-CN: "Response Topic(可选)" +"pub.save_preset": + en: "Save current as preset…" + zh-CN: "保存当前为预设…" +"pub.save_title": + en: "Save Message Preset" + zh-CN: "保存消息预设" +"pub.seconds_hint": + en: "seconds, e.g. 60" + zh-CN: "秒,如 60" +"pub.send_disabled": + en: "Offline — cannot send" + zh-CN: "未连接,无法发送" +"pub.sent_hidden": + en: "Sent (hidden by current filter — not shown in the stream)" + zh-CN: "已发送(被当前过滤条件隐藏,消息流中不显示)" +"pub.topic_placeholder": + en: "Publish topic" + zh-CN: "发布主题" +"pub.topic_preset_ph": + en: "Publish topic; supports {{variables}}" + zh-CN: "发布主题,支持 {{变量名}}" +"pub.topic_required": + en: "Publish topic cannot be empty" + zh-CN: "发布主题不能为空" +"pub.vars": + en: "Variable injection" + zh-CN: "变量注入" +"pub.vars_count": + en: "Variable injection ({n} placeholders)" + zh-CN: "变量注入({n} 个占位符)" +"resmon.auto_refresh": + en: "Auto refresh" + zh-CN: "自动刷新" +"resmon.budget": + en: "Budget {p}% used ({used} / {total})" + zh-CN: "预算占用 {p}%({used} / {total})" +"resmon.committed": + en: "Committed memory" + zh-CN: "提交内存" +"resmon.committed_hint": + en: "Physical pages committed by the process" + zh-CN: "进程独占的物理页承诺" +"resmon.cpu": + en: "CPU usage" + zh-CN: "CPU 占用" +"resmon.cpu_hint": + en: "Normalized by core count" + zh-CN: "按可用核数归一" +"resmon.n_msgs": + en: "{n} msgs" + zh-CN: "{n} 条消息" +"resmon.no_conn": + en: "No connections" + zh-CN: "暂无连接" +"resmon.peak": + en: "Peak {v}" + zh-CN: "峰值 {v}" +"resmon.refresh_now": + en: "Refresh now" + zh-CN: "立即刷新" +"resmon.section": + en: "Messages & Connections" + zh-CN: "消息与连接" +"resmon.stats": + en: "{n} connections ({m} connected) · {t} tabs · {msgs} messages in memory (cap {cap}/conn) · {logs} log lines" + zh-CN: "{n} 个连接(已连接 {m})· 打开标签 {t} · 内存消息 {msgs} 条(每连接上限 {cap} 条)· 日志 {logs} 条" +"resmon.threads_handles": + en: "Threads · handles" + zh-CN: "线程 · 句柄" +"resmon.threads_hint": + en: "tokio engine + GPUI renderer" + zh-CN: "tokio 引擎 + GPUI 渲染" +"resmon.title_hint": + en: "Self-process resource sampling" + zh-CN: "自身进程资源采样" +"resmon.uptime": + en: "Up {t}" + zh-CN: "运行 {t}" +"resmon.version_live": + en: "Version v{v} · refreshes every 1.5s" + zh-CN: "版本 v{v} · 每 1.5 秒自动刷新" +"resmon.version_paused": + en: "Version v{v} · auto-refresh paused" + zh-CN: "版本 v{v} · 自动刷新已暂停" +"resmon.working_set": + en: "Working set" + zh-CN: "工作集内存" +"settings.about": + en: "About" + zh-CN: "关于" +"settings.auto_update": + en: "Check for updates on start" + zh-CN: "自动检查更新(启动时)" +"settings.byte_budget": + en: "Plus a per-connection byte budget of {n} MB: under heavy traffic, oldest messages are evicted by size first" + zh-CN: "另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐" +"settings.data_dir": + en: "Data directory" + zh-CN: "数据目录" +"settings.language": + en: "Language" + zh-CN: "界面语言" +"settings.license": + en: "Apache-2.0 license" + zh-CN: "Apache-2.0 许可证" +"settings.log_dir": + en: "Log directory" + zh-CN: "日志目录" +"settings.max_messages": + en: "Messages kept in memory per connection (100-100000)" + zh-CN: "每条连接内存中保留的消息条数(100~100000)" +"settings.mkdir_failed": + en: "Failed to create log folder: {e}" + zh-CN: "创建日志目录失败: {e}" +"settings.open_dir": + en: "Open folder" + zh-CN: "打开目录" +"settings.open_dir_failed": + en: "Failed to open folder: {e}" + zh-CN: "打开目录失败: {e}" +"settings.open_log_dir": + en: "Open log folder" + zh-CN: "打开日志目录" +"settings.show_millis": + en: "Show milliseconds in timestamps" + zh-CN: "时间戳显示毫秒" +"settings.tagline": + en: "A cross-platform MQTT debug client built with GPUI, aiming at MQTTX" + zh-CN: "对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端" +"settings.theme": + en: "Theme" + zh-CN: "界面主题" +"settings.version_line": + en: "Version {v} (update source: {src})" + zh-CN: "版本 {v}(更新源 {src})" +"sidebar.collapse": + en: "Collapse sidebar (Ctrl+B)" + zh-CN: "收起侧栏(Ctrl+B)" +"sidebar.collapse_short": + en: "Collapse sidebar" + zh-CN: "收起侧栏" +"sidebar.connections": + en: "Connections" + zh-CN: "连接" +"sidebar.delete": + en: "Delete Connection" + zh-CN: "删除连接" +"sidebar.delete_confirm": + en: "Delete \"{name}\"? This also deletes {sub_count} subscriptions and disconnects. This cannot be undone." + zh-CN: "确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。" +"sidebar.disconnect": + en: "Disconnect" + zh-CN: "断开" +"sidebar.duplicate": + en: "Duplicate" + zh-CN: "复制连接" +"sidebar.empty": + en: "No connections yet. Click + above to create one" + zh-CN: "还没有连接,点击上方 + 新建" +"sidebar.expand": + en: "Expand sidebar (Ctrl+B)" + zh-CN: "展开连接侧栏(Ctrl+B)" +"sidebar.no_match": + en: "No matching connections" + zh-CN: "没有匹配的连接" +"sidebar.search": + en: "Search connections…" + zh-CN: "搜索连接…" +"sidebar.ungrouped": + en: "Ungrouped" + zh-CN: "未分组" +"ssl.ca": + en: "CA certificate (PEM)" + zh-CN: "CA 证书 (PEM)" +"ssl.ca_ph": + en: "Path to CA certificate (PEM, optional)" + zh-CN: "CA 证书 PEM 路径(可选)" +"ssl.cert": + en: "Client certificate (PEM)" + zh-CN: "客户端证书 (PEM)" +"ssl.cert_ph": + en: "Path to client certificate (PEM, optional)" + zh-CN: "客户端证书 PEM 路径(可选)" +"ssl.key": + en: "Client key (PEM)" + zh-CN: "客户端密钥 (PEM)" +"ssl.key_ph": + en: "Path to client private key (PEM, optional)" + zh-CN: "客户端私钥 PEM 路径(可选)" +"ssl.n_configured": + en: "{n} configured" + zh-CN: "已配置 {n} 项" +"ssl.pick_file": + en: "Select certificate / key file" + zh-CN: "选择证书 / 密钥文件" +"ssl.section": + en: "SSL/TLS" + zh-CN: "SSL/TLS" +"ssl.skip_verify": + en: "Skip CA verification (trust any server cert)" + zh-CN: "忽略 CA 校验(不验证服务器证书)" +"status.connected": + en: "Connected" + zh-CN: "已连接" +"status.connecting": + en: "Connecting" + zh-CN: "连接中" +"status.disconnected": + en: "Disconnected" + zh-CN: "未连接" +"status.error": + en: "Error" + zh-CN: "错误" +"sub.alias": + en: "Alias (optional)" + zh-CN: "别名(可选)" +"sub.cancel_edit": + en: "Cancel editing" + zh-CN: "取消编辑" +"sub.collapse": + en: "Collapse subscriptions panel" + zh-CN: "收起订阅面板" +"sub.color": + en: "Subscription color" + zh-CN: "订阅颜色" +"sub.color_clear": + en: "Clear color" + zh-CN: "清除颜色" +"sub.color_random": + en: "Random color" + zh-CN: "随机颜色" +"sub.disable": + en: "Disable subscription" + zh-CN: "停用订阅" +"sub.edit": + en: "Edit subscription" + zh-CN: "编辑订阅" +"sub.editing": + en: "Editing: {topic}" + zh-CN: "正在编辑:{topic}" +"sub.empty_hint": + en: "Subscribe to a topic and messages will show up here" + zh-CN: "订阅主题后,消息会显示在右侧" +"sub.enable": + en: "Enable subscription" + zh-CN: "启用订阅" +"sub.expand": + en: "Expand subscriptions panel" + zh-CN: "展开订阅面板" +"sub.failed": + en: "Subscribe failed: {e}" + zh-CN: "订阅失败: {e}" +"sub.identifier": + en: "Subscription Identifier" + zh-CN: "订阅标识符" +"sub.identifier_invalid": + en: "Subscription identifier must be a positive integer" + zh-CN: "订阅标识符须为正整数" +"sub.mqtt5_options": + en: "MQTT 5 Subscription Options" + zh-CN: "MQTT 5 订阅选项" +"sub.no_local": + en: "No Local" + zh-CN: "No Local" +"sub.rap": + en: "Retain As Published" + zh-CN: "Retain As Published" +"sub.retain_handling": + en: "Retain Handling" + zh-CN: "Retain Handling" +"sub.rh0": + en: "0 Send at every subscribe" + zh-CN: "0 每次发送" +"sub.rh1": + en: "1 Send only for new subscription" + zh-CN: "1 仅新订阅" +"sub.rh2": + en: "2 Do not send" + zh-CN: "2 不发送" +"sub.saved_offline": + en: "Offline: subscription saved" + zh-CN: "未连接:订阅已保存" +"sub.saved_offline_resub": + en: "Offline: subscription saved, restored on connect" + zh-CN: "未连接:订阅已保存,连接后自动恢复" +"sub.topic_placeholder": + en: "Topic filter, supports # +; separate multiple with commas/spaces" + zh-CN: "订阅主题,支持 # +,多个用逗号/空格分隔" +"sub.topic_required": + en: "Topic filter cannot be empty" + zh-CN: "订阅主题不能为空" +"sub.unsubscribe": + en: "Unsubscribe" + zh-CN: "取消订阅" +"tab.welcome_hint": + en: "Pick a connection on the left, or create a new one to start debugging" + zh-CN: "从左侧选择一个连接,或新建连接开始调试" +"tab.welcome_title": + en: "Get started with MQTTX" + zh-CN: "开始使用 MQTTX" +"theme.dark": + en: "Dark" + zh-CN: "深色" +"theme.light": + en: "Light" + zh-CN: "浅色" +"theme.system": + en: "Follow system" + zh-CN: "跟随系统" +"titlebar.aliyun": + en: "Alibaba Cloud Devices" + zh-CN: "阿里云设备" +"titlebar.export": + en: "Export Connections" + zh-CN: "导出连接" +"titlebar.import": + en: "Import Connections" + zh-CN: "导入连接" +"titlebar.new_connection": + en: "New Connection" + zh-CN: "新建连接" +"titlebar.pin": + en: "Pin window on top" + zh-CN: "置顶窗口" +"titlebar.pin_failed": + en: "Failed to set always-on-top" + zh-CN: "置顶操作失败" +"titlebar.resmon": + en: "Resource Monitor" + zh-CN: "资源监控" +"titlebar.settings": + en: "Settings" + zh-CN: "设置" +"titlebar.subtitle": + en: "MQTT debug client" + zh-CN: "MQTT 调试客户端" +"titlebar.switch_dark": + en: "Switch to dark theme" + zh-CN: "切换为深色" +"titlebar.switch_light": + en: "Switch to light theme" + zh-CN: "切换为浅色" +"titlebar.unpin": + en: "Unpin window" + zh-CN: "取消窗口置顶" +"titlebar.variables": + en: "Global Variables" + zh-CN: "全局变量" +"update.apply": + en: "Update to v{v} now" + zh-CN: "立即更新到 v{v}" +"update.check": + en: "Check for updates" + zh-CN: "检查更新" +"update.check_hint": + en: "Check GitHub Releases for a new version" + zh-CN: "检查 GitHub Releases 上的新版本" +"update.checking": + en: "Checking…" + zh-CN: "检查中…" +"update.checking_long": + en: "Checking for updates…" + zh-CN: "正在检查更新…" +"update.discard": + en: "Discard this update" + zh-CN: "放弃此次更新" +"update.discarded": + en: "Staged update discarded" + zh-CN: "已删除暂存的更新包" +"update.downloading": + en: "Downloading {pct}% ({done} / {total})" + zh-CN: "下载中 {pct}%({done} / {total})" +"update.downloading_size": + en: "Downloading {done}" + zh-CN: "下载中 {done}" +"update.failed": + en: "Update failed: {e}" + zh-CN: "更新失败:{e}" +"update.found": + en: "New version v{v} found — downloads, replaces itself and restarts" + zh-CN: "发现新版本 v{v},下载后自动替换重启" +"update.install_failed": + en: "Install failed: {e}" + zh-CN: "安装失败:{e}" +"update.install_later": + en: "Install on next start" + zh-CN: "下次启动安装" +"update.install_now": + en: "Install now" + zh-CN: "立即安装" +"update.not_now": + en: "Not now" + zh-CN: "暂不更新" +"update.ready": + en: "Version v{v} downloaded and verified, ready to install" + zh-CN: "新版本 v{v} 已下载并通过校验,等待安装" +"update.ready_body": + en: "Version v{v} has been downloaded (sha256 verified)." + zh-CN: "新版本 v{v} 已下载完成(已通过 sha256 校验)。" +"update.ready_title": + en: "Update Ready" + zh-CN: "更新就绪" +"update.recheck": + en: "Check again" + zh-CN: "重新检查" +"update.staged": + en: "Staged — it will install on next start" + zh-CN: "已暂存,下次启动时将自动安装" +"update.up_to_date": + en: "You are up to date" + zh-CN: "已是最新版本" +"var.builtin_inline": + en: "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}} · injected on publish" + zh-CN: "内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入" +"var.dialog_builtin": + en: "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}}" + zh-CN: "内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}" +"var.dialog_hint": + en: "Reference {{variables}} in publish topics and payloads." + zh-CN: "在发布主题、消息负载中使用 {{变量名}} 引用。" +"var.no_reference": + en: "No {{variable}} reference in topic/payload" + zh-CN: "主题/负载中没有 {{变量}} 引用" +"var.preview": + en: "Preview: {tp} | {pl}" + zh-CN: "预览:{tp} | {pl}" +"var.reextract": + en: "Re-extract placeholders" + zh-CN: "重新提取占位符" +"var.section": + en: "Variables ({n})" + zh-CN: "变量({n})" +"var.value": + en: "Value" + zh-CN: "变量值" +"will.enabled_hint": + en: "Enabled" + zh-CN: "已启用" +"will.topic_required": + en: "Will topic cannot be empty" + zh-CN: "遗嘱主题不能为空" +"will.topic_wildcard": + en: "Will topic cannot contain wildcards + or #" + zh-CN: "遗嘱主题不能包含通配符 + 或 #" diff --git a/gpui-app/src/lib.rs b/gpui-app/src/lib.rs index 9c5f03f..0a2727c 100644 --- a/gpui-app/src/lib.rs +++ b/gpui-app/src/lib.rs @@ -1,5 +1,16 @@ //! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。 +// 语言包编译期内嵌(key = 语义标识符,en/zh-CN 并排);miss 回退 en +rust_i18n::i18n!("locales", fallback = "en"); + +/// 把 gpui-kit 组件内置文案(按钮/日历/空态等)并入当前语言包。 +/// 启动时调用一次;extend! 展开引用本 crate 的 _rust_i18n_backend, +/// 必须在 lib 内调用(bin crate 的 crate:: 指向不同根)。 +pub fn init_i18n() { + use gpui_kit::component as gpui_component; + rust_i18n::extend!(gpui_component); +} + pub mod aliyun; pub mod model; pub mod mqtt; diff --git a/gpui-app/src/main.rs b/gpui-app/src/main.rs index 1bbaf12..2fd420e 100644 --- a/gpui-app/src/main.rs +++ b/gpui-app/src/main.rs @@ -59,6 +59,8 @@ fn main() { // 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白 .with_assets(gpui_kit::assets::AllAssets) .run(|cx: &mut App| { + // gpui-kit 组件内置文案(按钮/日历/空态等)并入当前语言包 + mqttx_desktop::init_i18n(); gpui_kit::init(cx); let options = WindowOptions { diff --git a/gpui-app/src/model/message.rs b/gpui-app/src/model/message.rs index a2794cd..bb4e2bc 100644 --- a/gpui-app/src/model/message.rs +++ b/gpui-app/src/model/message.rs @@ -17,8 +17,8 @@ pub enum Direction { impl Direction { pub fn label(self) -> &'static str { match self { - Self::Received => "接收", - Self::Published => "发布", + Self::Received => "dir.received", + Self::Published => "dir.published", } } } diff --git a/gpui-app/src/model/settings.rs b/gpui-app/src/model/settings.rs index 3838864..f55983f 100644 --- a/gpui-app/src/model/settings.rs +++ b/gpui-app/src/model/settings.rs @@ -18,10 +18,10 @@ pub enum ConnectionStatus { impl ConnectionStatus { pub fn label(self) -> &'static str { match self { - Self::Disconnected => "未连接", - Self::Connecting => "连接中", - Self::Connected => "已连接", - Self::Error => "错误", + Self::Disconnected => "status.disconnected", + Self::Connecting => "status.connecting", + Self::Connected => "status.connected", + Self::Error => "status.error", } } } @@ -42,9 +42,9 @@ impl ThemeModePref { pub fn label(self) -> &'static str { match self { - Self::System => "跟随系统", - Self::Light => "浅色", - Self::Dark => "深色", + Self::System => "theme.system", + Self::Light => "theme.light", + Self::Dark => "theme.dark", } } } diff --git a/gpui-app/src/ui/app/actions.rs b/gpui-app/src/ui/app/actions.rs index 80a407b..e4ad5be 100644 --- a/gpui-app/src/ui/app/actions.rs +++ b/gpui-app/src/ui/app/actions.rs @@ -228,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(i18n::t("连接配置")) + .title(i18n::t("form.title")) .child(form.clone()) .footer( h_flex() @@ -242,7 +242,7 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { } else { IconName::PlugZap }) - .label(if testing { i18n::t("测试中…") } else { i18n::t("测试连接") }) + .label(if testing { i18n::t("form.testing") } else { i18n::t("form.test") }) .outline() .loading(testing) .on_click(move |_, window, cx| { @@ -252,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(i18n::t("取消")) + .label(i18n::t("common.cancel")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("form-ok") - .label(i18n::t("保存")) + .label(i18n::t("common.save")) .primary() .on_click(move |_, window, cx| { let built = form_ok.read(cx).build(cx); @@ -288,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(i18n::t("配置已保存,重连后生效")), + Notification::info(i18n::t("form.saved_reconnect")), cx, ); } diff --git a/gpui-app/src/ui/app/io.rs b/gpui-app/src/ui/app/io.rs index f7b10a0..dec28f3 100644 --- a/gpui-app/src/ui/app/io.rs +++ b/gpui-app/src/ui/app/io.rs @@ -80,8 +80,8 @@ impl MqttXApp { Notification::success(i18n::tf( "导出 {n} 条连接到 {path}", &[ - ("n", &count.to_string()), - ("path", &path.display().to_string()), + ("n", &count), + ("path", &path.display()), ], )), cx, @@ -136,8 +136,8 @@ impl MqttXApp { Notification::success(i18n::tf( "导入 {n} 条,跳过 {n2} 条", &[ - ("n", &added.to_string()), - ("n2", &skipped.to_string()), + ("n", &added), + ("n2", &skipped), ], )), cx, diff --git a/gpui-app/src/ui/app/main_area.rs b/gpui-app/src/ui/app/main_area.rs index 3a72760..380aec0 100644 --- a/gpui-app/src/ui/app/main_area.rs +++ b/gpui-app/src/ui/app/main_area.rs @@ -27,18 +27,18 @@ pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context [&'static str; 2] { [i18n::t("MQTT 5.0"), "MQTT 3.1.1"] } +fn protocol_labels() -> [&'static 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 no_group_label() -> &'static str { i18n::t("无分组") } +fn no_group_label() -> &'static str { i18n::t("form.no_group") } pub struct ConnectionForm { engine: Arc, @@ -143,28 +143,28 @@ impl ConnectionForm { chrono::Local::now().timestamp() }); - 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 name = input(&c.name, i18n::t("form.name_ph"), window, cx); + let host = input(&c.host, "broker.example.com", window, cx); + let port = input(&c.port.to_string(), i18n::t(i18n::t("form.port")), window, cx); + let path = input(&c.path, "/mqtt", window, cx); + let client_id = input(&c.client_id, i18n::t("form.client_id"), window, cx); + let username = input(&c.username, i18n::t("form.username_ph"), window, cx); let password = cx.new(|cx| { InputState::new(window, cx) - .placeholder(i18n::t("密码(可选)")) + .placeholder(i18n::t("form.password_ph")) .default_value(&c.password) .masked(true) }); - let keep_alive = input(&c.keep_alive.to_string(), i18n::t("60"), window, cx); + let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx); let conn_timeout = input( &c.connection_timeout_secs.to_string(), - i18n::t("秒(默认 10)"), + i18n::t("form.timeout_ph"), window, cx, ); let max_reconnect = input( &c.max_reconnect_times.to_string(), - i18n::t("0=无限重连"), + i18n::t("form.max_reconnects_ph"), window, cx, ); @@ -174,39 +174,39 @@ impl ConnectionForm { } else { String::new() }, - i18n::t("秒(0=会话随连接结束)"), + i18n::t("form.session_expiry_ph"), 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 receive_max = optional_input(c.receive_maximum.map(u32::from), i18n::t("form.receive_max_ph"), window, cx); + let max_packet = optional_input(c.maximum_packet_size, i18n::t("form.max_packet"), window, cx); + let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), i18n::t("form.topic_alias_max"), 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 ssl_ca = input(&c.ssl.ca_file, i18n::t("ssl.ca_ph"), window, cx); + let ssl_client_cert = input(&c.ssl.client_cert_file, i18n::t("ssl.cert_ph"), window, cx); + let ssl_client_key = input(&c.ssl.client_key_file, i18n::t("ssl.key_ph"), window, cx); - let group_new = input("", i18n::t("填写后优先于左侧所选分组"), window, cx); + let group_new = input("", i18n::t("form.new_group_hint"), 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, i18n::t("遗嘱主题"), window, cx), - input(&w.payload, i18n::t("遗嘱消息"), window, cx), + input(&w.topic, i18n::t("form.will_topic"), window, cx), + input(&w.payload, i18n::t("form.will_payload_ph"), window, cx), true, w.retain, w.qos as usize, - input(w.content_type.as_deref().unwrap_or(""), i18n::t("内容类型(可选)"), window, cx), - input(w.response_topic.as_deref().unwrap_or(""), i18n::t("响应主题(可选)"), window, cx), + input(w.content_type.as_deref().unwrap_or(""), i18n::t("form.content_type_ph"), window, cx), + input(w.response_topic.as_deref().unwrap_or(""), i18n::t("form.response_topic_ph"), window, cx), ), None => ( - input("", i18n::t("遗嘱主题"), window, cx), - input("", i18n::t("遗嘱消息"), window, cx), + input("", i18n::t("form.will_topic"), window, cx), + input("", i18n::t("form.will_payload_ph"), window, cx), false, false, 0, - input("", i18n::t("内容类型(可选)"), window, cx), - input("", i18n::t("响应主题(可选)"), window, cx), + input("", i18n::t("form.content_type_ph"), window, cx), + input("", i18n::t("form.response_topic_ph"), window, cx), ), }; @@ -402,15 +402,15 @@ impl ConnectionForm { fn parse_u16(s: &str, label: &'static str) -> Result { s.trim() .parse::() - .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("需要是 0~65535 的整数"))) + .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("form.err_range"))) } /// 端口:1~65535。0 不是可连接端口,放行只会在 CONNECT 阶段报出难以理解的错误。 fn parse_port(s: &str) -> Result { match s.trim().parse::() { - Ok(0) => Err(i18n::t("端口需要是 1~65535 的整数").to_string()), + Ok(0) => Err(i18n::t("form.port_invalid").to_string()), Ok(p) => Ok(p), - Err(_) => Err(i18n::t("端口需要是 1~65535 的整数").to_string()), + Err(_) => Err(i18n::t("form.port_invalid").to_string()), } } @@ -419,7 +419,7 @@ impl ConnectionForm { /// 且该值会被持久化,表现为"每次连接都失败"。 fn parse_opt_positive_u32(s: &str, label: &'static str) -> Result, String> { match Self::parse_opt_u32(s, label)? { - Some(0) => Err(format!("{} {}", i18n::t(label), i18n::t("必须大于 0(0 是 MQTT 5 协议错误)"))), + Some(0) => Err(format!("{} {}", i18n::t(label), i18n::t("form.err_positive"))), other => Ok(other), } } @@ -431,7 +431,7 @@ impl ConnectionForm { } s.parse::() .map(Some) - .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("需要是非负整数"))) + .map_err(|_| format!("{} {}", i18n::t(label), i18n::t("form.err_uint"))) } pub fn build(&self, cx: &App) -> Result { @@ -451,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", i18n::t("连接名称不能为空"))); + return Err(self.fail("name", i18n::t("form.name_required"))); } - name = i18n::t("测试连接").into(); + name = i18n::t("form.test").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", i18n::t("主机地址不能为空"))); + return Err(self.fail("host", i18n::t("form.host_required"))); } 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", i18n::t("Client ID 不能为空"))); + return Err(self.fail("client_id", i18n::t("form.client_id_required"))); } 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), i18n::t("Keep Alive")))?; + self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), i18n::t("form.keep_alive_short")))?; // 空串视为 0(会话随连接结束);非空必须是合法非负整数,不再静默吞掉 -1/abc let session_expiry = { let raw = self.val(&self.session_expiry, cx); @@ -483,23 +483,23 @@ impl ConnectionForm { t.parse::().map_err(|_| { self.fail( "session_expiry", - i18n::t("会话过期间隔需要是非负整数(0=会话随连接结束)"), + i18n::t("form.session_expiry_invalid"), ) })? } }; let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, 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())) + Ok(Self::parse_opt_positive_u32(&self.val(&self.receive_max, cx), i18n::t("form.receive_max_short"))? + .map(|v| u16::try_from(v).map_err(|_| i18n::t("form.receive_max_invalid").to_string())) .transpose()) })())??; let maximum_packet_size = self.fail_as( "max_packet", - Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), i18n::t("最大报文长度")), + Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), i18n::t("form.max_packet")), )?; let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, 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())) + Ok(Self::parse_opt_positive_u32(&self.val(&self.topic_alias, cx), i18n::t("form.topic_alias"))? + .map(|v| u16::try_from(v).map_err(|_| i18n::t("form.topic_alias_invalid").to_string())) .transpose()) })())??; @@ -512,7 +512,7 @@ impl ConnectionForm { .parse::() .ok() .filter(|v| *v >= 1) - .ok_or_else(|| self.fail("conn_timeout", i18n::t("连接超时需要是 1~65535 的整数(秒)")))? + .ok_or_else(|| self.fail("conn_timeout", i18n::t("form.conn_timeout_invalid")))? }; let max_reconnect_raw = self.val(&self.max_reconnect, cx); let max_reconnect_times = if max_reconnect_raw.trim().is_empty() { @@ -521,7 +521,7 @@ impl ConnectionForm { max_reconnect_raw .trim() .parse::() - .map_err(|_| self.fail("max_reconnect", i18n::t("最大重连次数需要是非负整数(0=无限)")))? + .map_err(|_| self.fail("max_reconnect", i18n::t("form.max_reconnects_invalid")))? }; // 分组:新分组名非空时优先,否则取下拉所选(首项=无分组) @@ -559,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", i18n::t("WS/WSS 传输下 WS 路径不能为空"))); + return Err(self.fail("path", i18n::t("form.ws_path_required"))); } let ssl = SslConfig { @@ -572,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", i18n::t("客户端证书与客户端密钥必须同时填写"))); + return Err(self.fail("ssl_client_cert", i18n::t("form.cert_pair_required"))); } // 证书文件不存在时握手才报错太晚,保存前给出字段级提示 if transport.is_tls() { @@ -582,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, i18n::tf("{label}路径不存在:{p}", &[("label", &i18n::t(label)), ("p", &p)]))); + return Err(self.fail(key, i18n::tf("form.path_missing", &[("label", &i18n::t(label)), ("p", &p)]))); } } } @@ -591,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", i18n::t("遗嘱主题不能为空"))); + return Err(self.fail("will_topic", i18n::t("will.topic_required"))); } // 通配符只用于订阅过滤器;出现在 PUBLISH 主题上会被 broker 直接拒绝 if topic.contains('+') || topic.contains('#') { return Err(self.fail( "will_topic", - i18n::t("遗嘱主题不能包含通配符 + 或 #"), + i18n::t("will.topic_wildcard"), )); } let opt_str = |v: String| { @@ -703,7 +703,7 @@ impl ConnectionForm { ) .child( Button::new(id) - .label(i18n::t("浏览…")) + .label(i18n::t("common.browse")) .outline() .small() .on_click(cx.listener(move |this, _, window, cx| { @@ -723,7 +723,7 @@ impl ConnectionForm { let (tx, rx) = smol::channel::bounded::>(1); std::thread::spawn(move || { let picked = rfd::FileDialog::new() - .set_title(i18n::t("选择证书 / 密钥文件")) + .set_title(i18n::t("ssl.pick_file")) .add_filter("证书 / 密钥 (PEM)", &["pem", "crt", "cer", "key", "der"]) .pick_file(); let _ = tx.send_blocking(picked); @@ -878,40 +878,40 @@ impl Render for ConnectionForm { .iter() .filter(|on| **on) .count(); - let ssl_hint = (ssl_count > 0).then(|| SharedString::from(i18n::tf("已配置 {n} 项", &[("n", &ssl_count.to_string())]))); + let ssl_hint = (ssl_count > 0).then(|| SharedString::from(i18n::tf("ssl.n_configured", &[("n", &ssl_count)]))); let mqtt5_hint = - (mqtt5_count > 0).then(|| SharedString::from(i18n::tf("已配置 {n} 项", &[("n", &mqtt5_count.to_string())]))); + (mqtt5_count > 0).then(|| SharedString::from(i18n::tf("ssl.n_configured", &[("n", &mqtt5_count)]))); v_flex() .gap_3() .max_h(px(560.)) .overflow_y_scrollbar() .pr_1() // ── 基本信息(始终展开) ── - .child(section(i18n::t("基本信息"), border, card_bg, + .child(section(i18n::t("form.basics"), 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(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(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.host"), Input::new(&self.host), true, err("host"), danger))) + .child(div().w(px(120.)).child(field_ex(i18n::t(i18n::t("form.port")), 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(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(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.name"), Input::new(&self.name), true, err("name"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.protocol"), Select::new(&self.protocol)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.transport"), Select::new(&self.transport)))) ) .child(h_flex().gap_2().w_full() - .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(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.group"), Select::new(&self.group_select)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.new_group"), 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(i18n::t("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("form.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(i18n::t("重新生成 Client ID")) - .accessibility_label(i18n::t("重新生成 Client ID")) + .tooltip(i18n::t("form.regen_client_id")) + .accessibility_label(i18n::t("form.regen_client_id")) .on_click(cx.listener(|this, _, window, cx| { let cid = crate::model::generate_client_id(); this.client_id @@ -921,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(i18n::t("WS 路径"), Input::new(&self.path), true, err("path"), danger)) + div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.ws_path"), Input::new(&self.path), true, err("path"), danger)) )) ) )) // ── 认证与心跳 ── .child(self.collapsible_section( - "sec-auth", i18n::t("认证与心跳"), None, false, self.show_auth, + "sec-auth", i18n::t("form.auth_section"), 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(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(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.username"), Input::new(&self.username)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.password"), Input::new(&self.password).mask_toggle()))) + .child(div().w(px(110.)).child(field_ex(i18n::t("form.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(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(div().w(px(150.)).child(field_ex(i18n::t("form.conn_timeout"), Input::new(&self.conn_timeout), false, err("conn_timeout"), danger))) + .child(div().w(px(170.)).child(field_ex(i18n::t("form.max_reconnects"), Input::new(&self.max_reconnect), false, err("max_reconnect"), danger))) ) - .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)), + .child(self.switch_row("clean_start", i18n::t("form.clean_start"), self.clean_start, cx)) + .child(self.switch_row("auto_reconnect", i18n::t("form.auto_reconnect"), self.auto_reconnect, cx)) + .child(self.switch_row("auto_resubscribe", i18n::t("form.auto_resub"), self.auto_resubscribe, cx)) + .child(self.switch_row("auto_connect", i18n::t("form.auto_connect"), self.auto_connect, cx)), cx, )) // ── SSL/TLS(仅 TLS/WSS 显示) ── .when(is_tls, |w| w.child(self.collapsible_section( - "sec-ssl", i18n::t("SSL/TLS"), ssl_hint, true, self.show_ssl, + "sec-ssl", i18n::t("ssl.section"), 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", i18n::t("CA 证书 (PEM)"), self.ssl_ca.clone(), + "ssl-ca-browse", i18n::t("ssl.ca"), self.ssl_ca.clone(), err("ssl_ca"), danger, cx, )) .child(self.cert_path_row( - "ssl-cert-browse", i18n::t("客户端证书 (PEM)"), self.ssl_client_cert.clone(), + "ssl-cert-browse", i18n::t("ssl.cert"), self.ssl_client_cert.clone(), err("ssl_client_cert"), danger, cx, )) .child(self.cert_path_row( - "ssl-key-browse", i18n::t("客户端密钥 (PEM)"), self.ssl_client_key.clone(), + "ssl-key-browse", i18n::t("ssl.key"), self.ssl_client_key.clone(), err("ssl_client_key"), danger, cx, )) - .child(self.switch_row("ssl_ignore_ca", i18n::t("忽略 CA 校验(不验证服务器证书)"), self.ssl_ignore_ca, cx)), + .child(self.switch_row("ssl_ignore_ca", i18n::t("ssl.skip_verify"), self.ssl_ignore_ca, cx)), cx, ))) // ── MQTT 5 高级属性(仅 v5 协议显示) ── @@ -970,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(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))), + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.session_expiry"), Input::new(&self.session_expiry), false, err("session_expiry"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.receive_max"), Input::new(&self.receive_max), false, err("receive_max"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.max_packet"), Input::new(&self.max_packet), false, err("max_packet"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.topic_alias_max"), Input::new(&self.topic_alias), false, err("topic_alias"), danger))), cx, ))) // ── 遗嘱 ── .child(self.collapsible_section( - "sec-will", i18n::t("遗嘱消息 (Last Will)"), - self.will_enabled.then(|| i18n::t("已启用").into()), true, self.show_will, + "sec-will", i18n::t("form.will_section"), + self.will_enabled.then(|| i18n::t("will.enabled_hint").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", i18n::t("启用遗嘱消息"), self.will_enabled, cx)) + .child(self.switch_row("will_enabled", i18n::t("form.will_enable"), 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(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(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.will_topic"), Input::new(&self.will_topic), true, err("will_topic"), danger))) + .child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos)))) ) - .child(field(i18n::t("遗嘱内容"), Input::new(&self.will_payload))) + .child(field(i18n::t("form.will_payload"), 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(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(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.will_content_type"), Input::new(&self.will_content_type)))) + .child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.will_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 b6401f5..c27066d 100644 --- a/gpui-app/src/ui/connection_view/messages.rs +++ b/gpui-app/src/ui/connection_view/messages.rs @@ -92,11 +92,11 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement IconName::PlugZap }) .label(if connected { - i18n::t("断开") + i18n::t("sidebar.disconnect") } else if connecting { - i18n::t("连接中") + i18n::t("status.connecting") } else { - i18n::t("连接") + i18n::t("sidebar.connections") }) .when(connected, |b| b.danger().ghost()) .when(!connected, |b| b.primary().ghost()) @@ -151,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 { i18n::t("接收") } else { i18n::t("发布") }; + let direction_label = if received { i18n::t("dir.received") } else { i18n::t("dir.published") }; let header = h_flex() .gap_2() @@ -223,7 +223,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement .rounded_sm() .bg(cx.theme().warning.alpha(0.15)) .text_color(cx.theme().warning) - .child(format!("已截断 {}KB", MAX_PAYLOAD_RETAIN / 1024)), + .child(i18n::tf("msg.truncated", &[("n", &(MAX_PAYLOAD_RETAIN / 1024))])), ) }) .child( @@ -291,43 +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(i18n::t("主题")) + .label(i18n::t("msg.topic")) .ghost() .xsmall() - .tooltip(i18n::t("复制主题")) - .accessibility_label(i18n::t("复制主题")) + .tooltip(i18n::t("msg.copy_topic")) + .accessibility_label(i18n::t("msg.copy_topic")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_topic.to_string())); - window.push_notification(Notification::success(i18n::t("已复制主题")), cx); + window.push_notification(Notification::success(i18n::t("msg.topic_copied")), cx); }), ) .child( Button::new(SharedString::from(format!("msg-copy-payload-{}", seq))) .icon(IconName::Copy) - .label(i18n::t("负载")) + .label(i18n::t("msg.payload")) .ghost() .xsmall() - .tooltip(i18n::t("复制负载原文")) - .accessibility_label(i18n::t("复制负载原文")) + .tooltip(i18n::t("msg.copy_payload")) + .accessibility_label(i18n::t("msg.copy_payload")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_payload.to_string())); - window.push_notification(Notification::success(i18n::t("已复制负载")), cx); + window.push_notification(Notification::success(i18n::t("msg.payload_copied")), cx); }), ) .child( Button::new(SharedString::from(format!("msg-copy-detail-{}", seq))) .icon(IconName::Copy) - .label(i18n::t("详情")) + .label(i18n::t("msg.details")) .ghost() .xsmall() - .tooltip(i18n::t("复制完整详情(含 v5 属性)")) - .accessibility_label(i18n::t("复制完整详情(含 v5 属性)")) + .tooltip(i18n::t("msg.copy_details")) + .accessibility_label(i18n::t("msg.copy_details")) .on_click(move |_, window, cx| { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(copy_detail.clone())); - window.push_notification(Notification::success(i18n::t("已复制详情")), cx); + window.push_notification(Notification::success(i18n::t("msg.details_copied")), cx); }), ); details = details.child(detail_toolbar).child( @@ -350,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(i18n::t("用户属性"))) + .child(div().text_xs().font_semibold().child(i18n::t("preset.user_props"))) .child( div() .font_family(mono.clone()) @@ -363,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("消息过期"), + i18n::t("msg.expiry"), record .message_expiry_interval - .map(|v| i18n::tf("{n} 秒", &[("n", &v.to_string())])), + .map(|v| i18n::tf("msg.seconds", &[("n", &v)])), ), ( "订阅标识符", @@ -513,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("没有匹配的消息") + i18n::t("msg.no_match") } else { - i18n::t("暂无消息,订阅主题后消息会显示在这里") + i18n::t("msg.empty") }), ), ); @@ -585,8 +585,8 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .icon(IconName::Close) .ghost() .xsmall() - .tooltip(i18n::t("清除订阅过滤")) - .accessibility_label(i18n::t("清除订阅过滤")) + .tooltip(i18n::t("msg.clear_sub_filter")) + .accessibility_label(i18n::t("msg.clear_sub_filter")) .on_click(cx.listener(|this, _, _, cx| { this.sub_filter = None; cx.notify(); @@ -635,7 +635,7 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .px_1p5() .text_color(cx.theme().muted_foreground) // 计数显示过滤命中总数,不受渲染截断影响 - .child(i18n::tf("{n} 条", &[("n", &matched_total.to_string())])), + .child(i18n::tf("msg.count", &[("n", &matched_total)])), ) .when(matched_total > records.len(), |h| { h.child( @@ -645,7 +645,7 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .text_color(cx.theme().muted_foreground) .child(i18n::tf( "仅显示最新 {n} 条", - &[("n", &MAX_RENDERED_MESSAGES.to_string())], + &[("n", &MAX_RENDERED_MESSAGES)], )), ) }) @@ -662,19 +662,19 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement } else { IconName::Pause }) - .label(if paused { i18n::t("恢复") } else { i18n::t("暂停") }) + .label(if paused { i18n::t("common.resume") } else { i18n::t("common.pause") }) .ghost() .small() .when(paused, |b| b.selected(true)) .tooltip(if paused { - i18n::t("恢复实时滚动") + i18n::t("msg.resume_tooltip") } else { - i18n::t("暂停消息流滚动(消息仍在后台接收)") + i18n::t("msg.pause_tooltip") }) .accessibility_label(if paused { - i18n::t("恢复") + i18n::t("common.resume") } else { - i18n::t("暂停") + i18n::t("common.pause") }) .on_click(cx.listener(|this, _, _, cx| { this.paused = !this.paused; @@ -689,12 +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(i18n::t("清空")) + .label(i18n::t("common.clear")) .ghost() .small() .disabled(empty) - .tooltip(i18n::t("清空当前连接的消息")) - .accessibility_label(i18n::t("清空当前连接的消息")) + .tooltip(i18n::t("msg.clear_tooltip")) + .accessibility_label(i18n::t("msg.clear_tooltip")) .on_click(move |_, window, cx| { // 清空不可撤销,先弹二次确认 let weak = weak.clone(); @@ -703,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(i18n::t("清空消息")) - .description(i18n::t("确定清空当前连接的全部消息吗?此操作不可撤销。")) + .title(i18n::t("msg.clear_title")) + .description(i18n::t("msg.clear_confirm")) .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text(i18n::t("取消")) - .ok_text(i18n::t("清空")) + .cancel_text(i18n::t("common.cancel")) + .ok_text(i18n::t("common.clear")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, _, cx| { @@ -769,7 +769,7 @@ pub(super) fn render_logs(&self, cx: &mut Context) -> impl IntoElement { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(i18n::t("暂无日志")), + .child(i18n::t("msg.no_logs")), ), ); } @@ -830,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(i18n::t("连接日志")), + .child(i18n::t("msg.log_title")), ), ) .child(body) diff --git a/gpui-app/src/ui/connection_view/mod.rs b/gpui-app/src/ui/connection_view/mod.rs index 85f6058..7139226 100644 --- a/gpui-app/src/ui/connection_view/mod.rs +++ b/gpui-app/src/ui/connection_view/mod.rs @@ -41,23 +41,23 @@ const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; /// Retain Handling 0/1/2 的下拉文案 fn retain_handling_labels() -> [&'static str; 3] { - [i18n::t("0 每次发送"), i18n::t("1 仅新订阅"), i18n::t("2 不发送")] + [i18n::t("sub.rh0"), i18n::t("sub.rh1"), i18n::t("sub.rh2")] } const MAX_RENDERED_MESSAGES: usize = 300; /// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致 const MAX_RENDERED_LOGS: usize = 3000; /// 订阅色板预设:色相(度)+ 名称 const PRESET_HUES: [(f32, &str); 10] = [ - (0., "红"), - (30., "橙"), - (60., "黄"), - (120., "绿"), - (160., "青"), - (200., "蓝"), - (240., "靛"), - (270., "紫"), - (300., "品红"), - (330., "玫红"), + (0., "color.red"), + (30., "color.orange"), + (60., "color.yellow"), + (120., "color.green"), + (160., "color.cyan"), + (200., "color.blue"), + (240., "color.indigo"), + (270., "color.purple"), + (300., "color.magenta"), + (330., "color.rose"), ]; #[derive(Clone, Copy, PartialEq, Eq)] @@ -208,29 +208,29 @@ impl ConnectionView { ) -> Self { let sub_topic = cx.new(|cx| { - InputState::new(window, cx).placeholder(i18n::t("订阅主题,支持 # +,多个用逗号/空格分隔")) + InputState::new(window, cx).placeholder(i18n::t("sub.topic_placeholder")) }); let sub_qos = make_select(&QOS, 0, window, cx); - 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_alias = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("sub.alias"))); + let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("sub.identifier"))); 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 pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.topic_placeholder"))); let payload = cx.new(|cx| { TextareaState::new(window, cx) - .placeholder(i18n::t("输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}")) + .placeholder(i18n::t("pub.payload_placeholder")) }); 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(i18n::t("Content-Type(可选)"))); - let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("秒,如 60"))); + let content_type = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.content_type_ph"))); + let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.seconds_hint"))); let response_topic = - cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("Response Topic(可选)"))); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.response_topic_ph"))); let correlation_data = - cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("Correlation Data(可选)"))); - let filter = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("过滤主题或内容…"))); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.correlation_ph"))); + let filter = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("msg.filter_placeholder"))); let user_props = cx.new(|cx| KvEditor::new("pub", &[], window, cx)); let preset_name = - cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("预设名称,如 温度上报"))); + cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.preset_name_hint"))); Self { conn_id, @@ -332,7 +332,7 @@ impl Render for ConnectionView { .selected_index(if panel == Panel::Messages { 0 } else { 1 }) .child( Tab::new() - .label(i18n::t("消息流")) + .label(i18n::t("connview.messages")) .selected(panel == Panel::Messages) .on_click(cx.listener(|this, _, _, cx| { this.panel = Panel::Messages; @@ -341,7 +341,7 @@ impl Render for ConnectionView { ) .child( Tab::new() - .label(i18n::t("日志")) + .label(i18n::t("connview.logs")) .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 ba3bad1..9d4ac9d 100644 --- a/gpui-app/src/ui/connection_view/publish.rs +++ b/gpui-app/src/ui/connection_view/publish.rs @@ -36,8 +36,8 @@ impl ConnectionView { .icon(IconName::PanelBottomOpen) .ghost() .small() - .tooltip(i18n::t("展开发布面板")) - .accessibility_label(i18n::t("展开发布面板")) + .tooltip(i18n::t("pub.expand")) + .accessibility_label(i18n::t("pub.expand")) .on_click(cx.listener(|this, _, _, cx| { this.publish_collapsed = false; cx.notify(); @@ -47,7 +47,7 @@ impl ConnectionView { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(i18n::t("发布面板已收起")), + .child(i18n::t("pub.collapsed_hint")), ) } @@ -56,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(i18n::t("未连接:消息未发送")), cx); + window.push_notification(Notification::warning(i18n::t("pub.offline_not_sent")), cx); return; } let mut params = self.collect_publish_params(cx); if params.topic.trim().is_empty() { - window.push_notification(Notification::warning(i18n::t("发布主题不能为空")), cx); + window.push_notification(Notification::warning(i18n::t("pub.topic_required")), 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(i18n::t("消息过期须为非负整数(秒)")), cx); + window.push_notification(Notification::warning(i18n::t("pub.expiry_invalid")), cx); return; } @@ -76,7 +76,7 @@ impl ConnectionView { params.payload = render_template(¶ms.payload, &vars); // {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次 if params.topic.trim().is_empty() { - window.push_notification(Notification::warning(i18n::t("发布主题不能为空")), cx); + window.push_notification(Notification::warning(i18n::t("pub.topic_required")), cx); return; } // {{变量}} 同样作用于用户属性的 key/value @@ -93,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(i18n::tf("JSON 格式无效: {e}", &[("e", &e.to_string())])), + Notification::warning(i18n::tf("pub.json_invalid", &[("e", &e)])), cx, ); return; @@ -129,7 +129,7 @@ impl ConnectionView { self.engine.publish(self.conn_id.clone(), params); if hidden { window.push_notification( - Notification::success(i18n::t("已发送(被当前过滤条件隐藏,消息流中不显示)")), + Notification::success(i18n::t("pub.sent_hidden")), cx, ); } @@ -281,12 +281,12 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .tooltip(if count > 0 { i18n::tf( "变量注入({n} 个占位符)", - &[("n", &count.to_string())], + &[("n", &count)], ) } else { - i18n::t("变量注入").to_string() + i18n::t("pub.vars").to_string() }) - .accessibility_label(i18n::t("变量注入").to_string()) + .accessibility_label(i18n::t("pub.vars").to_string()) .on_click(cx.listener(|this, _, window, cx| { if this.show_vars { this.show_vars = false; @@ -315,8 +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(i18n::t("MQTT 5 属性")) - .accessibility_label(i18n::t("MQTT 5 属性")) + .tooltip(i18n::t("pub.mqtt5_props")) + .accessibility_label(i18n::t("pub.mqtt5_props")) .on_click(cx.listener(|this, _, _, cx| { this.show_props = !this.show_props; cx.notify(); @@ -326,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(i18n::t("发送")) + .label(i18n::t("common.send")) .primary() .small() // 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底 .disabled(!connected) - .when(!connected, |b| b.tooltip(i18n::t("未连接,无法发送"))) + .when(!connected, |b| b.tooltip(i18n::t("pub.send_disabled"))) .on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))), ) // 发布栏抽屉把手:收起整个底部面板 @@ -340,8 +340,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .icon(IconName::PanelBottom) .ghost() .small() - .tooltip(i18n::t("收起发布面板")) - .accessibility_label(i18n::t("收起发布面板")) + .tooltip(i18n::t("pub.collapse")) + .accessibility_label(i18n::t("pub.collapse")) .on_click(cx.listener(|this, _, _, cx| { this.publish_collapsed = true; cx.notify(); @@ -420,8 +420,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .icon(IconName::Bookmark) .ghost() .small() - .tooltip(i18n::t("消息预设")) - .accessibility_label(i18n::t("消息预设")) + .tooltip(i18n::t("pub.preset_menu")) + .accessibility_label(i18n::t("pub.preset_menu")) .dropdown_menu(move |mut menu, window, cx| { // 应用预设 for p in &presets { @@ -445,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(i18n::t("保存当前为预设…")).on_click( + menu = menu.item(PopupMenuItem::new(i18n::t("pub.save_preset")).on_click( move |_ev, window, cx| { if let Some(view) = weak_save.upgrade() { view.update(cx, |view, cx| { @@ -456,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(i18n::t("管理预设…")).on_click( + menu = menu.item(PopupMenuItem::new(i18n::t("pub.manage_presets")).on_click( move |_ev, window, cx| { if let Some(app) = manage_app.upgrade() { crate::ui::dialogs::presets_dialog::open(app, window, cx); @@ -477,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 = i18n::tf("删除「{name}」", &[("name", &p.name)]); + let label = i18n::tf("pub.delete_item", &[("name", &p.name)]); submenu = submenu.item( PopupMenuItem::new(SharedString::from(label)).on_click( move |_ev, window, cx| { @@ -489,7 +489,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into let weak = weak.clone(); let pid = pid.clone(); alert - .title(i18n::t("删除预设")) + .title(i18n::t("pub.delete_title")) .description(i18n::tf( "确定删除预设「{name}」吗?此操作不可撤销。", &[("name", &pname)], @@ -497,8 +497,8 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text(i18n::t("取消")) - .ok_text(i18n::t("删除")) + .cancel_text(i18n::t("common.cancel")) + .ok_text(i18n::t("common.delete")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, _, cx| { @@ -548,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(i18n::t("保存消息预设")) + .title(i18n::t("pub.save_title")) .child(v_flex().gap_3().child( - crate::ui::widgets::field(i18n::t("预设名称"), Input::new(&name_for_ok)), + crate::ui::widgets::field(i18n::t("aliyun.preset_name"), Input::new(&name_for_ok)), )) .footer( h_flex() @@ -559,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(i18n::t("取消")) + .label(i18n::t("common.cancel")) .outline() .on_click(move |_, window, cx| window.close_dialog(cx)), ) .child( Button::new("preset-save-ok") - .label(i18n::t("保存")) + .label(i18n::t("common.save")) .primary() .on_click({ let name_input = name_for_ok.clone(); @@ -578,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(i18n::t("请填写预设名称")), + Notification::warning(i18n::t("pub.preset_name_required")), cx, ); return; @@ -604,7 +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(i18n::t("消息过期须为非负整数(秒)")), + Notification::warning(i18n::t("pub.expiry_invalid")), cx, ); return; @@ -621,9 +621,9 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into cx.notify(); }); let msg = if existed { - i18n::tf("已覆盖预设「{name}」", &[("name", &name)]) + i18n::tf("pub.preset_overwritten", &[("name", &name)]) } else { - i18n::t("预设已保存").to_string() + i18n::t("pub.preset_saved").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 f67812e..4750627 100644 --- a/gpui-app/src/ui/connection_view/subscribe.rs +++ b/gpui-app/src/ui/connection_view/subscribe.rs @@ -35,8 +35,8 @@ impl ConnectionView { .icon(IconName::PanelLeftOpen) .ghost() .small() - .tooltip(i18n::t("展开订阅面板")) - .accessibility_label(i18n::t("展开订阅面板")) + .tooltip(i18n::t("sub.expand")) + .accessibility_label(i18n::t("sub.expand")) .on_click(cx.listener(|this, _, _, cx| { this.subs_collapsed = false; cx.notify(); @@ -169,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(i18n::t("订阅主题不能为空")), cx); + window.push_notification(Notification::warning(i18n::t("sub.topic_required")), cx); return; } let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; @@ -189,7 +189,7 @@ impl ConnectionView { Ok(v) if v > 0 => sub_identifier = Some(v), _ => { window.push_notification( - Notification::warning(i18n::t("订阅标识符须为正整数")), + Notification::warning(i18n::t("sub.identifier_invalid")), cx, ); return; @@ -303,9 +303,9 @@ impl ConnectionView { .map(|c| c.auto_resubscribe) .unwrap_or(true) { - i18n::t("未连接:订阅已保存,连接后自动恢复") + i18n::t("sub.saved_offline_resub") } else { - i18n::t("未连接:订阅已保存") + i18n::t("sub.saved_offline") }; window.push_notification(Notification::warning(hint), cx); } @@ -364,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(i18n::tf("正在编辑:{topic}", &[("topic", &topic)])), + .child(i18n::tf("sub.editing", &[("topic", &topic)])), ) }) .child( @@ -403,12 +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(i18n::t("高级")) + .label(i18n::t("common.advanced")) .ghost() .small() .when(show_advanced, |b| b.selected(true)) - .tooltip(i18n::t("MQTT 5 订阅选项")) - .accessibility_label(i18n::t("MQTT 5 订阅选项")) + .tooltip(i18n::t("sub.mqtt5_options")) + .accessibility_label(i18n::t("sub.mqtt5_options")) .on_click(cx.listener(|this, _, _, cx| { this.sub_show_advanced = !this.sub_show_advanced; cx.notify(); @@ -418,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 { i18n::t("更新") } else { i18n::t("订阅") }) + .label(if editing { i18n::t("common.update") } else { i18n::t("connview.subscriptions") }) // 编辑用 warning 色与新增的 primary 区分 .when(editing, |b| b.warning()) .when(!editing, |b| b.primary()) @@ -431,8 +431,8 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In .icon(IconName::Close) .ghost() .small() - .tooltip(i18n::t("取消编辑")) - .accessibility_label(i18n::t("取消编辑")) + .tooltip(i18n::t("sub.cancel_edit")) + .accessibility_label(i18n::t("sub.cancel_edit")) .on_click(cx.listener(|this, _, window, cx| { this.editing = None; // 清空回填到输入框的主题/别名/高级字段,回到新增态 @@ -470,7 +470,7 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In cx.notify(); }), )) - .child(div().text_xs().child(i18n::t("No Local"))), + .child(div().text_xs().child(i18n::t("sub.no_local"))), ) .child( h_flex() @@ -483,11 +483,11 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In cx.notify(); }), )) - .child(div().text_xs().child(i18n::t("Retain As Published"))), + .child(div().text_xs().child(i18n::t("sub.rap"))), ) .child( div().w(px(150.)).child(field( - i18n::t("Retain Handling"), + i18n::t("sub.retain_handling"), Select::new(&sub_retain_handling).small(), )), ), @@ -527,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(i18n::t("订阅主题后,消息会显示在右侧")), + .child(i18n::t("sub.empty_hint")), ), ); } @@ -548,8 +548,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl Button::new(trigger_id) .ghost() .xsmall() - .tooltip(i18n::t("订阅颜色")) - .accessibility_label(i18n::t("订阅颜色")) + .tooltip(i18n::t("sub.color")) + .accessibility_label(i18n::t("sub.color")) .child( div() .size(px(11.)) @@ -562,8 +562,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl Button::new(trigger_id) .ghost() .xsmall() - .tooltip(i18n::t("订阅颜色")) - .accessibility_label(i18n::t("订阅颜色")) + .tooltip(i18n::t("sub.color")) + .accessibility_label(i18n::t("sub.color")) .child( div() .size(px(11.)) @@ -583,7 +583,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl let sid = sid.clone(); let swatch = hue_color(hue); // 菜单只显示颜色名,角度数值对用户没有意义 - let label = SharedString::from(name); + let label = SharedString::from(crate::ui::i18n::t(name)); menu = menu.item( PopupMenuItem::element(move |_, _| { h_flex() @@ -611,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(i18n::t("随机颜色")).on_click(move |_, _, cx| { + menu = menu.item(PopupMenuItem::new(i18n::t("sub.color_random")).on_click(move |_, _, cx| { let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32; weak_rand .update(cx, |view, cx| { @@ -621,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(i18n::t("清除颜色")).on_click(move |_, _, cx| { + menu = menu.item(PopupMenuItem::new(i18n::t("sub.color_clear")).on_click(move |_, _, cx| { weak_clear .update(cx, |view, cx| { view.set_subscription_color(&sid_clear, None, cx); @@ -637,8 +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 { i18n::t("停用订阅") } else { i18n::t("启用订阅") }) - .accessibility_label(if enabled { i18n::t("停用订阅") } else { i18n::t("启用订阅") }) + .tooltip(if enabled { i18n::t("sub.disable") } else { i18n::t("sub.enable") }) + .accessibility_label(if enabled { i18n::t("sub.disable") } else { i18n::t("sub.enable") }) .on_click(cx.listener(move |this, _, _, cx| { this.toggle_subscription_enabled(&eye_sid, cx); })); @@ -648,8 +648,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .icon(IconName::Pencil) .ghost() .xsmall() - .tooltip(i18n::t("编辑订阅")) - .accessibility_label(i18n::t("编辑订阅")) + .tooltip(i18n::t("sub.edit")) + .accessibility_label(i18n::t("sub.edit")) .on_click(cx.listener(move |this, _, window, cx| { let sub = this .with_app(cx, |app| { @@ -670,8 +670,8 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .icon(IconName::Close) .ghost() .xsmall() - .tooltip(i18n::t("取消订阅")) - .accessibility_label(i18n::t("取消订阅")) + .tooltip(i18n::t("sub.unsubscribe")) + .accessibility_label(i18n::t("sub.unsubscribe")) .on_click(cx.listener(move |this, _, _, cx| { // 若删除的是当前过滤主题,一并清掉消息过滤 if this.sub_filter.as_deref() == Some(del_topic.as_str()) { @@ -778,14 +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(i18n::t("订阅"))) + .child(div().text_sm().font_semibold().child(i18n::t("connview.subscriptions"))) .child( Button::new("subs-collapse") .icon(IconName::PanelLeftClose) .ghost() .xsmall() - .tooltip(i18n::t("收起订阅面板")) - .accessibility_label(i18n::t("收起订阅面板")) + .tooltip(i18n::t("sub.collapse")) + .accessibility_label(i18n::t("sub.collapse")) .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 072266d..b18c372 100644 --- a/gpui-app/src/ui/connection_view/util.rs +++ b/gpui-app/src/ui/connection_view/util.rs @@ -125,7 +125,7 @@ pub(super) fn format_payload_detail(payload: &str, raw: Option<&[u8]>, format: D pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String { let mut out = String::new(); out.push_str(&format!("Topic: {}\n", record.topic)); - out.push_str(&format!("Direction: {}\n", record.direction.label())); + out.push_str(&format!("Direction: {}\n", crate::ui::i18n::t(record.direction.label()))); out.push_str(&format!("QoS: {}\n", record.qos)); out.push_str(&format!("Retain: {}\n", record.retain)); out.push_str(&format!( diff --git a/gpui-app/src/ui/connection_view/vars.rs b/gpui-app/src/ui/connection_view/vars.rs index 2d7966d..21dd2be 100644 --- a/gpui-app/src/ui/connection_view/vars.rs +++ b/gpui-app/src/ui/connection_view/vars.rs @@ -21,7 +21,7 @@ pub(super) fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoEleme .font_semibold() .child(i18n::tf( "变量({n})", - &[("n", &self.var_rows.len().to_string())], + &[("n", &self.var_rows.len())], )), ) .child(div().flex_1()) @@ -129,8 +129,8 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme .icon(IconName::RefreshCw) .ghost() .xsmall() - .tooltip(i18n::t("重新提取占位符")) - .accessibility_label(i18n::t("重新提取占位符")) + .tooltip(i18n::t("var.reextract")) + .accessibility_label(i18n::t("var.reextract")) .on_click(cx.listener(|this, _, window, cx| { this.sync_var_rows(window, cx); })), @@ -140,8 +140,8 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme .icon(IconName::ChevronUp) .ghost() .xsmall() - .tooltip(i18n::t("收起")) - .accessibility_label(i18n::t("收起")) + .tooltip(i18n::t("common.collapse")) + .accessibility_label(i18n::t("common.collapse")) .on_click(cx.listener(|this, _, _, cx| { this.show_vars = false; cx.notify(); @@ -153,7 +153,7 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoEleme div() .text_xs() .text_color(muted) - .child(i18n::t("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入")), + .child(i18n::t("var.builtin_inline")), ) .child( div() diff --git a/gpui-app/src/ui/dialogs/aliyun_dialog.rs b/gpui-app/src/ui/dialogs/aliyun_dialog.rs index 69778f5..0e0b97b 100644 --- a/gpui-app/src/ui/dialogs/aliyun_dialog.rs +++ b/gpui-app/src/ui/dialogs/aliyun_dialog.rs @@ -20,7 +20,9 @@ use crate::ui::app::MqttXApp; use crate::ui::widgets::{field, make_select, OptionDelegate}; use crate::ui::i18n; -const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"]; +fn auth_mode_labels() -> [&'static str; 2] { + [i18n::t("aliyun.auth_token_mode"), i18n::t("aliyun.auth_triple_mode")] +} struct AliyunDialog { app: Entity, @@ -60,12 +62,12 @@ impl AliyunDialog { app, presets, selected, - name: text_field!(cx, window, &first.name, i18n::t("预设名称")), - group: text_field!(cx, window, &first.group, i18n::t("分组(可选)")), + name: text_field!(cx, window, &first.name, i18n::t("aliyun.preset_name")), + group: text_field!(cx, window, &first.group, i18n::t("aliyun.group")), instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"), - region: text_field!(cx, window, &first.region, i18n::t("如 cn-shanghai(可留空)")), + region: text_field!(cx, window, &first.region, i18n::t("aliyun.region_ph")), auth_mode: make_select( - &AUTH_MODES, + &auth_mode_labels(), match first.auth_mode { AliyunAuthMode::Token => 0, AliyunAuthMode::DeviceCredential => 1, @@ -81,12 +83,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, i18n::t("设备 ID")), - product_key: text_field!(cx, window, &first.product_key, i18n::t("一机一密:ProductKey")), + device_id: text_field!(cx, window, &first.device_id, i18n::t("aliyun.device_id")), + product_key: text_field!(cx, window, &first.product_key, i18n::t("aliyun.product_key_ph")), device_secret: cx.new(|cx| { InputState::new(window, cx) .default_value(&first.device_secret) - .placeholder(i18n::t("一机一密:DeviceSecret")) + .placeholder(i18n::t("aliyun.device_secret_ph")) .masked(true) }), } @@ -186,7 +188,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(i18n::t("请填写预设名称")), cx); + window.push_notification(Notification::warning(i18n::t("pub.preset_name_required")), cx); return; } match self.selected { @@ -198,7 +200,7 @@ impl AliyunDialog { a.save_aliyun(saved); cx.notify(); }); - window.push_notification(Notification::success(i18n::t("预设已保存")), cx); + window.push_notification(Notification::success(i18n::t("pub.preset_saved")), cx); } fn connect(&self, window: &mut Window, cx: &mut Context) { @@ -217,7 +219,7 @@ impl AliyunDialog { window.close_dialog(cx); } Err(e) => { - window.push_notification(Notification::error(i18n::tf("无法生成连接: {e}", &[("e", &e.to_string())])), cx); + window.push_notification(Notification::error(i18n::tf("aliyun.build_failed", &[("e", &e)])), cx); } } } @@ -233,7 +235,7 @@ impl Render for AliyunDialog { v_flex() .w(px(160.)) .gap_1() - .child(div().text_xs().font_semibold().child(i18n::t("已保存预设"))) + .child(div().text_xs().font_semibold().child(i18n::t("aliyun.saved_presets"))) .children( self.presets .iter() @@ -257,7 +259,7 @@ impl Render for AliyunDialog { .child( Button::new("aliyun-new") .icon(IconName::Plus) - .label(i18n::t("新建")) + .label(i18n::t("common.new")) .ghost() .small() .on_click(cx.listener(|this, _, window, cx| { @@ -271,36 +273,36 @@ impl Render for AliyunDialog { .flex_1() .min_w(px(0.)) .gap_2() - .child(field(i18n::t("预设名称"), Input::new(&self.name))) + .child(field(i18n::t("aliyun.preset_name"), Input::new(&self.name))) .child( h_flex() .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("实例 ID"), + i18n::t("aliyun.instance_id"), Input::new(&self.instance_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("地域"), + i18n::t("aliyun.region"), Input::new(&self.region), )), ), ) - .child(field(i18n::t("鉴权方式"), Select::new(&self.auth_mode))) + .child(field(i18n::t("aliyun.auth_mode"), Select::new(&self.auth_mode))) .child( h_flex() .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("AccessKey ID"), + i18n::t("aliyun.access_key_id"), Input::new(&self.access_key_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("AccessKey Secret"), + i18n::t("aliyun.access_key_secret"), Input::new(&self.access_key_secret).mask_toggle(), )), ), @@ -310,13 +312,13 @@ impl Render for AliyunDialog { .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("Group ID"), + i18n::t("aliyun.group_id"), Input::new(&self.group_id), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("设备 ID"), + i18n::t("aliyun.device_id"), Input::new(&self.device_id), )), ), @@ -326,13 +328,13 @@ impl Render for AliyunDialog { .gap_2() .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("ProductKey(一机一密)"), + i18n::t("aliyun.product_key"), Input::new(&self.product_key), )), ) .child( div().flex_1().min_w(px(0.)).child(field( - i18n::t("DeviceSecret(一机一密)"), + i18n::t("aliyun.device_secret"), Input::new(&self.device_secret).mask_toggle(), )), ), @@ -341,7 +343,7 @@ impl Render for AliyunDialog { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名"), + .child(i18n::t("aliyun.help")), ) .child( h_flex() @@ -349,7 +351,7 @@ impl Render for AliyunDialog { .justify_end() .child( Button::new("aliyun-save") - .label(i18n::t("保存预设")) + .label(i18n::t("aliyun.save_preset")) .outline() .on_click(cx.listener(|this, _, window, cx| { this.save_preset(window, cx) @@ -357,7 +359,7 @@ impl Render for AliyunDialog { ) .child( Button::new("aliyun-connect") - .label(i18n::t("生成并连接")) + .label(i18n::t("aliyun.generate_connect")) .primary() .on_click(cx.listener(|this, _, window, cx| { this.connect(window, cx) @@ -379,7 +381,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(760.)) - .title(i18n::t("阿里云 IoT 设备")) + .title(i18n::t("aliyun.title")) .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 d2006f3..84e88d2 100644 --- a/gpui-app/src/ui/dialogs/presets_dialog.rs +++ b/gpui-app/src/ui/dialogs/presets_dialog.rs @@ -81,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(i18n::t("预设名称,如 温度上报")) + .placeholder(i18n::t("pub.preset_name_hint")) .default_value(name_value) }); let topic = cx.new(|cx| { InputState::new(window, cx) - .placeholder(i18n::t("发布主题,支持 {{变量名}}")) + .placeholder(i18n::t("pub.topic_preset_ph")) .default_value(params.topic.clone()) }); let payload = cx.new(|cx| { TextareaState::new(window, cx) - .placeholder(i18n::t("消息负载,支持 {{变量名}}")) + .placeholder(i18n::t("pub.payload_preset_ph")) .default_value(params.payload.clone()) }); let qos = make_select(&QOS, params.qos.min(2) as usize, window, cx); @@ -102,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(i18n::t("Content-Type(可选)")) + .placeholder(i18n::t("pub.content_type_ph")) .default_value(params.content_type.clone().unwrap_or_default()) }); let msg_expiry = cx.new(|cx| { - InputState::new(window, cx).placeholder(i18n::t("秒,如 60")).default_value( + InputState::new(window, cx).placeholder(i18n::t("pub.seconds_hint")).default_value( params .message_expiry_interval .map(|v| v.to_string()) @@ -115,12 +115,12 @@ impl PresetsDialog { }); let response_topic = cx.new(|cx| { InputState::new(window, cx) - .placeholder(i18n::t("Response Topic(可选)")) + .placeholder(i18n::t("pub.response_topic_ph")) .default_value(params.response_topic.clone().unwrap_or_default()) }); let correlation_data = cx.new(|cx| { InputState::new(window, cx) - .placeholder(i18n::t("Correlation Data(可选)")) + .placeholder(i18n::t("pub.correlation_ph")) .default_value(params.correlation_data.clone().unwrap_or_default()) }); let user_props = @@ -268,10 +268,10 @@ impl PresetsDialog { /// 「新建」:创建默认名称预设(重名自动加序号)并立即落盘、进入编辑。 fn create_new(&mut self, window: &mut Window, cx: &mut Context) { - let mut name = i18n::t("新预设").to_string(); + let mut name = i18n::t("preset.default_name").to_string(); let mut n = 2; while self.presets.iter().any(|p| p.name == name) { - name = i18n::tf("新预设 {n}", &[("n", &n.to_string())]); + name = i18n::tf("preset.default_name_n", &[("n", &n)]); n += 1; } self.presets.push(PublishPreset::new(name, PublishParams::default())); @@ -283,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(i18n::t("请先选择或新建预设")), cx); + window.push_notification(Notification::warning(i18n::t("preset.pick_first")), cx); return; }; let name = self.name.read(cx).value().trim().to_string(); if name.is_empty() { - window.push_notification(Notification::warning(i18n::t("请填写预设名称")), cx); + window.push_notification(Notification::warning(i18n::t("pub.preset_name_required")), cx); return; } // 与其他预设(不含自身)重名 → 报错且不落盘 @@ -312,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(i18n::t("消息过期须为非负整数(秒)")), + Notification::warning(i18n::t("pub.expiry_invalid")), cx, ); return; @@ -332,7 +332,7 @@ impl PresetsDialog { /// 「删除」:`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(i18n::t("请先选择要删除的预设")), cx); + window.push_notification(Notification::warning(i18n::t("preset.pick_delete_first")), cx); return; }; let pid = self.presets[idx].id.clone(); @@ -343,7 +343,7 @@ impl PresetsDialog { let weak = weak.clone(); let pid = pid.clone(); alert - .title(i18n::t("删除预设")) + .title(i18n::t("pub.delete_title")) .description(i18n::tf( "确定删除预设「{name}」吗?此操作不可撤销。", &[("name", &pname)], @@ -351,8 +351,8 @@ impl PresetsDialog { .button_props( DialogButtonProps::default() .show_cancel(true) - .cancel_text(i18n::t("取消")) - .ok_text(i18n::t("删除")) + .cancel_text(i18n::t("common.cancel")) + .ok_text(i18n::t("common.delete")) .ok_variant(ButtonVariant::Danger), ) .on_ok(move |_, window, cx| { @@ -398,7 +398,7 @@ impl Render for PresetsDialog { .text_xs() .text_color(muted) .text_center() - .child(i18n::t("暂无发布预设,点击上方「新建」创建")), + .child(i18n::t("preset.empty")), ), ); } else { @@ -460,10 +460,10 @@ impl Render for PresetsDialog { .flex_1() .min_w(px(0.)) .gap_2() - .child(field(i18n::t("预设名称"), Input::new(&self.name))) - .child(field(i18n::t("发布主题"), Input::new(&self.topic))) + .child(field(i18n::t("aliyun.preset_name"), Input::new(&self.name))) + .child(field(i18n::t("pub.topic_placeholder"), Input::new(&self.topic))) .child(field( - i18n::t("Payload(原文保存,发送时渲染变量)"), + i18n::t("preset.payload_field"), Textarea::new(&self.payload).h(px(96.)), )) .child( @@ -494,13 +494,13 @@ impl Render for PresetsDialog { .flex_1() .min_w(px(0.)) .child(field( - i18n::t("Payload 格式"), + i18n::t("preset.payload_format"), Select::new(&self.payload_format).small(), )), ), ) .child(div().h(px(1.)).bg(cx.theme().border).w_full()) - .child(div().text_xs().font_semibold().child(i18n::t("MQTT 5 属性"))) + .child(div().text_xs().font_semibold().child(i18n::t("pub.mqtt5_props"))) .child( h_flex() .gap_2() @@ -512,7 +512,7 @@ impl Render for PresetsDialog { ) .child( div().w(px(120.)).child(field( - i18n::t("消息过期(秒)"), + i18n::t("pub.expiry_field"), Input::new(&self.msg_expiry).small(), )), ), @@ -533,7 +533,7 @@ impl Render for PresetsDialog { )), ), ) - .child(field(i18n::t("用户属性"), self.user_props.clone())); + .child(field(i18n::t("preset.user_props"), self.user_props.clone())); h_flex() .gap_3() @@ -548,11 +548,11 @@ impl Render for PresetsDialog { h_flex() .justify_between() .items_center() - .child(div().text_sm().font_semibold().child(i18n::t("发布预设"))) + .child(div().text_sm().font_semibold().child(i18n::t("preset.section"))) .child( Button::new("preset-new") .icon(IconName::Plus) - .label(i18n::t("新建")) + .label(i18n::t("common.new")) .ghost() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -584,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(i18n::t("管理发布预设")) + .title(i18n::t("preset.manage_title")) .child(body) .footer( h_flex() @@ -593,7 +593,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .child( Button::new("preset-delete") .icon(IconName::Trash) - .label(i18n::t("删除")) + .label(i18n::t("common.delete")) .danger() .outline() .disabled(!has_selection) @@ -606,13 +606,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .gap_2() .child( Button::new("preset-close") - .label(i18n::t("关闭")) + .label(i18n::t("common.close")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("preset-save") - .label(i18n::t("保存")) + .label(i18n::t("common.save")) .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 6b68d1a..ff2e017 100644 --- a/gpui-app/src/ui/dialogs/resource_dialog.rs +++ b/gpui-app/src/ui/dialogs/resource_dialog.rs @@ -90,7 +90,7 @@ impl ResourceMonitorDialog { .get(&c.id) .copied() .map(|s| s.label()) - .unwrap_or(i18n::t("未连接")), + .unwrap_or(i18n::t("status.disconnected")), connected: app.engine.is_connected(&c.id), messages, bytes, @@ -247,7 +247,7 @@ impl ResourceMonitorDialog { div() .text_xs() .text_color(muted) - .child(i18n::tf("{n} 条消息", &[("n", &c.messages.to_string())])), + .child(i18n::tf("resmon.n_msgs", &[("n", &c.messages)])), ), ) .child( @@ -263,11 +263,16 @@ impl ResourceMonitorDialog { div() .text_xs() .text_color(muted) - .child(format!( - "预算占用 {:.0}%({} / {})", - pct, - Self::fmt_bytes(c.bytes as u64), - Self::fmt_bytes(MAX_CONNECTION_MESSAGE_BYTES as u64) + .child(i18n::tf( + "预算占用 {p}%({used} / {total})", + &[ + ("p", &format!("{:.0}", pct)), + ("used", &Self::fmt_bytes(c.bytes as u64)), + ( + "total", + &Self::fmt_bytes(MAX_CONNECTION_MESSAGE_BYTES as u64), + ), + ] )), ), ), @@ -310,7 +315,7 @@ impl Render for ResourceMonitorDialog { cx.theme().primary, cx.theme().primary_foreground, )) - .child(div().text_base().font_semibold().child(i18n::t("资源监控"))) + .child(div().text_base().font_semibold().child(i18n::t("titlebar.resmon"))) .child( div() .text_xs() @@ -319,7 +324,7 @@ impl Render for ResourceMonitorDialog { .rounded_full() .bg(cx.theme().muted) .text_color(muted) - .child(i18n::tf("运行 {t}", &[("t", &uptime_text)])), + .child(i18n::tf("resmon.uptime", &[("t", &uptime_text)])), ), ) .child( @@ -334,7 +339,7 @@ impl Render for ResourceMonitorDialog { div() .text_xs() .text_color(muted) - .child(i18n::t("自动刷新")), + .child(i18n::t("resmon.auto_refresh")), ) .child( Switch::new("resmon-auto") @@ -348,7 +353,7 @@ impl Render for ResourceMonitorDialog { .child( Button::new("resmon-refresh") .icon(IconName::RefreshCw) - .label(i18n::t("立即刷新")) + .label(i18n::t("resmon.refresh_now")) .outline() .xsmall() .on_click(cx.listener(|this, _, _, cx| { @@ -367,16 +372,16 @@ impl Render for ResourceMonitorDialog { .child(self.tile( "tile-cpu", IconName::Cpu, - i18n::t("CPU 占用"), + i18n::t("resmon.cpu"), format!("{cpu:.1}%"), - Some(i18n::t("按可用核数归一").into()), + Some(i18n::t("resmon.cpu_hint").into()), Some(cpu), cx, )) .child(self.tile( "tile-ws", IconName::MemoryStick, - i18n::t("工作集内存"), + i18n::t("resmon.working_set"), Self::fmt_bytes(ws), Some(i18n::tf( "峰值 {v}", @@ -388,18 +393,18 @@ impl Render for ResourceMonitorDialog { .child(self.tile( "tile-private", IconName::HardDrive, - i18n::t("提交内存"), + i18n::t("resmon.committed"), Self::fmt_bytes(s.private_bytes), - Some(i18n::t("进程独占的物理页承诺").into()), + Some(i18n::t("resmon.committed_hint").into()), None, cx, )) .child(self.tile( "tile-threads", IconName::Layers, - i18n::t("线程 · 句柄"), + i18n::t("resmon.threads_handles"), format!("{} / {}", s.threads, s.handles), - Some(i18n::t("tokio 引擎 + GPUI 渲染").into()), + Some(i18n::t("resmon.threads_hint").into()), None, cx, )); @@ -416,7 +421,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(i18n::t("暂无连接"))), + .child(div().text_sm().text_color(muted).child(i18n::t("resmon.no_conn"))), ); } for (i, c) in self.conns.iter().enumerate() { @@ -431,19 +436,21 @@ impl Render for ResourceMonitorDialog { .child( v_flex() .gap_2() - .child(self.section_header(IconName::Database, i18n::t("消息与连接"), cx)) + .child(self.section_header(IconName::Database, i18n::t("resmon.section"), cx)) .child( div() .text_xs() .text_color(muted) - .child(format!( - "{} 个连接(已连接 {})· 打开标签 {} · 内存消息 {} 条(每连接上限 {} 条)· 日志 {} 条", - self.conns.len(), - connected_count, - self.tabs, - self.total_messages, - self.app.read(cx).settings.max_messages, - self.logs + .child(i18n::tf( + "{n} 个连接(已连接 {m})· 打开标签 {t} · 内存消息 {msgs} 条(每连接上限 {cap} 条)· 日志 {logs} 条", + &[ + ("n", &self.conns.len()), + ("m", &connected_count), + ("t", &self.tabs), + ("msgs", &self.total_messages), + ("cap", &self.app.read(cx).settings.max_messages), + ("logs", &self.logs), + ] )), ) .child(conns_block), @@ -458,14 +465,14 @@ impl Render for ResourceMonitorDialog { .text_xs() .text_color(muted) .child(if self.auto_refresh { - format!( - "版本 v{} · 每 1.5 秒自动刷新", - crate::update::current_version() + i18n::tf( + "版本 v{v} · 每 1.5 秒自动刷新", + &[("v", &crate::update::current_version())], ) } else { - format!( - "版本 v{} · 自动刷新已暂停", - crate::update::current_version() + i18n::tf( + "版本 v{v} · 自动刷新已暂停", + &[("v", &crate::update::current_version())], ) }), ), @@ -506,7 +513,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(640.)) - .title(i18n::t("资源监控")) + .title(i18n::t("titlebar.resmon")) .child(dialog_view.clone()) .footer( h_flex() @@ -514,7 +521,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("res-close") - .label(i18n::t("关闭")) + .label(i18n::t("common.close")) .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 9713ea1..2ee181a 100644 --- a/gpui-app/src/ui/dialogs/settings_dialog.rs +++ b/gpui-app/src/ui/dialogs/settings_dialog.rs @@ -26,10 +26,10 @@ use crate::ui::i18n; use crate::update::{self, UpdateInfo}; fn theme_labels() -> [&'static str; 3] { - [i18n::t("跟随系统"), i18n::t("浅色"), i18n::t("深色")] + [i18n::t("theme.system"), i18n::t("theme.light"), i18n::t("theme.dark")] } fn language_labels() -> [&'static str; 3] { - [i18n::t("跟随系统 / System"), "中文", "English"] + ["跟随系统 / System", "中文", "English"] } /// 消息缓存条数的合法范围:太少不够回看,太多占内存 @@ -179,32 +179,32 @@ impl SettingsDialog { match &self.update { UpdateUi::Idle => row( Button::new("check-update") - .label(i18n::t("检查更新")) + .label(i18n::t("update.check")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - i18n::t("检查 GitHub Releases 上的新版本").into_any_element(), + i18n::t("update.check_hint").into_any_element(), false, ), UpdateUi::Checking => row( Button::new("check-update") - .label(i18n::t("检查中…")) + .label(i18n::t("update.checking")) .outline() .xsmall() .loading(true) .into_any_element(), - i18n::t("正在检查更新…").into_any_element(), + i18n::t("update.checking_long").into_any_element(), false, ), UpdateUi::UpToDate => row( Button::new("check-update") - .label(i18n::t("重新检查")) + .label(i18n::t("update.recheck")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - i18n::t("已是最新版本").into_any_element(), + i18n::t("update.up_to_date").into_any_element(), false, ), UpdateUi::Available(info) => { @@ -249,7 +249,7 @@ impl SettingsDialog { .gap_1p5() .child( Button::new("upd-now") - .label(i18n::t("立即安装")) + .label(i18n::t("update.install_now")) .primary() .xsmall() .on_click(move |_, window, cx| { @@ -267,7 +267,7 @@ impl SettingsDialog { ) .child( Button::new("upd-later") - .label(i18n::t("下次启动安装")) + .label(i18n::t("update.install_later")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -288,7 +288,7 @@ impl SettingsDialog { ) .child( Button::new("upd-discard") - .label(i18n::t("放弃此次更新")) + .label(i18n::t("update.discard")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| { @@ -297,7 +297,7 @@ impl SettingsDialog { } this.update = UpdateUi::Idle; window.push_notification( - Notification::info(i18n::t("已删除暂存的更新包")), + Notification::info(i18n::t("update.discarded")), cx, ); cx.notify(); @@ -341,12 +341,12 @@ impl SettingsDialog { } UpdateUi::Failed(e) => row( Button::new("check-update") - .label(i18n::t("重试")) + .label(i18n::t("common.retry")) .outline() .xsmall() .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) .into_any_element(), - i18n::tf("更新失败:{e}", &[("e", &e)]).into_any_element(), + i18n::tf("update.failed", &[("e", &e)]).into_any_element(), false, ), } @@ -401,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(i18n::tf("打开目录失败: {e}", &[("e", &e.to_string())])), cx); + window.push_notification(Notification::error(i18n::tf("settings.open_dir_failed", &[("e", &e)])), cx); } } @@ -415,10 +415,10 @@ impl Render for SettingsDialog { v_flex() .gap_4() .w(px(500.)) - .child(field(i18n::t("界面主题"), Select::new(&self.theme))) - .child(field(i18n::t("界面语言"), Select::new(&self.language))) + .child(field(i18n::t("settings.theme"), Select::new(&self.theme))) + .child(field(i18n::t("settings.language"), Select::new(&self.language))) .child(field( - i18n::t("每条连接内存中保留的消息条数(100~100000)"), + i18n::t("settings.max_messages"), Input::new(&self.max_messages), )) .child( @@ -435,7 +435,7 @@ impl Render for SettingsDialog { .child( h_flex() .justify_between() - .child(div().text_sm().child(i18n::t("时间戳显示毫秒"))) + .child(div().text_sm().child(i18n::t("settings.show_millis"))) .child( Switch::new("show-millis") .checked(self.show_millis) @@ -448,7 +448,7 @@ impl Render for SettingsDialog { .child( h_flex() .justify_between() - .child(div().text_sm().child(i18n::t("自动检查更新(启动时)"))) + .child(div().text_sm().child(i18n::t("settings.auto_update"))) .child( Switch::new("auto-check-update") .checked(self.auto_check_update) @@ -460,7 +460,7 @@ impl Render for SettingsDialog { ) .child(div().h(px(1.)).bg(cx.theme().border).w_full()) .child(field( - i18n::t("数据目录"), + i18n::t("settings.data_dir"), h_flex() .gap_1() .w_full() @@ -479,7 +479,7 @@ impl Render for SettingsDialog { .child( Button::new("open-data-dir") .icon(IconName::FolderOpen) - .label(i18n::t("打开目录")) + .label(i18n::t("settings.open_dir")) .outline() .xsmall() .on_click(move |_, window, cx| { @@ -488,7 +488,7 @@ impl Render for SettingsDialog { ), )) .child(field( - i18n::t("日志目录"), + i18n::t("settings.log_dir"), h_flex() .gap_1() .w_full() @@ -506,14 +506,14 @@ impl Render for SettingsDialog { .child( Button::new("open-log-dir") .icon(IconName::FolderOpen) - .label(i18n::t("打开日志目录")) + .label(i18n::t("settings.open_log_dir")) .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(i18n::tf("创建日志目录失败: {e}", &[("e", &e.to_string())])), + Notification::error(i18n::tf("settings.mkdir_failed", &[("e", &e)])), cx, ); return; @@ -527,7 +527,7 @@ impl Render for SettingsDialog { v_flex().gap_1().child( v_flex() .gap_1() - .child(div().text_sm().font_semibold().child(i18n::t("关于"))) + .child(div().text_sm().font_semibold().child(i18n::t("settings.about"))) .child( h_flex() .gap_2() @@ -536,18 +536,18 @@ impl Render for SettingsDialog { .child(i18n::tf( "版本 {v}(更新源 {src})", &[ - ("v", &update::current_version().to_string()), - ("src", update::UPDATE_SOURCE), + ("v", &update::current_version()), + ("src", &update::UPDATE_SOURCE), ], )) - .child(i18n::t("Apache-2.0 许可证")), + .child(i18n::t("settings.license")), ) .child(self.render_update_row(window, cx)) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(i18n::t("对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端")), + .child(i18n::t("settings.tagline")), ) .child( h_flex().gap_1().child( @@ -605,13 +605,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("settings-cancel") - .label(i18n::t("取消")) + .label(i18n::t("common.cancel")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("settings-ok") - .label(i18n::t("保存")) + .label(i18n::t("common.save")) .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 8c78974..35bf1e9 100644 --- a/gpui-app/src/ui/dialogs/variables_dialog.rs +++ b/gpui-app/src/ui/dialogs/variables_dialog.rs @@ -33,7 +33,7 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { let app_ok = app.clone(); dialog .w(px(560.)) - .title(i18n::t("全局变量")) + .title(i18n::t("titlebar.variables")) .child( v_flex() .gap_2() @@ -41,8 +41,8 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { v_flex() .text_sm() .gap_1() - .child(i18n::t("在发布主题、消息负载中使用 {{变量名}} 引用。")) - .child(i18n::t("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}")), + .child(i18n::t("var.dialog_hint")) + .child(i18n::t("var.dialog_builtin")), ) .child(editor_body), ) @@ -53,13 +53,13 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { .w_full() .child( Button::new("vars-cancel") - .label(i18n::t("取消")) + .label(i18n::t("common.cancel")) .outline() .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( Button::new("vars-ok") - .label(i18n::t("保存")) + .label(i18n::t("common.save")) .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 index d8d3dc8..da7736d 100644 --- a/gpui-app/src/ui/i18n.rs +++ b/gpui-app/src/ui/i18n.rs @@ -1,12 +1,29 @@ -//! 极简 i18n:以中文原文为 key 的双语文案表。 +//! i18n 门面:语义键(`ns.key`)+ rust-i18n(gpui-kit 官方方案)。 //! -//! 设计取舍: -//! - 中文是基准语言,`t("订阅")` 直接返回原文,代码里不引入间接 key, -//! 漏翻时回落中文而不是出现裸 key; -//! - 英文表集中在本文件一个 `match`,新增文案先写中文、补翻英文即可; -//! - 带参数的文案用 [`tf`],占位符 `{name}` 在中英模式中同名替换; -//! - 语言是进程级全局(UI 渲染热路径每帧取用,用原子量免锁)。 +//! # 架构 +//! - 语言包在 `locales/ui.yml`(en / zh-CN 双语并排,`_version: 2`), +//! 由 crate 根的 `rust_i18n::i18n!("locales", fallback = "en")` 编译期内嵌; +//! gpui-kit 组件内置文案经 main.rs 的 `rust_i18n::extend!` 随语言切换。 +//! - 调用点写 `t("common.save")` / `tf("msg.count", &[("n", &count)])`, +//! key 是**稳定标识符**,与任何自然语言解耦——改文案措辞不动代码, +//! 也不存在「一词多义撞 key」。 +//! - [`t`]/[`tf`] 直接查 [`crate::_rust_i18n_translate`](rust-i18n 生成、 +//! 接受运行时 key 的公开函数),不经 `t!` 宏——`label()` 这类运行时 +//! 值同样可查。 +//! +//! # 规则(单元测试强制,违反即测试失败) +//! 1. 新增文案:调用点 `t("ns.key")`,`locales/ui.yml` 加 key 块(en/zh-CN 必填)。 +//! 2. 源码每个 `t("…")` / `tf("…")` 字面量必须在语言包中存在。 +//! 3. 模型 `label()` 返回的 key 必须有**非自映射**的 en 词条。 +//! 4. 同一 key 的 zh-CN 与 en 占位符 `{name}` 集合必须一致 +//! (`tf` 按名替换,两侧不一致即静默漏参)。 +//! +//! # 已知限制 +//! 语言切换对**已打开页签里的下拉框**(QoS / Retain Handling 等)不即时生效—— +//! 这些 SelectState 在构造时固化了标签列表;重开页签即刷新。其余界面即时生效 +//! (save_settings 内 `window.refresh()` 兜底整窗重绘)。 +use std::borrow::Cow; use std::sync::atomic::{AtomicU8, Ordering}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -15,11 +32,20 @@ pub enum Lang { En, } -/// 0 = 中文,1 = English。默认中文(基准语言)。 +const LOCALE_ZH: &str = "zh-CN"; +const LOCALE_EN: &str = "en"; + +/// 0 = 中文,1 = English。默认中文。 static LANG: AtomicU8 = AtomicU8::new(0); +/// 切换语言:同时更新 rust-i18n 全局 locale(gpui-kit 组件内置文案随之切换)。 +/// 已渲染界面的刷新由调用方负责(settings 保存路径带整窗 refresh)。 pub fn set_lang(lang: Lang) { LANG.store(lang as u8, Ordering::Relaxed); + gpui_kit::component::set_locale(match lang { + Lang::Zh => LOCALE_ZH, + Lang::En => LOCALE_EN, + }); } pub fn lang() -> Lang { @@ -38,394 +64,253 @@ pub fn from_system() -> Lang { } } -/// 取当前语言的界面文案。中文原文即 key,英文缺失时回退中文。 -pub fn t(zh: &'static str) -> &'static str { - if lang() == Lang::Zh { - return zh; - } - en(zh) +/// 查指定 locale 的词条。 +fn translate<'k>(locale: &str, key: &'k str) -> Cow<'k, str> { + crate::_rust_i18n_translate(locale, key) } -/// 带参数的界面文案:中英模式均为 `{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() +/// 取当前语言的界面文案。miss 时 rust-i18n 原样返回 key(测试会拦截, +/// 运行期裸 key 只是最后的安全网)。 +pub fn t(key: &'static str) -> &'static str { + let locale = match lang() { + Lang::Zh => LOCALE_ZH, + Lang::En => LOCALE_EN, }; - let mut out = pattern; + match translate(locale, key) { + // 无参查表只返回 Borrowed(静态表值或原 key);Owned 视为异常回退 key + Cow::Borrowed(s) => s, + Cow::Owned(_) => key, + } +} + +/// 带参数的界面文案:语言包内占位符写作 `{name}`(非 rust-i18n 的 `%{name}`, +/// 由本函数按名替换),参数值只需 `Display`。 +pub fn tf(key: &'static str, args: &[(&str, &dyn std::fmt::Display)]) -> String { + let mut out = t(key).to_string(); for (name, value) in args { - out = out.replace(&format!("{{{name}}}"), value); + out = out.replace(&format!("{{{name}}}"), &value.to_string()); } 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", +/// 提取 `{name}` 形式的占位符(ASCII 标识符;`{{x}}` 双括号、`{$builtin}` +/// 与中文花括号说明文不算),排序后比较集合。 +#[cfg(test)] +fn placeholders(s: &str) -> Vec { + let b = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + if b[i] == b'{' { + let before_ok = i == 0 || b[i - 1] != b'{'; + let mut j = i + 1; + while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') { + j += 1; + } + let after_ok = j < b.len() && b[j] == b'}' && (j + 1 == b.len() || b[j + 1] != b'}'); + let name = &s[i + 1..j]; + let name_ok = name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); + if before_ok && after_ok && name_ok { + out.push(name.to_string()); + i = j + 1; + continue; + } + } + i += 1; + } + out.sort(); + out +} - // ── 侧边栏 ── - "搜索连接…" => "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", +/// 解析生成器产出的规整 YAML(key 行 + 缩进的 en/zh-CN 值行)。 +/// 格式意外时 panic——语言包是构建产物,格式受控。 +#[cfg(test)] +fn load_ui_yml() -> std::collections::BTreeMap { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales/ui.yml"), + ) + .expect("locales/ui.yml 应存在"); + let unquote = |v: &str| -> String { + let v = v.trim(); + let inner = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(v); + inner.replace("\\\"", "\"").replace("\\\\", "\\") + }; + let mut map = std::collections::BTreeMap::new(); + let mut key: Option = None; + let mut en: Option = None; + let mut zh: Option = None; + for line in text.lines() { + if line.starts_with('#') || line.starts_with("_version") || line.trim().is_empty() { + continue; + } + if !line.starts_with(' ') { + if let (Some(k), Some(e), Some(z)) = (key.take(), en.take(), zh.take()) { + map.insert(k, (e, z)); + } + key = Some(unquote(line.trim_end_matches(':'))); + } else if let Some(v) = line.trim().strip_prefix("en:") { + en = Some(unquote(v)); + } else if let Some(v) = line.trim().strip_prefix("zh-CN:") { + zh = Some(unquote(v)); + } + } + if let (Some(k), Some(e), Some(z)) = (key, en, zh) { + map.insert(k, (e, z)); + } + map +} - // ── 主区 / 标签页 ── - "开始使用 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)", +#[cfg(test)] +mod tests { + use super::*; - // ── 连接工作区 ── - "收起侧栏" => "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" + /// 每个 key 都必须有非空 en 与 zh-CN;占位符两侧集合一致。 + #[test] + fn yml_complete_and_placeholder_parity() { + let map = load_ui_yml(); + assert!(!map.is_empty(), "语言包不能为空"); + for (k, (en, zh)) in &map { + assert!(!en.trim().is_empty(), "en 为空: {k:?}"); + assert!(!zh.trim().is_empty(), "zh-CN 为空: {k:?}"); + assert_eq!( + placeholders(zh), + placeholders(en), + "占位符两侧不一致: {k:?}" + ); } - "别名(可选)" => "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." + /// 扫描 src/ 全部源码里的 `t("…")` / `tf("…")` 字面量, + /// 逐一断言语言包有词条——漏翻是测试失败,不再是静默回退。 + #[test] + fn call_sites_covered_by_yml() { + let map = load_ui_yml(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + collect_rs(&root, &mut files); + assert!(!files.is_empty(), "应扫描到源码文件"); + let mut missing = Vec::new(); + for f in files { + if f.ends_with("i18n.rs") { + continue; + } + let src = std::fs::read_to_string(&f).unwrap(); + for key in extract_t_literals(&src) { + if !map.contains_key(&key) { + missing.push(format!("{}: {key:?}", f.display())); + } + } } - "接收" => "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", + assert!( + missing.is_empty(), + "以下 t()/tf() 字面量没有语言包词条:\n{}", + missing.join("\n") + ); + } - // ── 发布面板 ── - "发布主题" => "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)", + /// 模型 `label()` 返回的 key 必须有真实(非自映射)的 en 词条。 + #[test] + fn model_labels_have_real_translations() { + let labels = [ + crate::model::ConnectionStatus::Disconnected.label(), + crate::model::ConnectionStatus::Connecting.label(), + crate::model::ConnectionStatus::Connected.label(), + crate::model::ConnectionStatus::Error.label(), + crate::model::Direction::Received.label(), + crate::model::Direction::Published.label(), + crate::model::ThemeModePref::System.label(), + crate::model::ThemeModePref::Light.label(), + crate::model::ThemeModePref::Dark.label(), + ]; + let missing: Vec<&str> = labels + .iter() + .filter(|l| translate(LOCALE_EN, l) == Cow::Borrowed(**l)) + .copied() + .collect(); + assert!(missing.is_empty(), "模型 label 缺 en 词条: {missing:?}"); + } - // ── 变量面板 ── - "检测到模板变量,发布时注入全局变量值" => { - "Template variables detected — global values injected on publish" + fn collect_rs(dir: &std::path::Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect_rs(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } } + } - // ── 连接表单 ── - "连接配置" => "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", + /// 手写扫描(避免引入 regex 依赖):找 `::t("` / `::tf("` 后的字面量。 + /// 两个针互不误伤:`::t("` 要求引号紧跟 t,不会命中 `::tf("`。 + fn extract_t_literals(src: &str) -> Vec { + let b = src.as_bytes(); + let mut out = Vec::new(); + for needle in [b"::tf(\"".as_slice(), b"::t(\"".as_slice()] { + let mut i = 0; + while let Some(rel) = find(b, i, needle) { + let start = rel + needle.len(); + let (lit, end) = read_string(b, start); + if let Some(lit) = lit { + out.push(lit); + } + i = end.max(i + 1); + } + } + out + } - // ── 设置 ── - "跟随系统" => "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}", + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + hay[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|p| p + from) + } - // ── 导入 / 导出 / 通知 ── - "导出 {n} 条连接到 {path}" => "Exported {n} connections to {path}", - "导入 {n} 条,跳过 {n2} 条" => "Imported {n}, skipped {n2}", - "已保存" => "Saved", - - // ── 预设管理 ── - "发布预设" => "Publish Presets", - "新建预设" => "New Preset", - "预设列表" => "Presets", - - // ── 资源监控 / 其他对话框 ── - "自身进程资源采样" => "Self-process resource sampling", - - _ => zh, + /// 从 `start`(首字节应为 `"`)读一个 Rust 字符串字面量, + /// 处理转义,返回 (解码内容, 结束位置)。 + fn read_string(b: &[u8], start: usize) -> (Option, usize) { + if start >= b.len() || b[start] != b'"' { + return (None, start + 1); + } + let mut out = String::new(); + let mut i = start + 1; + while i < b.len() { + match b[i] { + b'\\' => { + i += 1; + if i < b.len() { + match b[i] { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + other => { + out.push('\\'); + out.push(other as char); + } + } + i += 1; + } + } + b'"' => return (Some(out), i + 1), + _ => { + let mut end = i + 1; + while end < b.len() && (b[end] & 0xC0) == 0x80 { + end += 1; + } + out.push_str(&String::from_utf8_lossy(&b[i..end])); + i = end; + } + } + } + (None, i) } } diff --git a/gpui-app/src/ui/widgets/kv_editor.rs b/gpui-app/src/ui/widgets/kv_editor.rs index 9762c99..cef3d1a 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(crate::ui::i18n::t("添加属性")) + .label(crate::ui::i18n::t("kv.add")) .ghost() .xsmall() .on_click(cx.listener(|this, _, window, cx| {