feat: 国际化 / 键盘操作 / 无障碍 / 性能优化

国际化
- 新增 ui/i18n 双语文案表(中文原文为 key,英文集中映射,tf 支持占位符)
- AppSettings 增加 language(跟随系统/中文/English),启动与保存时即时生效
- 全部界面文案(标题栏/侧栏/工作区/发布/订阅/表单/设置/全部对话框/通知)入表

键盘操作
- Ctrl+W 关闭当前标签页;Ctrl+Tab / Ctrl+Shift+Tab 循环切换标签
- Ctrl+F 聚焦当前工作区消息过滤框

无障碍
- 全部纯图标按钮补 accessibility_label(屏幕阅读器 / UIA 自动化可识别)

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