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