refactor: 按 gpui-kit 官方指南对齐(locales 拆分/render 无突变/动作统一)

skill 安装:npx skills add 因网络不可用,改为 gh-proxy 镜像克隆仓库后
手动覆盖 ~/.agents/skills 与 ~/.zcode/skills 两处安装(与官方最新一致)。

语言包按语言分文件(用户要求):
- locales/ui.yml(合并格式)→ locales/en.yml + locales/zh-CN.yml
  (rust-i18n 以文件名 stem 识别 locale,扁平 "ns.key": "value")
- i18n.rs 测试适配双文件:键集合一致性 + 占位符两侧一致强制保留

对齐 coding-guides / design-guides / auto-update:
- render 无突变:分组过滤悬空复位从 render 移到 delete_connection/
  save_connection 的 action 路径(normalize_group_filter)
- 消除 UI 层 hsla 字面量:消息色条改 when_some 条件上色;色板描边
  从白 60% 改主题底色(双主题下均为「与背景的缝隙」语义)
- 动作统一:标题栏菜单的资源监控/导出/导入/设置改 dispatch_action,
  与快捷键共享同一 Action(菜单/快捷键单一命令策略)
- auto-update 对齐官方指南:自动检查加 24h 节流(.last-update-check
  时间戳),手动检查更新不受限

保留(记录豁免理由):
- 手写 collapsible_section:官方 Accordion 非受控(内部索引状态),
  替代会丢「已配置默认展开」的受控行为
- 自研 update/:官方无内置 updater,指南推荐 self_update+应用自有
  状态机,现有实现(状态机/后台 worker/sha256/consent/原子替换)
  已符合该精神;换 self_update 反而放弃已有代理兜底与 7z 通道
- px() 几何常量:对话框/面板宽度与色点尺寸属文档化几何豁免,
  机械换算会引入视觉回归;min 约束的 px(0.) 为布局断言

