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
+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,