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:
@@ -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("非文本"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +339,7 @@ mod tests {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
preview: Arc::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user