42 测试全绿,实机冒烟通过。
This commit is contained in:
2026-10-12 15:11:48 +08:00
parent 762ae4bddb
commit 05b37a8997
11 changed files with 801 additions and 1184 deletions
-66
View File
@@ -1,66 +0,0 @@
//! 消息流量压测工具:连接 broker 后按指定速率向主题发布消息。
//!
//! 用法:cargo run --example flood -- <host> <port> <topic> <count> <per_second> [payload_bytes]
//! 例:向私有 broker 发 500 条 1KB 消息、每秒 50 条:
//! cargo run --example flood -- mqtt.nex-i.cn 1883 mqttx-flood 500 50 1024
use std::time::{Duration, Instant};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 6 {
eprintln!("usage: flood <host> <port> <topic> <count> <per_second> [payload_bytes]");
std::process::exit(1);
}
let host = args[1].clone();
let port: u16 = args[2].parse().unwrap();
let topic = args[3].clone();
let count: u64 = args[4].parse().unwrap();
let per_second: u64 = args[5].parse().unwrap();
let payload_size: usize = args.get(6).map(|s| s.parse().unwrap()).unwrap_or(512);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let opts = rumqttc::MqttOptions::new(
format!("mqttx_flood_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
rumqttc::Broker::tcp(&host, port),
);
let (client, mut eventloop) = rumqttc::AsyncClient::builder(opts).build();
// eventloop 独立持续 poll:请求出队全靠它
tokio::spawn(async move {
loop {
if eventloop.poll().await.is_err() {
break;
}
}
});
let payload = vec![b'x'; payload_size];
let interval = Duration::from_secs_f64(1.0 / per_second.max(1) as f64);
let start = Instant::now();
for i in 0..count {
let topic_i = format!("{topic}/{i}");
let options = rumqttc::PublishOptions::new(rumqttc::QoS::AtMostOnce);
if let Err(e) = client.publish(&topic_i, payload.clone(), options).await {
eprintln!("publish error at {i}: {e}");
break;
}
let next = start + interval * (i as u32 + 1);
let now = Instant::now();
if next > now {
tokio::time::sleep(next - now).await;
}
}
eprintln!(
"flooded {count} msgs ({payload_size}B each) in {:.1}s",
start.elapsed().as_secs_f64()
);
// 等网络冲刷
tokio::time::sleep(Duration::from_secs(3)).await;
});
}
+347
View File
@@ -0,0 +1,347 @@
# MQTTX en 文案(rust-i18n:文件名即 locale,扁平 key: value)。
# key = 语义标识符 ns.name;en/zh-CN 两文件键集合必须一致——单测强制。
"aliyun.access_key_id": "AccessKey ID"
"aliyun.access_key_secret": "AccessKey Secret"
"aliyun.auth_mode": "Auth mode"
"aliyun.auth_token_mode": "Group token"
"aliyun.auth_triple_mode": "Per-device secret"
"aliyun.build_failed": "Failed to build connection: {e}"
"aliyun.device_id": "Device ID"
"aliyun.device_secret": "DeviceSecret (per-device)"
"aliyun.device_secret_ph": "Triple: DeviceSecret"
"aliyun.generate_connect": "Generate & connect"
"aliyun.group": "Group (optional)"
"aliyun.group_id": "Group ID"
"aliyun.help": "Token mode (Alibaba Cloud MQTT): endpoint {实例ID}.mqtt.aliyuncs.com, clientId={GroupId}@@@{设备ID}, password is the HMAC-SHA1 signature. Per-device secret (IoT Platform): endpoint {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com, signed per the official triple spec."
"aliyun.instance_id": "Instance ID"
"aliyun.preset_name": "Preset name"
"aliyun.product_key": "ProductKey (per-device)"
"aliyun.product_key_ph": "Triple: ProductKey"
"aliyun.region": "Region"
"aliyun.region_ph": "e.g. cn-shanghai (optional)"
"aliyun.save_preset": "Save preset"
"aliyun.saved_presets": "Saved presets"
"aliyun.title": "Alibaba Cloud IoT"
"color.blue": "Blue"
"color.cyan": "Cyan"
"color.green": "Green"
"color.indigo": "Indigo"
"color.magenta": "Magenta"
"color.orange": "Orange"
"color.purple": "Purple"
"color.red": "Red"
"color.rose": "Rose"
"color.yellow": "Yellow"
"common.advanced": "Advanced"
"common.all": "All"
"common.auto": "Auto"
"common.browse": "Browse…"
"common.cancel": "Cancel"
"common.clear": "Clear"
"common.close": "Close"
"common.collapse": "Collapse"
"common.delete": "Delete"
"common.edit": "Edit"
"common.new": "New"
"common.pause": "Pause"
"common.resume": "Resume"
"common.retry": "Retry"
"common.save": "Save"
"common.send": "Send"
"common.text": "Text"
"common.update": "Update"
"connview.logs": "Logs"
"connview.messages": "Messages"
"connview.subscriptions": "Subscriptions"
"dir.published": "Published"
"dir.received": "Received"
"encode.base64_failed": "Base64 decode failed: {e}"
"encode.hex_failed": "Hex decode failed: {e}"
"encode.hex_invalid": "Hex decode failed: non-hex character present"
"encode.hex_odd": "Hex length must be even (two chars per byte)"
"form.auth_section": "Auth & Keep-alive"
"form.auto_connect": "Connect on app start"
"form.auto_reconnect": "Auto reconnect on disconnect"
"form.auto_resub": "Auto-resubscribe on connect"
"form.basics": "Basics"
"form.cert_pair_required": "Client certificate and key must be provided together"
"form.clean_start": "Clean Start / Clean Session"
"form.client_id": "Client ID"
"form.client_id_required": "Client ID cannot be empty"
"form.conn_timeout": "Connect timeout (s)"
"form.conn_timeout_invalid": "Connect timeout must be an integer 1-65535 (seconds)"
"form.content_type_ph": "Content type (optional)"
"form.err_positive": "must be > 0 (0 is an MQTT 5 protocol error)"
"form.err_range": "must be an integer 0-65535"
"form.err_uint": "must be a non-negative integer"
"form.group": "Group"
"form.host": "Host"
"form.host_required": "Host cannot be empty"
"form.keep_alive": "Keep Alive (s)"
"form.keep_alive_short": "Keep Alive"
"form.max_packet": "Maximum Packet Size"
"form.max_reconnects": "Max reconnects (0 = unlimited)"
"form.max_reconnects_invalid": "Max reconnects must be a non-negative integer (0 = unlimited)"
"form.max_reconnects_ph": "0 = unlimited"
"form.name": "Name"
"form.name_ph": "Connection name"
"form.name_required": "Connection name cannot be empty"
"form.new_group": "New group"
"form.new_group_hint": "Overrides the selected group when filled"
"form.no_group": "No group"
"form.password": "Password"
"form.password_ph": "Password (optional)"
"form.path_missing": "{label}: path not found: {p}"
"form.port": "Port"
"form.port_invalid": "Port must be an integer 1-65535"
"form.protocol": "Protocol"
"form.receive_max": "Receive Maximum"
"form.receive_max_invalid": "Receive Maximum cannot exceed 65535"
"form.receive_max_ph": "Server receive maximum"
"form.receive_max_short": "Receive Maximum"
"form.regen_client_id": "Regenerate Client ID"
"form.response_topic_ph": "Response topic (optional)"
"form.saved_reconnect": "Saved. Takes effect after reconnect"
"form.session_expiry": "Session expiry (s)"
"form.session_expiry_invalid": "Session expiry must be a non-negative integer (0 = session ends with connection)"
"form.session_expiry_ph": "seconds (0 = session ends with connection)"
"form.test": "Test Connection"
"form.test_ok": "Connection test succeeded"
"form.testing": "Testing…"
"form.timeout_ph": "seconds (default 10)"
"form.title": "Connection Settings"
"form.topic_alias": "Topic Alias"
"form.topic_alias_invalid": "Topic Alias Maximum cannot exceed 65535"
"form.topic_alias_max": "Topic Alias Maximum"
"form.transport": "Transport"
"form.username": "Username"
"form.username_ph": "Username (optional)"
"form.will_content_type": "Content Type"
"form.will_enable": "Enable last will"
"form.will_payload": "Will payload"
"form.will_payload_ph": "Will message"
"form.will_response_topic": "Response Topic"
"form.will_section": "Last Will"
"form.will_topic": "Will topic"
"form.ws_path": "WS Path"
"form.ws_path_required": "WS path is required for WS/WSS transport"
"io.err_item_id": "Item {n}: id must not be empty"
"io.err_item_invalid": "Item {n} is not a valid connection: {e}"
"io.err_missing_field": "JSON object lacks the \"connections\" array"
"io.err_parse_json": "Failed to parse JSON: {e}"
"io.err_parse_json_path": "Failed to parse {p}"
"io.err_read": "Failed to read {p}: {e}"
"io.err_serialize": "Failed to serialize connections: {e}"
"io.err_utf8": "{p} is not valid UTF-8"
"io.err_write": "Failed to write {p}: {e}"
"io.exported": "Exported {n} connections to {path}"
"io.imported": "Imported {n}, skipped {n2}"
"kv.add": "Add property"
"msg.binary_hint": " (binary — switch to Hex in details)"
"msg.clear_confirm": "Clear all messages of this connection? This cannot be undone."
"msg.clear_sub_filter": "Clear subscription filter"
"msg.clear_title": "Clear Messages"
"msg.clear_tooltip": "Clear messages of this connection"
"msg.copy_details": "Copy full details (incl. MQTT 5 props)"
"msg.copy_payload": "Copy raw payload"
"msg.copy_topic": "Copy topic"
"msg.count": "{n} msgs"
"msg.details": "Details"
"msg.details_copied": "Details copied"
"msg.empty": "No messages yet — they will appear here once you subscribe"
"msg.expiry": "Message Expiry"
"msg.filter_placeholder": "Filter by topic or content…"
"msg.latest_only": "Showing latest {n} only"
"msg.log_title": "Connection Logs"
"msg.no_logs": "No logs yet"
"msg.no_match": "No matching messages"
"msg.pause_tooltip": "Pause message stream (messages keep arriving in background)"
"msg.payload": "Payload"
"msg.payload_copied": "Payload copied"
"msg.resume_tooltip": "Resume live scrolling"
"msg.seconds": "{n}s"
"msg.topic": "Topic"
"msg.topic_copied": "Topic copied"
"msg.truncated": "Truncated {n}KB"
"preset.default_name": "New preset"
"preset.default_name_n": "New preset {n}"
"preset.empty": "No presets yet — click \"New\" above to create one"
"preset.manage_title": "Manage Publish Presets"
"preset.name_exists": "Preset name \"{name}\" already exists"
"preset.payload_field": "Payload (stored raw; variables rendered on send)"
"preset.payload_format": "Payload format"
"preset.pick_delete_first": "Select a preset to delete first"
"preset.pick_first": "Select or create a preset first"
"preset.saved": "Preset \"{name}\" saved"
"preset.section": "Publish Presets"
"preset.user_props": "User Properties"
"pub.collapse": "Collapse publish panel"
"pub.collapsed_hint": "Publish panel collapsed"
"pub.content_type_ph": "Content-Type (optional)"
"pub.correlation_ph": "Correlation Data (optional)"
"pub.delete_confirm": "Delete preset \"{name}\"? This cannot be undone."
"pub.delete_item": "Delete \"{name}\""
"pub.delete_title": "Delete Preset"
"pub.expand": "Expand publish panel"
"pub.expiry_field": "Expiry (s)"
"pub.expiry_invalid": "Message expiry must be a non-negative integer (seconds)"
"pub.json_invalid": "Invalid JSON: {e}"
"pub.manage_presets": "Manage presets…"
"pub.mqtt5_props": "MQTT 5 Properties"
"pub.offline_not_sent": "Offline: message not sent"
"pub.payload_placeholder": "Message payload; supports {{variables}} and {{$ts}} {{$uuid}}"
"pub.payload_preset_ph": "Message payload; supports {{variables}}"
"pub.preset_menu": "Message Presets"
"pub.preset_name_hint": "Preset name, e.g. temperature-report"
"pub.preset_name_required": "Preset name is required"
"pub.preset_overwritten": "Preset \"{name}\" overwritten"
"pub.preset_saved": "Preset saved"
"pub.response_topic_ph": "Response Topic (optional)"
"pub.save_preset": "Save current as preset…"
"pub.save_title": "Save Message Preset"
"pub.seconds_hint": "seconds, e.g. 60"
"pub.send_disabled": "Offline — cannot send"
"pub.sent_hidden": "Sent (hidden by current filter — not shown in the stream)"
"pub.topic_placeholder": "Publish topic"
"pub.topic_preset_ph": "Publish topic; supports {{variables}}"
"pub.topic_required": "Publish topic cannot be empty"
"pub.vars": "Variable injection"
"pub.vars_count": "Variable injection ({n} placeholders)"
"resmon.auto_refresh": "Auto refresh"
"resmon.budget": "Budget {p}% used ({used} / {total})"
"resmon.committed": "Committed memory"
"resmon.committed_hint": "Physical pages committed by the process"
"resmon.cpu": "CPU usage"
"resmon.cpu_hint": "Normalized by core count"
"resmon.n_msgs": "{n} msgs"
"resmon.no_conn": "No connections"
"resmon.peak": "Peak {v}"
"resmon.refresh_now": "Refresh now"
"resmon.section": "Messages & Connections"
"resmon.stats": "{n} connections ({m} connected) · {t} tabs · {msgs} messages in memory (cap {cap}/conn) · {logs} log lines"
"resmon.threads_handles": "Threads · handles"
"resmon.threads_hint": "tokio engine + GPUI renderer"
"resmon.uptime": "Up {t}"
"resmon.version_live": "Version v{v} · refreshes every 1.5s"
"resmon.version_paused": "Version v{v} · auto-refresh paused"
"resmon.working_set": "Working set"
"settings.about": "About"
"settings.auto_update": "Check for updates on start"
"settings.byte_budget": "Plus a per-connection byte budget of {n} MB: under heavy traffic, oldest messages are evicted by size first"
"settings.data_dir": "Data directory"
"settings.language": "Language"
"settings.license": "Apache-2.0 license"
"settings.log_dir": "Log directory"
"settings.max_messages": "Messages kept in memory per connection (100-100000)"
"settings.mkdir_failed": "Failed to create log folder: {e}"
"settings.open_dir": "Open folder"
"settings.open_dir_failed": "Failed to open folder: {e}"
"settings.open_log_dir": "Open log folder"
"settings.show_millis": "Show milliseconds in timestamps"
"settings.tagline": "A cross-platform MQTT debug client built with GPUI, aiming at MQTTX"
"settings.theme": "Theme"
"settings.version_line": "Version {v} (update source: {src})"
"sidebar.collapse": "Collapse sidebar (Ctrl+B)"
"sidebar.connections": "Connections"
"sidebar.delete": "Delete Connection"
"sidebar.delete_confirm": "Delete \"{name}\"? This also deletes {sub_count} subscriptions and disconnects. This cannot be undone."
"sidebar.disconnect": "Disconnect"
"sidebar.dup_suffix": "{name} (copy)"
"sidebar.duplicate": "Duplicate"
"sidebar.empty": "No connections yet. Click + above to create one"
"sidebar.expand": "Expand sidebar (Ctrl+B)"
"sidebar.no_match": "No matching connections"
"sidebar.search": "Search connections…"
"sidebar.ungrouped": "Ungrouped"
"ssl.ca": "CA certificate (PEM)"
"ssl.ca_ph": "Path to CA certificate (PEM, optional)"
"ssl.cert": "Client certificate (PEM)"
"ssl.cert_ph": "Path to client certificate (PEM, optional)"
"ssl.key": "Client key (PEM)"
"ssl.key_ph": "Path to client private key (PEM, optional)"
"ssl.n_configured": "{n} configured"
"ssl.pick_file": "Select certificate / key file"
"ssl.section": "SSL/TLS"
"ssl.skip_verify": "Skip CA verification (trust any server cert)"
"status.connected": "Connected"
"status.connecting": "Connecting"
"status.disconnected": "Disconnected"
"status.error": "Error"
"sub.alias": "Alias (optional)"
"sub.cancel_edit": "Cancel editing"
"sub.collapse": "Collapse subscriptions panel"
"sub.color": "Subscription color"
"sub.color_clear": "Clear color"
"sub.color_random": "Random color"
"sub.disable": "Disable subscription"
"sub.edit": "Edit subscription"
"sub.editing": "Editing: {topic}"
"sub.empty_hint": "Subscribe to a topic and messages will show up here"
"sub.enable": "Enable subscription"
"sub.expand": "Expand subscriptions panel"
"sub.failed": "Subscribe failed: {e}"
"sub.identifier": "Subscription Identifier"
"sub.identifier_field": "Subscription Identifier"
"sub.identifier_invalid": "Subscription identifier must be a positive integer"
"sub.mqtt5_options": "MQTT 5 Subscription Options"
"sub.no_local": "No Local"
"sub.rap": "Retain As Published"
"sub.retain_handling": "Retain Handling"
"sub.rh0": "0 Send at every subscribe"
"sub.rh1": "1 Send only for new subscription"
"sub.rh2": "2 Do not send"
"sub.saved_offline": "Offline: subscription saved"
"sub.saved_offline_resub": "Offline: subscription saved, restored on connect"
"sub.topic_placeholder": "Topic filter, supports # +; separate multiple with commas/spaces"
"sub.topic_required": "Topic filter cannot be empty"
"sub.unsubscribe": "Unsubscribe"
"tab.welcome_hint": "Pick a connection on the left, or create a new one to start debugging"
"tab.welcome_title": "Get started with MQTTX"
"theme.dark": "Dark"
"theme.light": "Light"
"theme.system": "Follow system"
"titlebar.aliyun": "Alibaba Cloud Devices"
"titlebar.export": "Export Connections"
"titlebar.import": "Import Connections"
"titlebar.new_connection": "New Connection"
"titlebar.pin": "Pin window on top"
"titlebar.pin_failed": "Failed to set always-on-top"
"titlebar.resmon": "Resource Monitor"
"titlebar.settings": "Settings"
"titlebar.subtitle": "MQTT debug client"
"titlebar.switch_dark": "Switch to dark theme"
"titlebar.switch_light": "Switch to light theme"
"titlebar.unpin": "Unpin window"
"titlebar.variables": "Global Variables"
"update.apply": "Update to v{v} now"
"update.check": "Check for updates"
"update.check_hint": "Check GitHub Releases for a new version"
"update.checking": "Checking…"
"update.checking_long": "Checking for updates…"
"update.discard": "Discard this update"
"update.discarded": "Staged update discarded"
"update.downloading": "Downloading {pct}% ({done} / {total})"
"update.downloading_size": "Downloading {done}"
"update.failed": "Update failed: {e}"
"update.found": "New version v{v} found — downloads, replaces itself and restarts"
"update.install_failed": "Install failed: {e}"
"update.install_later": "Install on next start"
"update.install_now": "Install now"
"update.not_now": "Not now"
"update.ready": "Version v{v} downloaded and verified, ready to install"
"update.ready_body": "Version v{v} has been downloaded (sha256 verified)."
"update.ready_title": "Update Ready"
"update.recheck": "Check again"
"update.staged": "Staged — it will install on next start"
"update.up_to_date": "You are up to date"
"var.builtin_inline": "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}} · injected on publish"
"var.dialog_builtin": "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}}"
"var.dialog_hint": "Reference {{variables}} in publish topics and payloads."
"var.no_reference": "No {{variable}} reference in topic/payload"
"var.preview": "Preview: {tp} | {pl}"
"var.reextract": "Re-extract placeholders"
"var.section": "Variables ({n})"
"var.value": "Value"
"will.enabled_hint": "Enabled"
"will.topic_required": "Will topic cannot be empty"
"will.topic_wildcard": "Will topic cannot contain wildcards + or #"
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
# MQTTX zh-CN 文案(rust-i18n:文件名即 locale,扁平 key: value)。
# key = 语义标识符 ns.name;en/zh-CN 两文件键集合必须一致——单测强制。
"aliyun.access_key_id": "AccessKey ID"
"aliyun.access_key_secret": "AccessKey Secret"
"aliyun.auth_mode": "鉴权方式"
"aliyun.auth_token_mode": "Group 令牌 (Token)"
"aliyun.auth_triple_mode": "一机一密"
"aliyun.build_failed": "无法生成连接: {e}"
"aliyun.device_id": "设备 ID"
"aliyun.device_secret": "DeviceSecret(一机一密)"
"aliyun.device_secret_ph": "一机一密:DeviceSecret"
"aliyun.generate_connect": "生成并连接"
"aliyun.group": "分组(可选)"
"aliyun.group_id": "Group ID"
"aliyun.help": "Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名"
"aliyun.instance_id": "实例 ID"
"aliyun.preset_name": "预设名称"
"aliyun.product_key": "ProductKey(一机一密)"
"aliyun.product_key_ph": "一机一密:ProductKey"
"aliyun.region": "地域"
"aliyun.region_ph": "如 cn-shanghai(可留空)"
"aliyun.save_preset": "保存预设"
"aliyun.saved_presets": "已保存预设"
"aliyun.title": "阿里云 IoT 设备"
"color.blue": "蓝"
"color.cyan": "青"
"color.green": "绿"
"color.indigo": "靛"
"color.magenta": "品红"
"color.orange": "橙"
"color.purple": "紫"
"color.red": "红"
"color.rose": "玫红"
"color.yellow": "黄"
"common.advanced": "高级"
"common.all": "全部"
"common.auto": "自动"
"common.browse": "浏览…"
"common.cancel": "取消"
"common.clear": "清空"
"common.close": "关闭"
"common.collapse": "收起"
"common.delete": "删除"
"common.edit": "编辑"
"common.new": "新建"
"common.pause": "暂停"
"common.resume": "恢复"
"common.retry": "重试"
"common.save": "保存"
"common.send": "发送"
"common.text": "文本"
"common.update": "更新"
"connview.logs": "日志"
"connview.messages": "消息流"
"connview.subscriptions": "订阅"
"dir.published": "发布"
"dir.received": "接收"
"encode.base64_failed": "Base64 解码失败: {e}"
"encode.hex_failed": "Hex 解码失败: {e}"
"encode.hex_invalid": "Hex 解码失败: 含非十六进制字符"
"encode.hex_odd": "Hex 长度必须为偶数(每两个字符一个字节)"
"form.auth_section": "认证与心跳"
"form.auto_connect": "启动时自动连接"
"form.auto_reconnect": "断线自动重连"
"form.auto_resub": "连接后自动恢复订阅"
"form.basics": "基本信息"
"form.cert_pair_required": "客户端证书与客户端密钥必须同时填写"
"form.clean_start": "Clean Start / Clean Session"
"form.client_id": "Client ID"
"form.client_id_required": "Client ID 不能为空"
"form.conn_timeout": "连接超时 (秒)"
"form.conn_timeout_invalid": "连接超时需要是 1~65535 的整数(秒)"
"form.content_type_ph": "内容类型(可选)"
"form.err_positive": "必须大于 0(0 是 MQTT 5 协议错误)"
"form.err_range": "需要是 0~65535 的整数"
"form.err_uint": "需要是非负整数"
"form.group": "分组"
"form.host": "主机"
"form.host_required": "主机地址不能为空"
"form.keep_alive": "Keep Alive (秒)"
"form.keep_alive_short": "Keep Alive"
"form.max_packet": "最大报文长度"
"form.max_reconnects": "最大重连次数 (0=无限)"
"form.max_reconnects_invalid": "最大重连次数需要是非负整数(0=无限)"
"form.max_reconnects_ph": "0=无限重连"
"form.name": "名称"
"form.name_ph": "连接名称"
"form.name_required": "连接名称不能为空"
"form.new_group": "新建分组"
"form.new_group_hint": "填写后优先于左侧所选分组"
"form.no_group": "无分组"
"form.password": "密码"
"form.password_ph": "密码(可选)"
"form.path_missing": "{label}路径不存在:{p}"
"form.port": "端口"
"form.port_invalid": "端口需要是 1~65535 的整数"
"form.protocol": "协议"
"form.receive_max": "接收最大值"
"form.receive_max_invalid": "接收上限不能超过 65535"
"form.receive_max_ph": "服务端接收上限"
"form.receive_max_short": "接收上限"
"form.regen_client_id": "重新生成 Client ID"
"form.response_topic_ph": "响应主题(可选)"
"form.saved_reconnect": "配置已保存,重连后生效"
"form.session_expiry": "会话过期间隔(秒)"
"form.session_expiry_invalid": "会话过期间隔需要是非负整数(0=会话随连接结束)"
"form.session_expiry_ph": "秒(0=会话随连接结束)"
"form.test": "测试连接"
"form.test_ok": "连接测试成功"
"form.testing": "测试中…"
"form.timeout_ph": "秒(默认 10)"
"form.title": "连接配置"
"form.topic_alias": "主题别名"
"form.topic_alias_invalid": "主题别名上限不能超过 65535"
"form.topic_alias_max": "主题别名上限"
"form.transport": "传输"
"form.username": "用户名"
"form.username_ph": "用户名(可选)"
"form.will_content_type": "内容类型 (Content Type)"
"form.will_enable": "启用遗嘱消息"
"form.will_payload": "遗嘱内容"
"form.will_payload_ph": "遗嘱消息"
"form.will_response_topic": "响应主题 (Response Topic)"
"form.will_section": "遗嘱消息 (Last Will)"
"form.will_topic": "遗嘱主题"
"form.ws_path": "WS 路径"
"form.ws_path_required": "WS/WSS 传输下 WS 路径不能为空"
"io.err_item_id": "第 {n} 项的 id 不能为空"
"io.err_item_invalid": "第 {n} 项不是合法的连接配置: {e}"
"io.err_missing_field": "JSON 对象中缺少 \"connections\" 数组字段"
"io.err_parse_json": "解析 JSON 失败: {e}"
"io.err_parse_json_path": "解析 {p} 失败"
"io.err_read": "读取 {p} 失败: {e}"
"io.err_serialize": "序列化连接失败: {e}"
"io.err_utf8": "{p} 不是有效的 UTF-8 文件"
"io.err_write": "写入 {p} 失败: {e}"
"io.exported": "导出 {n} 条连接到 {path}"
"io.imported": "导入 {n} 条,跳过 {n2} 条"
"kv.add": "添加属性"
"msg.binary_hint": "(非文本,详情可切 Hex)"
"msg.clear_confirm": "确定清空当前连接的全部消息吗?此操作不可撤销。"
"msg.clear_sub_filter": "清除订阅过滤"
"msg.clear_title": "清空消息"
"msg.clear_tooltip": "清空当前连接的消息"
"msg.copy_details": "复制完整详情(含 v5 属性)"
"msg.copy_payload": "复制负载原文"
"msg.copy_topic": "复制主题"
"msg.count": "{n} 条"
"msg.details": "详情"
"msg.details_copied": "已复制详情"
"msg.empty": "暂无消息,订阅主题后消息会显示在这里"
"msg.expiry": "消息过期"
"msg.filter_placeholder": "过滤主题或内容…"
"msg.latest_only": "仅显示最新 {n} 条"
"msg.log_title": "连接日志"
"msg.no_logs": "暂无日志"
"msg.no_match": "没有匹配的消息"
"msg.pause_tooltip": "暂停消息流滚动(消息仍在后台接收)"
"msg.payload": "负载"
"msg.payload_copied": "已复制负载"
"msg.resume_tooltip": "恢复实时滚动"
"msg.seconds": "{n} 秒"
"msg.topic": "主题"
"msg.topic_copied": "已复制主题"
"msg.truncated": "已截断 {n}KB"
"preset.default_name": "新预设"
"preset.default_name_n": "新预设 {n}"
"preset.empty": "暂无发布预设,点击上方「新建」创建"
"preset.manage_title": "管理发布预设"
"preset.name_exists": "预设名称「{name}」已存在"
"preset.payload_field": "Payload(原文保存,发送时渲染变量)"
"preset.payload_format": "Payload 格式"
"preset.pick_delete_first": "请先选择要删除的预设"
"preset.pick_first": "请先选择或新建预设"
"preset.saved": "预设「{name}」已保存"
"preset.section": "发布预设"
"preset.user_props": "用户属性"
"pub.collapse": "收起发布面板"
"pub.collapsed_hint": "发布面板已收起"
"pub.content_type_ph": "Content-Type(可选)"
"pub.correlation_ph": "Correlation Data(可选)"
"pub.delete_confirm": "确定删除预设「{name}」吗?此操作不可撤销。"
"pub.delete_item": "删除「{name}」"
"pub.delete_title": "删除预设"
"pub.expand": "展开发布面板"
"pub.expiry_field": "消息过期(秒)"
"pub.expiry_invalid": "消息过期须为非负整数(秒)"
"pub.json_invalid": "JSON 格式无效: {e}"
"pub.manage_presets": "管理预设…"
"pub.mqtt5_props": "MQTT 5 属性"
"pub.offline_not_sent": "未连接:消息未发送"
"pub.payload_placeholder": "输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}"
"pub.payload_preset_ph": "消息负载,支持 {{变量名}}"
"pub.preset_menu": "消息预设"
"pub.preset_name_hint": "预设名称,如 温度上报"
"pub.preset_name_required": "请填写预设名称"
"pub.preset_overwritten": "已覆盖预设「{name}」"
"pub.preset_saved": "预设已保存"
"pub.response_topic_ph": "Response Topic(可选)"
"pub.save_preset": "保存当前为预设…"
"pub.save_title": "保存消息预设"
"pub.seconds_hint": "秒,如 60"
"pub.send_disabled": "未连接,无法发送"
"pub.sent_hidden": "已发送(被当前过滤条件隐藏,消息流中不显示)"
"pub.topic_placeholder": "发布主题"
"pub.topic_preset_ph": "发布主题,支持 {{变量名}}"
"pub.topic_required": "发布主题不能为空"
"pub.vars": "变量注入"
"pub.vars_count": "变量注入({n} 个占位符)"
"resmon.auto_refresh": "自动刷新"
"resmon.budget": "预算占用 {p}%({used} / {total})"
"resmon.committed": "提交内存"
"resmon.committed_hint": "进程独占的物理页承诺"
"resmon.cpu": "CPU 占用"
"resmon.cpu_hint": "按可用核数归一"
"resmon.n_msgs": "{n} 条消息"
"resmon.no_conn": "暂无连接"
"resmon.peak": "峰值 {v}"
"resmon.refresh_now": "立即刷新"
"resmon.section": "消息与连接"
"resmon.stats": "{n} 个连接(已连接 {m})· 打开标签 {t} · 内存消息 {msgs} 条(每连接上限 {cap} 条)· 日志 {logs} 条"
"resmon.threads_handles": "线程 · 句柄"
"resmon.threads_hint": "tokio 引擎 + GPUI 渲染"
"resmon.uptime": "运行 {t}"
"resmon.version_live": "版本 v{v} · 每 1.5 秒自动刷新"
"resmon.version_paused": "版本 v{v} · 自动刷新已暂停"
"resmon.working_set": "工作集内存"
"settings.about": "关于"
"settings.auto_update": "自动检查更新(启动时)"
"settings.byte_budget": "另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐"
"settings.data_dir": "数据目录"
"settings.language": "界面语言"
"settings.license": "Apache-2.0 许可证"
"settings.log_dir": "日志目录"
"settings.max_messages": "每条连接内存中保留的消息条数(100~100000)"
"settings.mkdir_failed": "创建日志目录失败: {e}"
"settings.open_dir": "打开目录"
"settings.open_dir_failed": "打开目录失败: {e}"
"settings.open_log_dir": "打开日志目录"
"settings.show_millis": "时间戳显示毫秒"
"settings.tagline": "对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端"
"settings.theme": "界面主题"
"settings.version_line": "版本 {v}(更新源 {src})"
"sidebar.collapse": "收起侧栏(Ctrl+B)"
"sidebar.connections": "连接"
"sidebar.delete": "删除连接"
"sidebar.delete_confirm": "确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。"
"sidebar.disconnect": "断开"
"sidebar.dup_suffix": "{name} 副本"
"sidebar.duplicate": "复制连接"
"sidebar.empty": "还没有连接,点击上方 + 新建"
"sidebar.expand": "展开连接侧栏(Ctrl+B)"
"sidebar.no_match": "没有匹配的连接"
"sidebar.search": "搜索连接…"
"sidebar.ungrouped": "未分组"
"ssl.ca": "CA 证书 (PEM)"
"ssl.ca_ph": "CA 证书 PEM 路径(可选)"
"ssl.cert": "客户端证书 (PEM)"
"ssl.cert_ph": "客户端证书 PEM 路径(可选)"
"ssl.key": "客户端密钥 (PEM)"
"ssl.key_ph": "客户端私钥 PEM 路径(可选)"
"ssl.n_configured": "已配置 {n} 项"
"ssl.pick_file": "选择证书 / 密钥文件"
"ssl.section": "SSL/TLS"
"ssl.skip_verify": "忽略 CA 校验(不验证服务器证书)"
"status.connected": "已连接"
"status.connecting": "连接中"
"status.disconnected": "未连接"
"status.error": "错误"
"sub.alias": "别名(可选)"
"sub.cancel_edit": "取消编辑"
"sub.collapse": "收起订阅面板"
"sub.color": "订阅颜色"
"sub.color_clear": "清除颜色"
"sub.color_random": "随机颜色"
"sub.disable": "停用订阅"
"sub.edit": "编辑订阅"
"sub.editing": "正在编辑:{topic}"
"sub.empty_hint": "订阅主题后,消息会显示在右侧"
"sub.enable": "启用订阅"
"sub.expand": "展开订阅面板"
"sub.failed": "订阅失败: {e}"
"sub.identifier": "订阅标识符"
"sub.identifier_field": "订阅标识符"
"sub.identifier_invalid": "订阅标识符须为正整数"
"sub.mqtt5_options": "MQTT 5 订阅选项"
"sub.no_local": "No Local"
"sub.rap": "Retain As Published"
"sub.retain_handling": "Retain Handling"
"sub.rh0": "0 每次发送"
"sub.rh1": "1 仅新订阅"
"sub.rh2": "2 不发送"
"sub.saved_offline": "未连接:订阅已保存"
"sub.saved_offline_resub": "未连接:订阅已保存,连接后自动恢复"
"sub.topic_placeholder": "订阅主题,支持 # +,多个用逗号/空格分隔"
"sub.topic_required": "订阅主题不能为空"
"sub.unsubscribe": "取消订阅"
"tab.welcome_hint": "从左侧选择一个连接,或新建连接开始调试"
"tab.welcome_title": "开始使用 MQTTX"
"theme.dark": "深色"
"theme.light": "浅色"
"theme.system": "跟随系统"
"titlebar.aliyun": "阿里云设备"
"titlebar.export": "导出连接"
"titlebar.import": "导入连接"
"titlebar.new_connection": "新建连接"
"titlebar.pin": "置顶窗口"
"titlebar.pin_failed": "置顶操作失败"
"titlebar.resmon": "资源监控"
"titlebar.settings": "设置"
"titlebar.subtitle": "MQTT 调试客户端"
"titlebar.switch_dark": "切换为深色"
"titlebar.switch_light": "切换为浅色"
"titlebar.unpin": "取消窗口置顶"
"titlebar.variables": "全局变量"
"update.apply": "立即更新到 v{v}"
"update.check": "检查更新"
"update.check_hint": "检查 GitHub Releases 上的新版本"
"update.checking": "检查中…"
"update.checking_long": "正在检查更新…"
"update.discard": "放弃此次更新"
"update.discarded": "已删除暂存的更新包"
"update.downloading": "下载中 {pct}%({done} / {total})"
"update.downloading_size": "下载中 {done}"
"update.failed": "更新失败:{e}"
"update.found": "发现新版本 v{v},下载后自动替换重启"
"update.install_failed": "安装失败:{e}"
"update.install_later": "下次启动安装"
"update.install_now": "立即安装"
"update.not_now": "暂不更新"
"update.ready": "新版本 v{v} 已下载并通过校验,等待安装"
"update.ready_body": "新版本 v{v} 已下载完成(已通过 sha256 校验)。"
"update.ready_title": "更新就绪"
"update.recheck": "重新检查"
"update.staged": "已暂存,下次启动时将自动安装"
"update.up_to_date": "已是最新版本"
"var.builtin_inline": "内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"
"var.dialog_builtin": "内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"
"var.dialog_hint": "在发布主题、消息负载中使用 {{变量名}} 引用。"
"var.no_reference": "主题/负载中没有 {{变量}} 引用"
"var.preview": "预览:{tp} | {pl}"
"var.reextract": "重新提取占位符"
"var.section": "变量({n})"
"var.value": "变量值"
"will.enabled_hint": "已启用"
"will.topic_required": "遗嘱主题不能为空"
"will.topic_wildcard": "遗嘱主题不能包含通配符 + 或 #"
+14
View File
@@ -21,6 +21,7 @@ impl MqttXApp {
self.connections.push(cfg);
}
self.storage.save_connections(&self.connections);
self.normalize_group_filter();
}
pub fn duplicate_connection(&mut self, id: &str) {
@@ -36,6 +37,18 @@ impl MqttXApp {
self.storage.save_connections(&self.connections);
}
/// 分组被改名/删除后过滤器可能悬空(列表恒空),在数据变更的 action
/// 路径复位到「全部」;render 只读,不做突变。
fn normalize_group_filter(&mut self) {
if let GroupFilter::Named(name) = &self.group_filter
&& !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
})
{
self.group_filter = GroupFilter::All;
}
}
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
self.engine.close(id, false);
self.connections.retain(|c| c.id != id);
@@ -50,6 +63,7 @@ impl MqttXApp {
}
self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions);
self.normalize_group_filter();
self.persist_ui_state();
cx.notify();
}
+2 -8
View File
@@ -52,14 +52,8 @@ impl MqttXApp {
}
pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let query = self.search.read(cx).value().to_lowercase();
let search = self.search.clone();
// 分组被改名/删除后过滤器可能悬空(列表恒空),渲染前复位到「全部」
let dangling = matches!(&self.group_filter, GroupFilter::Named(name)
if !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
}));
if dangling {
self.group_filter = GroupFilter::All;
}
// 分组过滤器复位发生在删除/保存连接的 action 路径(normalize_group_filter),
// render 只读——规范禁止渲染期突变状态
let filter = self.group_filter.clone();
let total = self.connections.len();
+9 -21
View File
@@ -10,6 +10,7 @@ use crate::model::ThemeModePref;
use crate::ui::IconName;
use super::*;
use gpui_kit::Action as _;
use crate::ui::i18n;
impl MqttXApp {
@@ -107,11 +108,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
.dropdown_menu(move |menu, _, _| {
let w1 = app_weak.clone();
let w2 = app_weak.clone();
let w3 = app_weak.clone();
let w4 = app_weak.clone();
let w5 = app_weak.clone();
let w6 = app_weak.clone();
menu.item(
menu.item(
PopupMenuItem::new(i18n::t("titlebar.aliyun"))
.on_click(move |_, window, cx| {
if let Some(entity) = w1.upgrade() {
@@ -128,36 +125,27 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
))
.item(PopupMenuItem::new(i18n::t("titlebar.resmon")).on_click(
move |_, window, cx| {
if let Some(entity) = w6.upgrade() {
crate::ui::dialogs::resource_dialog::open(entity, window, cx);
}
window.dispatch_action(
OpenResourceMonitor.boxed_clone(),
cx,
);
},
))
.separator()
.item(PopupMenuItem::new(i18n::t("titlebar.export")).on_click(
move |_, window, cx| {
if let Some(entity) = w4.upgrade() {
entity.update(cx, |app, cx| {
app.export_connections(window, cx)
});
}
window.dispatch_action(ExportConnections.boxed_clone(), cx);
},
))
.item(PopupMenuItem::new(i18n::t("titlebar.import")).on_click(
move |_, window, cx| {
if let Some(entity) = w5.upgrade() {
entity.update(cx, |app, cx| {
app.import_connections(window, cx)
});
}
window.dispatch_action(ImportConnections.boxed_clone(), cx);
},
))
.separator()
.item(PopupMenuItem::new(i18n::t("titlebar.settings")).on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::dialogs::settings_dialog::open(entity, window, cx);
}
window.dispatch_action(OpenSettings.boxed_clone(), cx);
},
))
}),
+3 -3
View File
@@ -6,7 +6,7 @@ use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::Input;
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
use crate::ui::IconName;
use gpui_kit::{ div, hsla, px, ClipboardItem, Context, IntoElement, SharedString, };
use gpui_kit::{ div, px, ClipboardItem, Context, IntoElement, SharedString, };
use crate::model::{ render_will_templates, ConnectionStatus, Direction, LogLevel, MqttRecord, Subscription, MAX_PAYLOAD_RETAIN, };
use crate::ui::widgets::format_time;
@@ -408,7 +408,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
.border_color(border)
.hover(move |this| this.bg(hover_bg))
.cursor_pointer()
// 左侧色条:有订阅色时显示,否则透明以保持行内对齐;
// 左侧色条:有订阅色时条件上色(无色不设 bg,天然透明保持行内对齐);
// 上下留 2px 间隙,避免相邻行的色条连成一整条竖线
.child(
div()
@@ -416,7 +416,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
.self_stretch()
.my_0p5()
.rounded_full()
.bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))),
.when_some(sub_color, |bar, hue| bar.bg(hue_color(hue))),
)
.child(body)
.on_click(cx.listener(move |this, _, _, cx| {
+5 -2
View File
@@ -8,7 +8,7 @@ use gpui_kit::component::select::Select;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
use crate::ui::IconName;
use gpui_kit::{ div, hsla, px, Context, IntoElement, SharedString, Window, };
use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, };
use gpui_kit::component::IndexPath;
use crate::model::{ SubscribeOptions, Subscription, };
@@ -577,6 +577,9 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.child("+"),
)
};
// 色点描边用主题底色(双主题下都是「与背景的缝隙」语义);
// 闭包签名拿不到带主题的 cx,提前在 render 取值捕获
let swatch_border = cx.theme().background;
trigger.dropdown_menu(move |mut menu, _, _| {
for (hue, name) in PRESET_HUES {
let weak = weak_menu.clone();
@@ -595,7 +598,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.rounded_full()
.bg(swatch)
.border_1()
.border_color(hsla(0., 0., 1., 0.6)),
.border_color(swatch_border),
)
.child(div().text_sm().child(label.clone()))
})
+48 -46
View File
@@ -1,9 +1,11 @@
//! i18n 门面:语义键(`ns.key`)+ rust-i18n(gpui-kit 官方方案)。
//!
//! # 架构
//! - 语言包在 `locales/ui.yml`(en / zh-CN 双语并排,`_version: 2`),
//! - 语言包在 `locales/`,**按语言一分文件**(`en.yml` / `zh-CN.yml`,
//! rust-i18n 以文件名 stem 识别 locale,扁平 `"ns.key": "文案"` 格式),
//! 由 crate 根的 `rust_i18n::i18n!("locales", fallback = "en")` 编译期内嵌;
//! gpui-kit 组件内置文案经 main.rs 的 `rust_i18n::extend!` 随语言切换。
//! gpui-kit 组件内置文案经 lib.rs 的 `init_i18n()`(`rust_i18n::extend!`)
//! 随语言切换。
//! - 调用点写 `t("common.save")` / `tf("msg.count", &[("n", &count)])`,
//! key 是**稳定标识符**,与任何自然语言解耦——改文案措辞不动代码,
//! 也不存在「一词多义撞 key」。
@@ -12,7 +14,8 @@
//! 值同样可查。
//!
//! # 规则(单元测试强制,违反即测试失败)
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/ui.yml` 加 key 块(en/zh-CN 必填)。
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/en.yml` 与 `locales/zh-CN.yml`
//! 各加一行(两文件键集合必须一致)。
//! 2. 源码每个 `t("…")` / `tf("…")` 字面量必须在语言包中存在。
//! 3. 模型 `label()` 返回的 key 必须有**非自映射**的 en 词条。
//! 4. 同一 key 的 zh-CN 与 en 占位符 `{name}` 集合必须一致
@@ -127,43 +130,33 @@ fn placeholders(s: &str) -> Vec<String> {
out
}
/// 解析生成器产出的规整 YAML(key 行 + 缩进的 en/zh-CN 值行)。
/// 解析 `locales/{locale}.yml`(规整格式:每行 `"key": "value"`,# 注释)。
/// 格式意外时 panic——语言包是构建产物,格式受控。
#[cfg(test)]
fn load_ui_yml() -> std::collections::BTreeMap<String, (String, String)> {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales/ui.yml"),
)
.expect("locales/ui.yml 应存在");
let unquote = |v: &str| -> String {
let v = v.trim();
let inner = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
inner.replace("\\\"", "\"").replace("\\\\", "\\")
};
fn load_locale_yml(locale: &str) -> std::collections::BTreeMap<String, String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("locales")
.join(format!("{locale}.yml"));
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{} 应存在: {e}", path.display()));
let mut map = std::collections::BTreeMap::new();
let mut key: Option<String> = None;
let mut en: Option<String> = None;
let mut zh: Option<String> = None;
for line in text.lines() {
if line.starts_with('#') || line.starts_with("_version") || line.trim().is_empty() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if !line.starts_with(' ') {
if let (Some(k), Some(e), Some(z)) = (key.take(), en.take(), zh.take()) {
map.insert(k, (e, z));
}
key = Some(unquote(line.trim_end_matches(':')));
} else if let Some(v) = line.trim().strip_prefix("en:") {
en = Some(unquote(v));
} else if let Some(v) = line.trim().strip_prefix("zh-CN:") {
zh = Some(unquote(v));
}
}
if let (Some(k), Some(e), Some(z)) = (key, en, zh) {
map.insert(k, (e, z));
let rest = line.strip_prefix('"').unwrap_or_else(|| panic!("行缺开引号: {line:?}"));
let (key, value) = rest
.split_once("\": ")
.unwrap_or_else(|| panic!("行缺 `\": ` 分隔: {line:?}"));
let unquote_value = |v: &str| {
let inner = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or_else(|| panic!("值缺引号: {v:?}"));
inner.replace("\\\"", "\"").replace("\\\\", "\\")
};
map.insert(key.to_string(), unquote_value(value));
}
map
}
@@ -172,17 +165,26 @@ fn load_ui_yml() -> std::collections::BTreeMap<String, (String, String)> {
mod tests {
use super::*;
/// 每个 key 都必须有非空 en 与 zh-CN;占位符两侧集合一致。
/// 两个语言文件键集合必须一致;每个 key 的 en/zh-CN 非空且
/// 占位符两侧集合一致。
#[test]
fn yml_complete_and_placeholder_parity() {
let map = load_ui_yml();
assert!(!map.is_empty(), "语言包不能为空");
for (k, (en, zh)) in &map {
assert!(!en.trim().is_empty(), "en 为空: {k:?}");
assert!(!zh.trim().is_empty(), "zh-CN 为空: {k:?}");
let en = load_locale_yml("en");
let zh = load_locale_yml("zh-CN");
assert!(!en.is_empty(), "en 语言包不能为空");
let en_only: Vec<_> = en.keys().filter(|k| !zh.contains_key(*k)).collect();
let zh_only: Vec<_> = zh.keys().filter(|k| !en.contains_key(*k)).collect();
assert!(
en_only.is_empty() && zh_only.is_empty(),
"键集合不一致: en 独有 {en_only:?}, zh 独有 {zh_only:?}"
);
for (k, en_v) in &en {
let zh_v = &zh[k];
assert!(!en_v.trim().is_empty(), "en 为空: {k:?}");
assert!(!zh_v.trim().is_empty(), "zh-CN 为空: {k:?}");
assert_eq!(
placeholders(zh),
placeholders(en),
placeholders(zh_v),
placeholders(en_v),
"占位符两侧不一致: {k:?}"
);
}
@@ -190,9 +192,10 @@ mod tests {
/// 扫描 src/ 全部源码里的 `t("…")` / `tf("…")` 字面量,
/// 逐一断言语言包有词条——漏翻是测试失败,不再是静默回退。
/// `(` 与 `"` 之间允许空白/换行(覆盖多行调用写法)。
#[test]
fn call_sites_covered_by_yml() {
let map = load_ui_yml();
let map = load_locale_yml("en");
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs(&root, &mut files);
@@ -251,9 +254,8 @@ mod tests {
}
/// 手写扫描(避免引入 regex 依赖):找 `::t(` / `::tf(` 后的字面量。
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法
/// `i18n::tf(\n "key",\n &[...]`;`::t(` 针不会命中 `::tf(`
/// (f 不是空白,也到不了引号分支)。
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法;`::t(` 针不会
/// 命中 `::tf(`(f 不是空白,也到不了引号分支)。
fn extract_t_literals(src: &str) -> Vec<String> {
let b = src.as_bytes();
let mut out = Vec::new();
+26
View File
@@ -165,8 +165,34 @@ fn tag_from_releases_url(uri: &str) -> Option<&str> {
(tag.starts_with('v') && !tag.contains('/')).then_some(tag)
}
/// 自动检查的节流窗口:24h 内启动不再重复请求 GitHub(官方 auto-update
/// 指南建议缓存上次检查时间)。手动「检查更新」不受此限制。
const AUTO_CHECK_INTERVAL_SECS: i64 = 24 * 60 * 60;
fn stamp_path() -> std::path::PathBuf {
crate::store::Storage::new().dir().join(".last-update-check")
}
fn last_check_recent() -> bool {
let Ok(meta) = std::fs::metadata(stamp_path()) else { return false };
let Ok(modified) = meta.modified() else { return false };
modified
.elapsed()
.map(|d| d.as_secs() < AUTO_CHECK_INTERVAL_SECS as u64)
.unwrap_or(false)
}
fn touch_check_stamp() {
let _ = std::fs::write(stamp_path(), chrono::Local::now().to_rfc3339());
}
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
/// 走 24h 节流:距上次自动检查不足一天时直接跳过(返回 None)。
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
if last_check_recent() {
return Ok(None);
}
touch_check_stamp();
let Some(info) = check_latest()? else {
return Ok(None);
};