fix(i18n): 全局复查修复——多行调用点漏替换、动态 label 回归与死键清理

复查发现(audit 脚本 + 修正后的单测扫描器):
- 27 处多行 tf("中文") 调用点未被键迁移的单行替换覆盖,英文模式下
  回退显示中文;正则补丁全部换为语义键
- DirFilter/DetailFormat::label() 仍返回中文常量,t() 动态查键必然
  miss(英文模式方向过滤/格式按钮显示中文);label() 改返回
  common.all/dir.*、common.auto/text(Hex/Base64 语言中性字面量)
- 单测扫描器与 audit 脚本的共同盲区:t(/tf( 与引号之间的空白换行
  不被匹配;扫描器改为跳过空白后取字面量,多行调用纳入强制覆盖
- 6 处 UI 直写补翻:连接测试成功通知、复制连接"(copy)"后缀、
  rfd 导入/导出对话框标题、设置对话框标题、订阅标识符字段标签
- store 导入/导出错误与 PayloadFormat 编码错误(均经 Notification
  展示)key 化,共 14 条新词条
- 删除 10 个真死键;mqtt/tls/update 层诊断错误维持中文(技术诊断
  流,不进 UI 渲染路径),边界记录于模块文档

语言包现状:354 键、en/zh-CN 双语并排、零死键、零 UI 中文直写;
42 测试全绿;英文模式实机截图复核(含回归点)通过。
This commit is contained in:
2026-10-04 23:49:28 +08:00
parent 35e2b6fb60
commit 5db0972263
18 changed files with 101 additions and 90 deletions
+33 -30
View File
@@ -130,27 +130,18 @@ _version: 2
"common.new":
en: "New"
zh-CN: "新建"
"common.ok":
en: "OK"
zh-CN: "确定"
"common.pause":
en: "Pause"
zh-CN: "暂停"
"common.resume":
en: "Resume"
zh-CN: "恢复"
"common.retain":
en: "Retain"
zh-CN: "Retain"
"common.retry":
en: "Retry"
zh-CN: "重试"
"common.save":
en: "Save"
zh-CN: "保存"
"common.saved":
en: "Saved"
zh-CN: "已保存"
"common.send":
en: "Send"
zh-CN: "发送"
@@ -160,9 +151,6 @@ _version: 2
"common.update":
en: "Update"
zh-CN: "更新"
"connview.connecting":
en: "Connecting…"
zh-CN: "连接中…"
"connview.logs":
en: "Logs"
zh-CN: "日志"
@@ -247,9 +235,6 @@ _version: 2
"form.keep_alive":
en: "Keep Alive (s)"
zh-CN: "Keep Alive (秒)"
"form.keep_alive_invalid":
en: "Keep Alive must be an integer 0-65535"
zh-CN: "Keep Alive (秒) 需要是 0~65535 的整数"
"form.keep_alive_short":
en: "Keep Alive"
zh-CN: "Keep Alive"
@@ -400,9 +385,6 @@ _version: 2
"kv.add":
en: "Add property"
zh-CN: "添加属性"
"kv.remove":
en: "Remove this property"
zh-CN: "删除此属性"
"msg.binary_hint":
en: " (binary — switch to Hex in details)"
zh-CN: "(非文本,详情可切 Hex)"
@@ -490,18 +472,12 @@ _version: 2
"preset.empty":
en: "No presets yet — click \"New\" above to create one"
zh-CN: "暂无发布预设,点击上方「新建」创建"
"preset.list":
en: "Presets"
zh-CN: "预设列表"
"preset.manage_title":
en: "Manage Publish Presets"
zh-CN: "管理发布预设"
"preset.name_exists":
en: "Preset name \"{name}\" already exists"
zh-CN: "预设名称「{name}」已存在"
"preset.new":
en: "New Preset"
zh-CN: "新建预设"
"preset.payload_field":
en: "Payload (stored raw; variables rendered on send)"
zh-CN: "Payload(原文保存,发送时渲染变量)"
@@ -661,9 +637,6 @@ _version: 2
"resmon.threads_hint":
en: "tokio engine + GPUI renderer"
zh-CN: "tokio 引擎 + GPUI 渲染"
"resmon.title_hint":
en: "Self-process resource sampling"
zh-CN: "自身进程资源采样"
"resmon.uptime":
en: "Up {t}"
zh-CN: "运行 {t}"
@@ -727,9 +700,6 @@ _version: 2
"sidebar.collapse":
en: "Collapse sidebar (Ctrl+B)"
zh-CN: "收起侧栏(Ctrl+B)"
"sidebar.collapse_short":
en: "Collapse sidebar"
zh-CN: "收起侧栏"
"sidebar.connections":
en: "Connections"
zh-CN: "连接"
@@ -1033,3 +1003,36 @@ _version: 2
"will.topic_wildcard":
en: "Will topic cannot contain wildcards + or #"
zh-CN: "遗嘱主题不能包含通配符 + 或 #"
"io.err_item_id":
en: "Item {n}: id must not be empty"
zh-CN: "第 {n} 项的 id 不能为空"
"io.err_item_invalid":
en: "Item {n} is not a valid connection: {e}"
zh-CN: "第 {n} 项不是合法的连接配置: {e}"
"io.err_missing_field":
en: "JSON object lacks the \"connections\" array"
zh-CN: "JSON 对象中缺少 \"connections\" 数组字段"
"io.err_parse_json":
en: "Failed to parse JSON: {e}"
zh-CN: "解析 JSON 失败: {e}"
"io.err_read":
en: "Failed to read {p}: {e}"
zh-CN: "读取 {p} 失败: {e}"
"io.err_serialize":
en: "Failed to serialize connections: {e}"
zh-CN: "序列化连接失败: {e}"
"io.err_utf8":
en: "{p} is not valid UTF-8"
zh-CN: "{p} 不是有效的 UTF-8 文件"
"io.err_write":
en: "Failed to write {p}: {e}"
zh-CN: "写入 {p} 失败: {e}"
"sidebar.dup_suffix":
en: "{name} (copy)"
zh-CN: "{name} 副本"
"sub.identifier_field":
en: "Subscription Identifier"
zh-CN: "订阅标识符"
"io.err_parse_json_path":
en: "Failed to parse {p}"
zh-CN: "解析 {p} 失败"
+5 -5
View File
@@ -53,23 +53,23 @@ impl PayloadFormat {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| format!("Base64 解码失败: {e}"))
.map_err(|e| crate::ui::i18n::tf("encode.base64_failed", &[("e", &e.to_string())]))
}
Self::Hex => {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
if !cleaned.len().is_multiple_of(2) {
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
return Err(crate::ui::i18n::t("encode.hex_odd").into());
}
// 必须先做全字节校验:长度检查按字节计数,多字节字符(如中文)
// 可凑成偶数字节,直接按字节索引切片会落在字符边界内而 panic。
if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("Hex 解码失败: 含非十六进制字符".into());
return Err(crate::ui::i18n::t("encode.hex_invalid").into());
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|e| format!("Hex 解码失败: {e}"))
.map_err(|e| crate::ui::i18n::tf("encode.hex_failed", &[("e", &e.to_string())]))
})
.collect()
}
@@ -154,7 +154,7 @@ pub fn compute_preview(payload: &str) -> String {
}
};
if payload.contains('\u{FFFD}') {
out.push_str("(非文本,详情可切 Hex)");
out.push_str(crate::ui::i18n::t("msg.binary_hint"));
}
out
}
+10 -10
View File
@@ -72,7 +72,7 @@ impl Storage {
fn read_json_inner<T: for<'de> serde::Deserialize<'de>>(&self, p: &Path) -> Result<Option<T>> {
let mut text = std::fs::read_to_string(p)
.with_context(|| format!("读取 {} 失败", p.display()))?;
.with_context(|| crate::ui::i18n::tf("io.err_read", &[("p", &p.display().to_string())]))?;
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM(BOM 只有 1 个字符,逐字符剥离)
if let Some(rest) = text.strip_prefix('\u{feff}') {
text = rest.to_string();
@@ -81,7 +81,7 @@ impl Storage {
return Ok(None);
}
let value =
serde_json::from_str(&text).with_context(|| format!("解析 {} 失败", p.display()))?;
serde_json::from_str(&text).with_context(|| crate::ui::i18n::tf("io.err_parse_json_path", &[("p", &p.display().to_string())]))?;
Ok(Some(value))
}
@@ -245,8 +245,8 @@ impl Default for Storage {
/// 导出连接为 JSON 数组(与 connections.json 同构:缩进格式化、UTF-8 无 BOM)。
pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<(), String> {
let text = serde_json::to_string_pretty(conns)
.map_err(|e| format!("序列化连接失败: {e}"))?;
std::fs::write(path, text).map_err(|e| format!("写入 {} 失败: {e}", path.display()))
.map_err(|e| crate::ui::i18n::tf("io.err_serialize", &[("e", &e.to_string())]))?;
std::fs::write(path, text).map_err(|e| crate::ui::i18n::tf("io.err_write", &[("p", &path.display().to_string()), ("e", &e.to_string())]))
}
/// 从 JSON 文件导入连接。
@@ -254,21 +254,21 @@ pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<
/// 容错规则:容忍 UTF-8 BOM;顶层允许是数组,也可能是 `{ "connections": [...] }` 对象;
/// 任一项不是合法连接配置、或 `id` 为空白时,返回带序号的错误信息。
pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, String> {
let bytes = std::fs::read(path).map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
let bytes = std::fs::read(path).map_err(|e| crate::ui::i18n::tf("io.err_read", &[("p", &path.display().to_string()), ("e", &e.to_string())]))?;
let mut text =
String::from_utf8(bytes).map_err(|_| format!("{} 不是有效的 UTF-8 文件", path.display()))?;
String::from_utf8(bytes).map_err(|_| crate::ui::i18n::tf("io.err_utf8", &[("p", &path.display().to_string())]))?;
if let Some(rest) = text.strip_prefix('\u{feff}') {
text = rest.to_string();
}
let value: serde_json::Value =
serde_json::from_str(text.trim()).map_err(|e| format!("解析 JSON 失败: {e}"))?;
serde_json::from_str(text.trim()).map_err(|e| crate::ui::i18n::tf("io.err_parse_json", &[("e", &e.to_string())]))?;
let items = match value {
serde_json::Value::Array(items) => items,
serde_json::Value::Object(mut map) => match map.remove("connections") {
Some(serde_json::Value::Array(items)) => items,
_ => {
return Err(
"JSON 对象中缺少 \"connections\" 数组字段".to_string(),
crate::ui::i18n::t("io.err_missing_field").to_string(),
);
}
},
@@ -280,11 +280,11 @@ pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, Str
Ok(c) => {
// 空 id 会在去重/订阅关联时产生悬空引用,导入层直接拒绝
if c.id.trim().is_empty() {
return Err(format!("第 {} 项的 id 不能为空", i + 1));
return Err(crate::ui::i18n::tf("io.err_item_id", &[("n", &(i + 1))]));
}
conns.push(c);
}
Err(e) => return Err(format!("第 {} 项不是合法的连接配置: {e}", i + 1)),
Err(e) => return Err(crate::ui::i18n::tf("io.err_item_invalid", &[("n", &(i + 1)), ("e", &e.to_string())])),
}
}
Ok(conns)
+1 -1
View File
@@ -29,7 +29,7 @@ impl MqttXApp {
};
let mut copy = src;
copy.id = uuid::Uuid::new_v4().to_string();
copy.name = format!("{} 副本", copy.name);
copy.name = i18n::tf("sidebar.dup_suffix", &[("name", &copy.name)]);
copy.client_id =
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
self.connections.push(copy);
+1 -1
View File
@@ -112,7 +112,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
if let Some(e) = error {
window.push_notification(
Notification::error(i18n::tf(
"订阅失败: {e}",
"sub.failed",
&[("e", &e)],
)),
cx,
+4 -4
View File
@@ -64,7 +64,7 @@ impl MqttXApp {
self.spawn_file_dialog(
move || {
rfd::FileDialog::new()
.set_title("导出连接")
.set_title(i18n::t("titlebar.export"))
.set_directory(dir)
.set_file_name("connections-export.json")
.add_filter("JSON", &["json"])
@@ -78,7 +78,7 @@ impl MqttXApp {
match crate::store::export_connections_to(&path, &app.connections) {
Ok(()) => window.push_notification(
Notification::success(i18n::tf(
"导出 {n} 条连接到 {path}",
"io.exported",
&[
("n", &count),
("path", &path.display()),
@@ -100,7 +100,7 @@ impl MqttXApp {
self.spawn_file_dialog(
move || {
let Some(path) = rfd::FileDialog::new()
.set_title("导入连接")
.set_title(i18n::t("titlebar.import"))
.set_directory(dir)
.add_filter("JSON", &["json"])
.pick_file()
@@ -134,7 +134,7 @@ impl MqttXApp {
}
window.push_notification(
Notification::success(i18n::tf(
"导入 {n} 条,跳过 {n2} 条",
"io.imported",
&[
("n", &added),
("n2", &skipped),
+2 -2
View File
@@ -220,7 +220,7 @@ impl MqttXApp {
.child(
v_flex().gap_2().child(
div().text_sm().child(i18n::tf(
"新版本 v{v} 已下载完成(已通过 sha256 校验)。",
"update.ready_body",
&[("v", &staged.version)],
)),
),
@@ -263,7 +263,7 @@ impl MqttXApp {
{
window.push_notification(
Notification::error(i18n::tf(
"安装失败:{e}",
"update.install_failed",
&[("e", &e),
])),
cx,
+1 -1
View File
@@ -411,7 +411,7 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
alert
.title(i18n::t("sidebar.delete"))
.description(SharedString::from(i18n::tf(
"确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。",
"sidebar.delete_confirm",
&[("name", &name), ("sub_count", &sub_count)],
)))
.button_props(
+1 -1
View File
@@ -668,7 +668,7 @@ impl ConnectionForm {
form.testing = false;
match result {
Ok(()) => window.push_notification(
Notification::success("连接测试成功"),
Notification::success(i18n::t("form.test_ok")),
cx,
),
Err(e) => window.push_notification(Notification::error(format!(
+1 -1
View File
@@ -644,7 +644,7 @@ pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement
.px_1p5()
.text_color(cx.theme().muted_foreground)
.child(i18n::tf(
"仅显示最新 {n} 条",
"msg.latest_only",
&[("n", &MAX_RENDERED_MESSAGES)],
)),
)
+7 -5
View File
@@ -75,11 +75,12 @@ enum DirFilter {
}
impl DirFilter {
/// 返回 i18n 语义 key;Hex/Base64 为语言中性字面量。
fn label(self) -> &'static str {
match self {
Self::All => "全部",
Self::Received => "接收",
Self::Published => "发布",
Self::All => "common.all",
Self::Received => "dir.received",
Self::Published => "dir.published",
}
}
@@ -111,10 +112,11 @@ enum DetailFormat {
}
impl DetailFormat {
/// 返回 i18n 语义 key;Hex/Base64 为语言中性字面量。
fn label(self) -> &'static str {
match self {
Self::Auto => "自动",
Self::Text => "文本",
Self::Auto => "common.auto",
Self::Text => "common.text",
Self::Hex => "Hex",
Self::Base64 => "Base64",
}
+2 -2
View File
@@ -280,7 +280,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
.when(show_vars, |b| b.selected(true))
.tooltip(if count > 0 {
i18n::tf(
"变量注入({n} 个占位符)",
"pub.vars_count",
&[("n", &count)],
)
} else {
@@ -491,7 +491,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
alert
.title(i18n::t("pub.delete_title"))
.description(i18n::tf(
"确定删除预设「{name}」吗?此操作不可撤销。",
"pub.delete_confirm",
&[("name", &pname)],
))
.button_props(
+1 -1
View File
@@ -457,7 +457,7 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context<Self>) -> impl In
.child(
div()
.w(px(150.))
.child(field("订阅标识符", Input::new(&sub_identifier).small())),
.child(field(i18n::t("sub.identifier_field"), Input::new(&sub_identifier).small())),
)
.child(
h_flex()
+2 -2
View File
@@ -119,7 +119,7 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context<Self>) -> impl IntoEleme
.text_xs()
.font_semibold()
.child(i18n::tf(
"变量({n})",
"var.section",
&[("n", &self.var_rows.len())],
)),
)
@@ -160,7 +160,7 @@ pub(super) fn render_vars_panel(&self, cx: &mut Context<Self>) -> impl IntoEleme
.text_xs()
.text_color(muted)
.child(i18n::tf(
"预览:{tp} | {pl}",
"var.preview",
&[("tp", &topic_rendered), ("pl", &payload_preview)],
)),
)
+3 -3
View File
@@ -300,7 +300,7 @@ impl PresetsDialog {
{
window.push_notification(
Notification::error(i18n::tf(
"预设名称「{name}」已存在",
"preset.name_exists",
&[("name", &name)],
)),
cx,
@@ -324,7 +324,7 @@ impl PresetsDialog {
}
self.persist(cx);
window.push_notification(Notification::success(i18n::tf(
"预设「{name}」已保存",
"preset.saved",
&[("name", &name)],
)), cx);
}
@@ -345,7 +345,7 @@ impl PresetsDialog {
alert
.title(i18n::t("pub.delete_title"))
.description(i18n::tf(
"确定删除预设「{name}」吗?此操作不可撤销。",
"pub.delete_confirm",
&[("name", &pname)],
))
.button_props(
+5 -5
View File
@@ -264,7 +264,7 @@ impl ResourceMonitorDialog {
.text_xs()
.text_color(muted)
.child(i18n::tf(
"预算占用 {p}%({used} / {total})",
"resmon.budget",
&[
("p", &format!("{:.0}", pct)),
("used", &Self::fmt_bytes(c.bytes as u64)),
@@ -384,7 +384,7 @@ impl Render for ResourceMonitorDialog {
i18n::t("resmon.working_set"),
Self::fmt_bytes(ws),
Some(i18n::tf(
"峰值 {v}",
"resmon.peak",
&[("v", &Self::fmt_bytes(s.peak_working_set))],
)),
Some(ws as f32 / peak as f32 * 100.),
@@ -442,7 +442,7 @@ impl Render for ResourceMonitorDialog {
.text_xs()
.text_color(muted)
.child(i18n::tf(
"{n} 个连接(已连接 {m})· 打开标签 {t} · 内存消息 {msgs} 条(每连接上限 {cap} 条)· 日志 {logs} 条",
"resmon.stats",
&[
("n", &self.conns.len()),
("m", &connected_count),
@@ -466,12 +466,12 @@ impl Render for ResourceMonitorDialog {
.text_color(muted)
.child(if self.auto_refresh {
i18n::tf(
"版本 v{v} · 每 1.5 秒自动刷新",
"resmon.version_live",
&[("v", &crate::update::current_version())],
)
} else {
i18n::tf(
"版本 v{v} · 自动刷新已暂停",
"resmon.version_paused",
&[("v", &crate::update::current_version())],
)
}),
+9 -9
View File
@@ -213,7 +213,7 @@ impl SettingsDialog {
row(
Button::new("apply-update")
.label(i18n::tf(
"立即更新到 v{v}",
"update.apply",
&[("v", &ver)],
))
.primary()
@@ -223,7 +223,7 @@ impl SettingsDialog {
}))
.into_any_element(),
i18n::tf(
"发现新版本 v{v},下载后自动替换重启",
"update.found",
&[("v", &ver)],
)
.into_any_element(),
@@ -240,7 +240,7 @@ impl SettingsDialog {
.text_xs()
.text_color(cx.theme().accent)
.child(i18n::tf(
"新版本 v{v} 已下载并通过校验,等待安装",
"update.ready",
&[("v", &ver)],
)),
)
@@ -279,7 +279,7 @@ impl SettingsDialog {
this.update = UpdateUi::Idle;
window.push_notification(
Notification::info(i18n::t(
"已暂存,下次启动时将自动安装",
"update.staged",
)),
cx,
);
@@ -313,7 +313,7 @@ impl SettingsDialog {
};
let status = if *total > 0 {
i18n::tf(
"下载中 {pct}%({done} / {total})",
"update.downloading",
&[
("pct", &format!("{:.0}", pct * 100.)),
("done", &Self::fmt_mb(*downloaded)),
@@ -322,7 +322,7 @@ impl SettingsDialog {
)
} else {
i18n::tf(
"下载中 {done}",
"update.downloading_size",
&[("done", &Self::fmt_mb(*downloaded))],
)
};
@@ -426,7 +426,7 @@ impl Render for SettingsDialog {
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(i18n::tf(
"另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐",
"settings.byte_budget",
&[
("n", &format!("{}", MAX_CONNECTION_MESSAGE_BYTES / (1024 * 1024))),
],
@@ -534,7 +534,7 @@ impl Render for SettingsDialog {
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(i18n::tf(
"版本 {v}(更新源 {src})",
"settings.version_line",
&[
("v", &update::current_version()),
("src", &update::UPDATE_SOURCE),
@@ -596,7 +596,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let app_for_save = app_save.clone();
dialog
.w(px(560.))
.title("设置")
.title(i18n::t("titlebar.settings"))
.child(body)
.footer(
h_flex()
+13 -7
View File
@@ -250,20 +250,26 @@ mod tests {
}
}
/// 手写扫描(避免引入 regex 依赖):找 `::t("` / `::tf("` 后的字面量。
/// 两个针互不误伤:`::t("` 要求引号紧跟 t,不会命中 `::tf("`。
/// 手写扫描(避免引入 regex 依赖):找 `::t(` / `::tf(` 后的字面量。
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法
/// `i18n::tf(\n "key",\n &[...]`;`::t(` 针不会命中 `::tf(`
/// (f 不是空白,也到不了引号分支)。
fn extract_t_literals(src: &str) -> Vec<String> {
let b = src.as_bytes();
let mut out = Vec::new();
for needle in [b"::tf(\"".as_slice(), b"::t(\"".as_slice()] {
for needle in [b"::tf(".as_slice(), b"::t(".as_slice()] {
let mut i = 0;
while let Some(rel) = find(b, i, needle) {
let start = rel + needle.len();
let (lit, end) = read_string(b, start);
if let Some(lit) = lit {
let mut start = rel + needle.len();
while start < b.len() && b[start].is_ascii_whitespace() {
start += 1;
}
if start < b.len() && b[start] == b'"'
&& let Some(lit) = read_string(b, start).0
{
out.push(lit);
}
i = end.max(i + 1);
i = rel + needle.len();
}
}
out