feat: 阿里云 MQTT 连接预设、连接分组与 MQTT 3.1.1 支持

- 后端引入 rumqttc v4 客户端,支持 MQTT 3.1.1 连接/发布/订阅,协议版本随连接下发
- 连接表单:阿里云预设管理(分组、导入导出、去抖自动保存),退出云模式需确认,mqtts 时端口跟随 8883
- 连接列表:分组列就地编辑(输入即创建),空分组提示优化
- 导入按内容去重并提示跳过数量,导出提示含明文密钥
- 调试日志密码脱敏;修复多处取消弹窗的 unhandled rejection
This commit is contained in:
2026-09-04 01:45:19 +08:00
parent 8a2c63e575
commit 0152a10ee6
10 changed files with 1626 additions and 345 deletions
+24
View File
@@ -2248,6 +2248,7 @@ dependencies = [
name = "mqttx"
version = "0.1.0"
dependencies = [
"rumqttc-v4-next",
"rumqttc-v5-next",
"serde",
"serde_json",
@@ -3107,6 +3108,29 @@ dependencies = [
"tokio-rustls",
]
[[package]]
name = "rumqttc-v4-next"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3767dde22a804ea806107d0f4331e33cb794d1b8468b11c86fb0c1366f07054"
dependencies = [
"async-tungstenite",
"bytes",
"fixedbitset",
"flume",
"futures-util",
"log",
"mqttbytes-core-next",
"rumqttc-core-next",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
]
[[package]]
name = "rumqttc-v5-next"
version = "0.34.0"
+3 -1
View File
@@ -27,7 +27,9 @@ tauri-plugin-process = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rumqttc-v5-next = "0.34"
# rumqttc-v5-next 与 rumqttc-v4-next 的 lib 目标名都是 "rumqttc",必须重命名依赖才能共存
rumqttc = { package = "rumqttc-v5-next", version = "0.34" }
rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34" }
tokio = { version = "1", features = ["sync"] }
uuid = { version = "1", features = ["v4"] }
+470 -102
View File
@@ -5,6 +5,7 @@ use rumqttc::{
AsyncClient, Broker, ConnectionError, Event, EventLoop, MqttOptions, Outgoing,
PublishOptions,
};
use rumqttc_v4 as rv4;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -13,6 +14,29 @@ use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{RwLock, watch};
// ─── 调试日志 ────────────────────────────────────────────────────────────────
fn write_debug_log(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("mqttx_debug.log")
{
let _ = writeln!(f, "[{}] {}", chrono_now(), msg);
}
}
fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
// 简单 UTC 时间
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}
// ─── DTO structs ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -24,6 +48,9 @@ pub struct MqttConnectionDto {
pub username: String,
pub password: String,
pub client_id: String,
/// MQTT 协议版本: "3.1.1" | "5.0",老数据缺省按 5.0 处理
#[serde(default = "default_protocol_version")]
pub protocol_version: String,
pub clean_start: bool,
pub session_expiry_interval: u32,
pub keep_alive: u64,
@@ -33,6 +60,10 @@ pub struct MqttConnectionDto {
pub topic_alias_maximum: Option<u16>,
}
fn default_protocol_version() -> String {
"5.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishDto {
pub connection_id: String,
@@ -183,8 +214,13 @@ impl LogWriter {
// ─── State management ──────────────────────────────────────────────────────────
enum ClientKind {
V5(AsyncClient),
V4(rv4::AsyncClient),
}
struct ClientHandle {
client: AsyncClient,
client: ClientKind,
cancel_tx: watch::Sender<bool>,
connected: Arc<AtomicBool>,
}
@@ -238,6 +274,22 @@ fn emit_log(app_handle: &AppHandle, connection_id: &str, level: &str, event: &st
}
}
fn make_status(
connection_id: &str,
status: &str,
error: Option<String>,
session_present: Option<bool>,
reason_code: Option<u8>,
) -> MqttStatusEvent {
MqttStatusEvent {
connection_id: connection_id.to_string(),
status: status.to_string(),
error,
session_present,
reason_code,
}
}
fn extract_publish_info(publish: &Publish) -> MqttMessageEvent {
let payload_str = String::from_utf8_lossy(&publish.payload).to_string();
let props = publish.properties.as_ref();
@@ -271,6 +323,26 @@ fn extract_publish_info(publish: &Publish) -> MqttMessageEvent {
}
}
/// MQTT 3.1.1 的 Publish 没有 v5 properties,v5 专属字段一律置空
fn extract_publish_info_v4(publish: &rv4::Publish) -> MqttMessageEvent {
MqttMessageEvent {
connection_id: String::new(), // will be filled by caller
topic: String::from_utf8_lossy(&publish.topic).to_string(),
payload: String::from_utf8_lossy(&publish.payload).to_string(),
qos: qos_to_u8(publish.qos),
retain: publish.retain,
timestamp: now_timestamp(),
reason_code: None,
user_properties: Vec::new(),
content_type: None,
content_encoding: None,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
}
}
fn describe_publish_properties(props: &Option<PublishProperties>) -> String {
props.as_ref().map(|p| {
let mut parts = Vec::new();
@@ -308,7 +380,7 @@ fn describe_publish_properties(props: &Option<PublishProperties>) -> String {
// ─── Background event loop task ────────────────────────────────────────────────
fn spawn_event_loop(
fn spawn_event_loop_v5(
connection_id: String,
mut eventloop: EventLoop,
mut cancel_rx: watch::Receiver<bool>,
@@ -361,13 +433,10 @@ fn spawn_event_loop(
emit_log(&app_handle, &connection_id, "error", "connack",
format!("连接被拒绝: {:?} (code: {})", connack.code, reason_code), None);
}
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "connected".to_string(),
error: None,
session_present: Some(connack.session_present),
reason_code: Some(reason_code),
};
let status_event = make_status(
&connection_id, "connected", None,
Some(connack.session_present), Some(reason_code),
);
let _ = app_handle.emit("mqtt:status", &status_event);
}
@@ -389,13 +458,11 @@ fn spawn_event_loop(
format!("收到断开连接: {reason_desc} (code: {reason_code})"),
Some(props_info).filter(|d| !d.is_empty()),
);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: Some(format!("Disconnect reason: {reason_desc}")),
session_present: None,
reason_code: Some(reason_code),
};
let status_event = make_status(
&connection_id, "disconnected",
Some(format!("Disconnect reason: {reason_desc}")),
None, Some(reason_code),
);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
@@ -545,13 +612,7 @@ fn spawn_event_loop(
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_sent",
"已发送断开连接请求".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
@@ -560,13 +621,7 @@ fn spawn_event_loop(
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "requests_done",
"所有请求已完成,连接关闭".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
@@ -575,13 +630,9 @@ fn spawn_event_loop(
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "error", "connection_error",
format!("连接错误: {e}"), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
session_present: None,
reason_code: None,
};
let status_event = make_status(
&connection_id, "error", Some(e.to_string()), None, None,
);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
@@ -618,13 +669,210 @@ fn spawn_event_loop(
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_cancelled",
"用户取消连接".to_string(), None);
let status_event = MqttStatusEvent {
connection_id: connection_id.clone(),
status: "disconnected".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
}
}
});
}
/// MQTT 3.1.1 事件循环:与 v5 平行,packet 类型/日志行为按 v4 协议对齐
fn spawn_event_loop_v4(
connection_id: String,
mut eventloop: rv4::EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
app_handle: AppHandle,
) {
let conn_id = connection_id.clone();
let ah = app_handle.clone();
emit_log(&ah, &conn_id, "info", "event_loop_started", "MQTT 3.1.1 事件循环已启动".to_string(), None);
tokio::spawn(async move {
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(rv4::Event::Incoming(rv4::Packet::Publish(publish))) => {
let topic = String::from_utf8_lossy(&publish.topic).to_string();
let payload_len = publish.payload.len();
let payload_preview = String::from_utf8_lossy(&publish.payload).to_string().chars().take(200).collect::<String>();
emit_log(&app_handle, &connection_id, "info", "publish_received",
format!("收到消息: {topic} ({} 字节, QoS {})", payload_len, qos_to_u8(publish.qos)),
Some(format!("payload: {}{}",
payload_preview,
if payload_len > 200 { format!("... (共 {} 字节)", payload_len) } else { String::new() })),
);
let mut msg = extract_publish_info_v4(&publish);
msg.connection_id = connection_id.clone();
let _ = app_handle.emit("mqtt:message", &msg);
}
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(connack))) => {
connected.store(true, Ordering::SeqCst);
let reason_code = connack.code as u8;
let is_success = connack.code == rv4::ConnectReturnCode::Success;
if is_success {
emit_log(&app_handle, &connection_id, "info", "connack",
format!("连接成功 (session_present: {})", connack.session_present), None);
} else {
emit_log(&app_handle, &connection_id, "error", "connack",
format!("连接被拒绝: {:?} (code: {})", connack.code, reason_code), None);
}
let status_event = make_status(
&connection_id, "connected", None,
Some(connack.session_present), Some(reason_code),
);
let _ = app_handle.emit("mqtt:status", &status_event);
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "warn", "disconnect_received",
"收到断开连接 (MQTT 3.1.1)".to_string(), None);
let status_event = make_status(
&connection_id, "disconnected",
Some("收到服务端断开连接".to_string()), None, None,
);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(suback))) => {
let codes: Vec<String> = suback.return_codes.iter().map(|rc| {
match rc {
rv4::SubscribeReasonCode::Success(qos) => format!("成功(QoS {})", qos_to_u8(*qos)),
rv4::SubscribeReasonCode::Failure => "失败(0x80)".to_string(),
}
}).collect();
let any_failure = suback.return_codes.iter().any(|rc| !matches!(rc, rv4::SubscribeReasonCode::Success(_)));
let level = if any_failure { "warn" } else { "info" };
emit_log(&app_handle, &connection_id, level, "suback",
format!("订阅确认 (packet_id: {}): {}", suback.pkid, codes.join(", ")), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(unsuback))) => {
emit_log(&app_handle, &connection_id, "info", "unsuback",
format!("取消订阅确认 (packet_id: {})", unsuback.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(puback))) => {
emit_log(&app_handle, &connection_id, "info", "puback",
format!("发布确认 (packet_id: {})", puback.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubRec(pubrec))) => {
emit_log(&app_handle, &connection_id, "info", "pubrec",
format!("QoS2 发布收到 (packet_id: {})", pubrec.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubRel(pubrel))) => {
emit_log(&app_handle, &connection_id, "info", "pubrel",
format!("QoS2 发布释放 (packet_id: {})", pubrel.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubComp(pubcomp))) => {
emit_log(&app_handle, &connection_id, "info", "pubcomp",
format!("QoS2 发布完成 (packet_id: {})", pubcomp.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PingResp)) => {
// Ping responses are too noisy to log every time
}
// ─── Outgoing events (packet IDs only) ──────────────────────
Ok(rv4::Event::Outgoing(rv4::Outgoing::PingReq)) => {
// Ping requests are too noisy
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "publish_sent",
format!("发送消息 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Subscribe(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "subscribe_sent",
format!("发送订阅请求 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Unsubscribe(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "unsubscribe_sent",
format!("发送取消订阅请求 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::PubAck(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "puback_sent",
format!("发送发布确认 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::PubRec(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubrec_sent",
format!("发送QoS2发布收到 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::PubRel(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubrel_sent",
format!("发送QoS2发布释放 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::PubComp(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "pubcomp_sent",
format!("发送QoS2发布完成 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::PingResp)) => {
// Ping responses are too noisy
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::AwaitAck(pkid))) => {
emit_log(&app_handle, &connection_id, "info", "await_ack",
format!("等待应答 (packet_id: {pkid})"), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Disconnect)) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_sent",
"已发送断开连接请求".to_string(), None);
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Err(rv4::ConnectionError::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "requests_done",
"所有请求已完成,连接关闭".to_string(), None);
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "error", "connection_error",
format!("连接错误: {e}"), None);
let status_event = make_status(
&connection_id, "error", Some(e.to_string()), None, None,
);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
Ok(rv4::Event::Incoming(other)) => {
emit_log(&app_handle, &connection_id, "info", "incoming_other",
format!("未处理入站事件: {:?}", std::mem::discriminant(&other)), None);
}
}
}
_ = cancel_rx.changed() => {
connected.store(false, Ordering::SeqCst);
emit_log(&app_handle, &connection_id, "info", "disconnect_cancelled",
"用户取消连接".to_string(), None);
let status_event = make_status(&connection_id, "disconnected", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
break;
}
@@ -641,6 +889,47 @@ async fn mqtt_connect(
state: tauri::State<'_, AppState>,
conn: MqttConnectionDto,
) -> Result<(), String> {
// 调试日志:打印连接参数(密码脱敏,避免明文落盘)
let debug_info = format!(
"MQTT连接参数:\n host: {}\n port: {}\n client_id: {}\n username: {}\n password: ***\n protocol: {}\n clean_start: {}",
conn.host, conn.port, conn.client_id, conn.username,
conn.protocol_version, conn.clean_start
);
write_debug_log(&debug_info);
emit_log(&app_handle, &conn.id, "debug", "connect_params", debug_info.clone(), None);
eprintln!("[MQTT DEBUG] {}", debug_info);
// Cancel existing connection if any
{
let clients = state.clients.read().await;
if let Some(handle) = clients.get(&conn.id) {
let _ = handle.cancel_tx.send(true);
}
}
let connected = Arc::new(AtomicBool::new(false));
let (cancel_tx, cancel_rx) = watch::channel(false);
let is_v4 = conn.protocol_version == "3.1.1";
let client_kind = if is_v4 {
let broker = rv4::Broker::tcp(&conn.host, conn.port);
let mut mqttopts = rv4::MqttOptions::new(&conn.client_id, broker);
mqttopts.set_keep_alive(conn.keep_alive as u16);
mqttopts.set_clean_session(conn.clean_start);
if !conn.username.is_empty() {
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
}
let (client, eventloop) = rv4::AsyncClient::builder(mqttopts).build();
let handle = ClientHandle {
client: ClientKind::V4(client.clone()),
cancel_tx,
connected: connected.clone(),
};
spawn_event_loop_v4(conn.id.clone(), eventloop, cancel_rx, connected.clone(), app_handle.clone());
handle
} else {
let broker = Broker::tcp(&conn.host, conn.port);
let mut mqttopts = MqttOptions::new(&conn.client_id, broker);
mqttopts.set_keep_alive(conn.keep_alive as u16);
@@ -664,44 +953,28 @@ async fn mqtt_connect(
mqttopts.set_topic_alias_max(Some(tam));
}
// Cancel existing connection if any
{
let clients = state.clients.read().await;
if let Some(handle) = clients.get(&conn.id) {
let _ = handle.cancel_tx.send(true);
}
}
let (client, eventloop) = AsyncClient::builder(mqttopts).build();
let connected = Arc::new(AtomicBool::new(false));
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = ClientHandle {
client: client.clone(),
client: ClientKind::V5(client.clone()),
cancel_tx,
connected: connected.clone(),
};
spawn_event_loop_v5(conn.id.clone(), eventloop, cancel_rx, connected.clone(), app_handle.clone());
handle
};
{
let mut clients = state.clients.write().await;
clients.insert(conn.id.clone(), handle);
clients.insert(conn.id.clone(), client_kind);
}
// Emit connecting status
let status_event = MqttStatusEvent {
connection_id: conn.id.clone(),
status: "connecting".to_string(),
error: None,
session_present: None,
reason_code: None,
};
let status_event = make_status(&conn.id, "connecting", None, None, None);
let _ = app_handle.emit("mqtt:status", &status_event);
emit_log(&app_handle, &conn.id, "info", "connect",
format!("正在连接 {0}:{1}", conn.host, conn.port), None);
// Spawn background task
spawn_event_loop(conn.id, eventloop, cancel_rx, connected, app_handle);
format!("正在连接 {0}:{1} (MQTT {2})", conn.host, conn.port, conn.protocol_version),
Some(format!("protocol_version: {}", conn.protocol_version)));
Ok(())
}
@@ -710,23 +983,67 @@ async fn mqtt_connect(
async fn mqtt_test_connection(conn: MqttConnectionDto) -> Result<String, String> {
use tokio::time::timeout;
let test_id = format!("{}_test_{}", conn.client_id, std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis());
// 调试日志:打印测试连接参数(密码脱敏,避免明文落盘)
let debug_info = format!(
"MQTT测试连接参数:\n host: {}\n port: {}\n client_id: {}\n username: {}\n password: ***\n protocol: {}",
conn.host, conn.port, conn.client_id, conn.username,
conn.protocol_version
);
write_debug_log(&debug_info);
eprintln!("[MQTT TEST DEBUG] {}", debug_info);
let broker = Broker::tcp(&conn.host, conn.port);
let mut mqttopts = MqttOptions::new(&test_id, broker);
// 测试连接使用原始 client_id,不修改它
// 因为 password 是基于 client_id 计算的签名
let is_v4 = conn.protocol_version == "3.1.1";
let result = if is_v4 {
let broker = rv4::Broker::tcp(&conn.host, conn.port);
let mut mqttopts = rv4::MqttOptions::new(&conn.client_id, broker);
mqttopts.set_keep_alive(10);
mqttopts.set_clean_start(true);
mqttopts.set_clean_session(true);
if !conn.username.is_empty() {
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
}
let (client, mut eventloop) = AsyncClient::builder(mqttopts).build();
let (_client, mut eventloop) = rv4::AsyncClient::builder(mqttopts).build();
timeout(std::time::Duration::from_secs(5), async {
loop {
match eventloop.poll().await {
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(connack))) => {
if connack.code == rv4::ConnectReturnCode::Success {
write_debug_log("MQTT测试连接成功");
return Ok("连接测试成功!".to_string());
} else {
let err = format!("连接被拒绝: {:?}", connack.code);
write_debug_log(&err);
return Err(err);
}
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
let err = "连接被拒绝: 服务端主动断开".to_string();
write_debug_log(&err);
return Err(err);
}
Err(e) => {
let err = format!("连接失败: {}", e);
write_debug_log(&err);
return Err(err);
}
_ => {}
}
}
}).await
} else {
let broker = Broker::tcp(&conn.host, conn.port);
let mut mqttopts = MqttOptions::new(&conn.client_id, broker);
mqttopts.set_keep_alive(10);
mqttopts.set_clean_start(true);
if !conn.username.is_empty() {
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
}
let result = timeout(std::time::Duration::from_secs(5), async {
let (_client, mut eventloop) = AsyncClient::builder(mqttopts).build();
timeout(std::time::Duration::from_secs(5), async {
loop {
match eventloop.poll().await {
Ok(Event::Incoming(Packet::ConnAck(connack))) => {
@@ -745,10 +1062,8 @@ async fn mqtt_test_connection(conn: MqttConnectionDto) -> Result<String, String>
_ => {}
}
}
}).await;
// Drop client to close the connection
drop(client);
}).await
};
match result {
Ok(r) => r,
@@ -789,15 +1104,6 @@ async fn mqtt_publish(
let qos = qos_from_u8(publish.qos);
let payload = publish.payload.into_bytes();
let mut props = PublishProperties::default();
props.user_properties = publish.user_properties;
props.content_type = publish.content_type.clone();
props.message_expiry_interval = publish.message_expiry_interval;
props.response_topic = publish.response_topic.clone();
props.correlation_data = publish
.correlation_data
.map(|s| s.into_bytes().into());
emit_log(&app_handle, &publish.connection_id, "info", "publish_sent",
format!("发布消息到 {topic}: {size} 字节 (QoS {qos}, retain={retain})",
topic = publish.topic,
@@ -808,12 +1114,22 @@ async fn mqtt_publish(
Some(format!("payload: {}", String::from_utf8_lossy(&payload).to_string().chars().take(200).collect::<String>())),
);
match &handle.client {
ClientKind::V5(client) => {
let mut props = PublishProperties::default();
props.user_properties = publish.user_properties;
props.content_type = publish.content_type.clone();
props.message_expiry_interval = publish.message_expiry_interval;
props.response_topic = publish.response_topic.clone();
props.correlation_data = publish
.correlation_data
.map(|s| s.into_bytes().into());
let options = PublishOptions::new(qos)
.retain(publish.retain)
.properties(props);
handle
.client
client
.publish(publish.topic, payload, options)
.await
.map_err(|e| {
@@ -821,6 +1137,21 @@ async fn mqtt_publish(
format!("发布失败: {e}"), None);
e.to_string()
})?;
}
ClientKind::V4(client) => {
// MQTT 3.1.1 没有 v5 properties,直接忽略
let options = rv4::PublishOptions::new(qos).retain(publish.retain);
client
.publish(publish.topic, payload, options)
.await
.map_err(|e| {
emit_log(&app_handle, &publish.connection_id, "error", "publish_error",
format!("发布失败: {e}"), None);
e.to_string()
})?;
}
}
Ok(())
}
@@ -842,8 +1173,9 @@ async fn mqtt_subscribe(
format!("订阅主题: {topic} (QoS {qos})", topic = subscribe.topic, qos = subscribe.qos), None);
// Use tracked subscribe to wait for SubAck result
let notice = handle
.client
match &handle.client {
ClientKind::V5(client) => {
let notice = client
.subscribe_tracked(subscribe.topic, qos)
.await
.map_err(|e| {
@@ -851,14 +1183,30 @@ async fn mqtt_subscribe(
format!("订阅请求失败: {e}"), None);
e.to_string()
})?;
// Wait for SubAck and check return codes
notice.wait_completion_async().await.map_err(|e| {
let err_msg = format!("订阅被拒绝: {e}");
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_rejected",
err_msg.clone(), None);
err_msg
})?;
}
ClientKind::V4(client) => {
let notice = client
.subscribe_tracked(subscribe.topic, qos)
.await
.map_err(|e| {
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_error",
format!("订阅请求失败: {e}"), None);
e.to_string()
})?;
notice.wait_completion_async().await.map_err(|e| {
let err_msg = format!("订阅被拒绝: {e}");
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_rejected",
err_msg.clone(), None);
err_msg
})?;
}
}
Ok(())
}
@@ -878,15 +1226,22 @@ async fn mqtt_unsubscribe(
emit_log(&app_handle, &connection_id, "info", "unsubscribe",
format!("取消订阅主题: {topic}"), None);
handle
.client
.unsubscribe(topic)
.await
.map_err(|e| {
match &handle.client {
ClientKind::V5(client) => {
client.unsubscribe(topic).await.map_err(|e| {
emit_log(&app_handle, &connection_id, "error", "unsubscribe_error",
format!("取消订阅失败: {e}"), None);
e.to_string()
})?;
}
ClientKind::V4(client) => {
client.unsubscribe(topic).await.map_err(|e| {
emit_log(&app_handle, &connection_id, "error", "unsubscribe_error",
format!("取消订阅失败: {e}"), None);
e.to_string()
})?;
}
}
Ok(())
}
@@ -931,6 +1286,18 @@ fn mqtt_get_log_dir(app_handle: AppHandle) -> Result<String, String> {
Ok(dir.to_string_lossy().to_string())
}
#[tauri::command]
async fn mqtt_read_debug_log() -> Result<String, String> {
let path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("mqttx_debug.log");
if path.exists() {
std::fs::read_to_string(&path).map_err(|e| e.to_string())
} else {
Ok("日志文件不存在".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let log_writer = std::env::current_dir()
@@ -960,6 +1327,7 @@ pub fn run() {
mqtt_get_connection_status,
mqtt_test_connection,
mqtt_get_log_dir,
mqtt_read_debug_log,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+504 -71
View File
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { defaultConnection } from '../../types/mqtt'
import { mqttTestConnection } from '../../stores/useMqttBridge'
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { MqttConnection, AliyunPreset } from '../../types/mqtt'
import { defaultConnection, defaultAliyunPreset, generateAliyunConnection } from '../../types/mqtt'
import { mqttTestConnection, mqttReadDebugLog } from '../../stores/useMqttBridge'
import { useConnections } from '../../stores/useConnections'
import { useAliyunPresets } from '../../stores/useAliyunPresets'
import { toConnectionDto } from '../../types/mqtt'
const { getGroups: getConnectionGroups } = useConnections()
const { presets, add: addPreset, update: updatePreset, remove: removePreset, getGroups: getPresetGroups, renameGroup: renamePresetGroup, deleteGroup: deletePresetGroup, exportPresets, importPresets } = useAliyunPresets()
const props = defineProps<{
modelValue: boolean
connection: MqttConnection | null
@@ -18,24 +23,299 @@ const emit = defineEmits<{
const form = ref<MqttConnection>(defaultConnection())
const testing = ref(false)
const importInput = ref<HTMLInputElement | null>(null)
// ─── 连接分组 ──────────────────────────────────────────────────────────────
const connectionGroupOptions = computed(() => {
const existing = getConnectionGroups()
const opts = [{ label: '未分组', value: '' }]
for (const g of existing) opts.push({ label: g, value: g })
return opts
})
// ─── 阿里云预设 ─────────────────────────────────────────────────────────────
type CloudPreset = '' | 'aliyun'
const cloudPreset = ref<CloudPreset>('')
const selectedPresetId = ref<string>('')
const aliyunPreset = ref<AliyunPreset | null>(null)
// 预设分组
const presetGroupFilter = ref<string | null>(null)
const presetGroups = computed(() => getPresetGroups())
const filteredPresets = computed(() => {
if (presetGroupFilter.value === null) return presets.value
if (presetGroupFilter.value === '__ungrouped__') {
return presets.value.filter((p) => !p.group)
}
return presets.value.filter((p) => p.group === presetGroupFilter.value)
})
const presetGroupCounts = computed(() => {
const map: Record<string, number> = {}
for (const p of presets.value) {
const key = p.group || '__ungrouped__'
map[key] = (map[key] || 0) + 1
}
return map
})
/** 预设内容签名,用于判断用户是否真的修改过(避免把空白/未改动的预设写进库) */
function presetSignature(p: AliyunPreset): string {
return JSON.stringify([
p.name, p.group, p.instanceId, p.authMode, p.accessKeyId,
p.accessKeySecret, p.groupId, p.deviceId,
p.deviceAccessKeyId, p.deviceAccessKeySecret,
])
}
let presetBaseline = ''
function markPresetBaseline() {
if (aliyunPreset.value) presetBaseline = presetSignature(aliyunPreset.value)
}
function handleSelectPreset(id: string) {
const found = id ? presets.value.find((p) => p.id === id) : undefined
if (found) {
selectedPresetId.value = id
aliyunPreset.value = { ...found }
} else {
// 清空选择 → 空白预设,字段保持可编辑
selectedPresetId.value = ''
aliyunPreset.value = defaultAliyunPreset()
}
markPresetBaseline()
applyAliyunPreset()
}
function handleNewPreset() {
// 不强制先命名:直接给空白预设,填入有效内容后自动入库
selectedPresetId.value = ''
aliyunPreset.value = {
...defaultAliyunPreset(),
group: presetGroupFilter.value && presetGroupFilter.value !== '__ungrouped__' ? presetGroupFilter.value : '',
}
markPresetBaseline()
}
async function handleDeletePreset() {
if (!selectedPresetId.value) return
try {
await ElMessageBox.confirm(`确定删除预设 "${aliyunPreset.value?.name || '未命名'}"?`, '确认', { type: 'warning' })
} catch {
return
}
removePreset(selectedPresetId.value)
selectedPresetId.value = ''
aliyunPreset.value = defaultAliyunPreset()
markPresetBaseline()
ElMessage.success('已删除')
}
async function handleRenamePresetGroup(group: string) {
const { value } = await ElMessageBox.prompt('重命名预设分组', '重命名', {
inputValue: group,
inputPattern: /\S+/,
inputErrorMessage: '名称不能为空',
}).catch(() => ({ value: undefined }))
if (value && value !== group) {
renamePresetGroup(group, value)
if (presetGroupFilter.value === group) presetGroupFilter.value = value
}
}
async function handleDeletePresetGroup(group: string) {
const count = presetGroupCounts.value[group] || 0
try {
await ElMessageBox.confirm(`删除分组 "${group}"?组内 ${count} 个预设将变为未分组`, '确认', { type: 'warning' })
} catch {
return
}
deletePresetGroup(group)
if (presetGroupFilter.value === group) presetGroupFilter.value = null
}
// ─── 连接监听 ──────────────────────────────────────────────────────────────
watch(
() => props.connection,
(conn) => {
if (conn) {
form.value = { ...conn }
if (conn.host.includes('.mqtt.aliyuncs.com') && conn.username.startsWith('Signature|')) {
cloudPreset.value = 'aliyun'
parseAliyunFromConnection(conn)
} else {
cloudPreset.value = ''
aliyunPreset.value = null
selectedPresetId.value = ''
}
} else {
form.value = defaultConnection()
cloudPreset.value = ''
aliyunPreset.value = null
selectedPresetId.value = ''
}
},
{ immediate: true },
)
function parseAliyunFromConnection(conn: MqttConnection) {
const hostMatch = conn.host.match(/^(.+)\.mqtt\.aliyuncs\.com$/)
const userMatch = conn.username.match(/^Signature\|(.+)\|(.+)$/)
const clientMatch = conn.clientId.match(/^(.+)@@@(.+)$/)
// 尝试匹配已有预设
const match = presets.value.find((p) =>
p.instanceId === (hostMatch?.[1] ?? '') &&
p.accessKeyId === (userMatch?.[1] ?? '') &&
p.groupId === (clientMatch?.[1] ?? ''),
)
if (match) {
selectedPresetId.value = match.id
aliyunPreset.value = { ...match }
} else {
aliyunPreset.value = {
...defaultAliyunPreset(),
name: '',
instanceId: hostMatch?.[1] ?? '',
accessKeyId: userMatch?.[1] ?? '',
groupId: clientMatch?.[1] ?? '',
deviceId: clientMatch?.[2] ?? '',
}
}
markPresetBaseline()
}
async function handlePresetChange(val: CloudPreset | undefined) {
if (val === 'aliyun') {
if (!aliyunPreset.value) {
aliyunPreset.value = defaultAliyunPreset()
markPresetBaseline()
}
return
}
// 退出阿里云模式:已生成过连接参数时先确认,保留名称和分组
if (form.value.host.endsWith('.mqtt.aliyuncs.com')) {
try {
await ElMessageBox.confirm('退出阿里云模式将清空已生成的连接参数(保留名称和分组)', '提示', { type: 'warning' })
} catch {
cloudPreset.value = 'aliyun'
return
}
}
const { name, group } = form.value
form.value = { ...defaultConnection(), id: form.value.id, name, group }
aliyunPreset.value = null
selectedPresetId.value = ''
}
async function applyAliyunPreset() {
const p = aliyunPreset.value
if (!p || !p.instanceId || !p.accessKeyId || !p.accessKeySecret || !p.groupId) return
const generated = await generateAliyunConnection(p)
// 用户手动改过连接名时不覆盖(自动名以“阿里云 ”开头)
const autoName = p.name || `阿里云 ${p.groupId}`
const keepName = form.value.name !== '' && form.value.name !== autoName && !form.value.name.startsWith('阿里云 ')
form.value = {
...form.value,
...generated,
port: form.value.ssl ? 8883 : 1883,
name: keepName ? form.value.name : autoName,
}
}
function persistPresetIfChanged() {
const p = aliyunPreset.value
if (!p || cloudPreset.value !== 'aliyun') return
if (presetSignature(p) === presetBaseline) return
presetBaseline = presetSignature(p)
if (selectedPresetId.value) {
updatePreset(selectedPresetId.value, p)
} else if (p.name || p.instanceId || p.groupId || p.accessKeyId) {
// 空白预设填入有效内容后才入库
const added = addPreset({ ...p })
selectedPresetId.value = added.id
}
}
// 预设内容变化:去抖后自动入库 + 重新生成连接参数
let persistTimer: ReturnType<typeof setTimeout> | undefined
watch(aliyunPreset, () => {
if (cloudPreset.value !== 'aliyun' || !aliyunPreset.value) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = undefined
persistPresetIfChanged()
applyAliyunPreset()
}, 500)
}, { deep: true })
async function flushPendingPreset() {
if (!persistTimer) return
clearTimeout(persistTimer)
persistTimer = undefined
persistPresetIfChanged()
await applyAliyunPreset()
}
// 阿里云模式下切换 TLS 时端口跟随(1883 / 8883)
watch(() => form.value.ssl, (ssl) => {
if (cloudPreset.value === 'aliyun' && form.value.host.endsWith('.mqtt.aliyuncs.com')) {
form.value.port = ssl ? 8883 : 1883
}
})
// ─── 预设导入导出 ──────────────────────────────────────────────────────────
function handleExportPresets() {
const json = exportPresets()
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mqttx_presets_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${presets.value.length} 个预设(含明文密钥,请妥善保管)`)
}
function handleImportPresetsClick() {
importInput.value?.click()
}
async function handleImportPresetsFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const { imported, skipped } = importPresets(text)
if (imported === 0 && skipped > 0) {
ElMessage.warning(`全部 ${skipped} 个预设与现有内容重复,未导入`)
} else {
ElMessage.success(skipped > 0 ? `导入 ${imported} 个预设,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个预设`)
}
} catch (e) {
ElMessage.error(`导入失败: ${e}`)
} finally {
if (importInput.value) importInput.value.value = ''
}
}
// ─── 其他 ──────────────────────────────────────────────────────────────────
function generateClientId() {
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
}
function handleSave() {
async function handleSave() {
await flushPendingPreset()
if (!form.value.name.trim()) {
ElMessage.warning('请输入连接名称')
return
@@ -57,6 +337,7 @@ function handleCancel() {
}
async function handleTestConnection() {
await flushPendingPreset()
if (!form.value.host.trim()) {
ElMessage.warning('请输入主机地址')
return
@@ -66,12 +347,24 @@ async function handleTestConnection() {
const msg = await mqttTestConnection(toConnectionDto(form.value))
ElMessage.success(msg)
} catch (e: unknown) {
const err = e instanceof Error ? e.message : String(e)
ElMessage.error(err)
ElMessage.error(e instanceof Error ? e.message : String(e))
} finally {
testing.value = false
}
}
async function handleViewDebugLog() {
try {
const log = await mqttReadDebugLog()
ElMessageBox.alert(
log || '暂无调试日志',
'MQTT 调试日志',
{ type: 'info', customStyle: { whiteSpace: 'pre-wrap', maxWidth: '800px' }, confirmButtonText: '关闭' },
)
} catch (e: unknown) {
ElMessage.error('读取日志失败: ' + (e instanceof Error ? e.message : String(e)))
}
}
</script>
<template>
@@ -88,9 +381,148 @@ async function handleTestConnection() {
label-position="top"
size="small"
>
<!-- 云厂商预设 -->
<el-form-item label="云厂商预设">
<el-select
v-model="cloudPreset"
placeholder="无"
clearable
style="width: 100%"
@change="handlePresetChange"
>
<el-option value="aliyun" label="阿里云 MQTT" />
</el-select>
</el-form-item>
<!-- 阿里云预设配置 -->
<template v-if="cloudPreset === 'aliyun'">
<div class="section-title">
<span>阿里云 MQTT 配置</span>
<div class="section-actions">
<span class="autosave-hint">修改自动保存</span>
<el-button size="small" @click="handleImportPresetsClick">导入预设</el-button>
<el-button size="small" :disabled="presets.length === 0" @click="handleExportPresets">导出预设</el-button>
</div>
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportPresetsFile" />
</div>
<!-- 预设分组筛选 -->
<div v-if="presetGroups.length > 0" class="preset-group-tabs">
<span
:class="['preset-tab', presetGroupFilter === null && 'active']"
@click="presetGroupFilter = null"
>全部</span>
<span
:class="['preset-tab', presetGroupFilter === '__ungrouped__' && 'active']"
@click="presetGroupFilter = '__ungrouped__'"
>未分组</span>
<span
v-for="g in presetGroups"
:key="g"
:class="['preset-tab', presetGroupFilter === g && 'active']"
@click="presetGroupFilter = g"
>
{{ g }}
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenamePresetGroup(g) : handleDeletePresetGroup(g)">
<span class="preset-tab-menu" @click.stop>&#8230;</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
<!-- 预设选择 -->
<el-row :gutter="8">
<el-col :span="16">
<el-form-item label="选择预设">
<el-select
:model-value="selectedPresetId"
placeholder="选择已保存的预设"
clearable
style="width: 100%"
@change="(val: string) => handleSelectPreset(val)"
>
<el-option
v-for="p in filteredPresets"
:key="p.id"
:value="p.id"
:label="p.name || `${p.instanceId} / ${p.groupId}`"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8" style="display: flex; align-items: flex-end; gap: 4px; padding-bottom: 18px;">
<el-button size="small" @click="handleNewPreset">新建</el-button>
<el-button size="small" type="danger" :disabled="!selectedPresetId" @click="handleDeletePreset">删除</el-button>
</el-col>
</el-row>
<el-form-item v-if="aliyunPreset" label="预设名称(可选)">
<el-input v-model="aliyunPreset.name" placeholder="如:生产环境-门禁设备" />
</el-form-item>
<template v-if="aliyunPreset">
<el-form-item v-if="presetGroups.length > 0 || aliyunPreset.group" label="预设分组">
<el-select v-model="aliyunPreset.group" placeholder="未分组" clearable style="width: 100%">
<el-option v-for="g in presetGroups" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="实例 ID">
<el-input v-model="aliyunPreset.instanceId" placeholder="post-cn-xxx" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Group ID">
<el-input v-model="aliyunPreset.groupId" placeholder="GID-prod" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="Access Key ID">
<el-input v-model="aliyunPreset.accessKeyId" placeholder="LTAI4xxx" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Access Key Secret">
<el-input
v-model="aliyunPreset.accessKeySecret"
type="password"
show-password
placeholder="K24iRxxx"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="设备 ID">
<el-input v-model="aliyunPreset.deviceId" placeholder="device_xxx">
<template #append>
<el-button @click="aliyunPreset!.deviceId = `device_${Math.random().toString(36).slice(2, 10)}`">
重新生成
</el-button>
</template>
</el-input>
</el-form-item>
</template>
<el-divider border-style="dashed" />
</template>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="给连接起个名字" />
</el-form-item>
<el-form-item label="分组">
<el-select v-model="form.group" placeholder="未分组" clearable style="width: 100%">
<el-option v-for="opt in connectionGroupOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="协议">
@@ -107,126 +539,127 @@ async function handleTestConnection() {
</el-col>
<el-col :span="6">
<el-form-item label="端口">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
style="width: 100%"
/>
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="(可选)" />
<el-input v-model="form.username" placeholder="可选" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="(可选)"
/>
<el-input v-model="form.password" type="password" show-password placeholder="可选" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="16">
<el-form-item label="Client ID">
<el-input v-model="form.clientId" placeholder="自动生成即可">
<el-input v-model="form.clientId" placeholder="客户端 ID">
<template #append>
<el-button @click="generateClientId">重新生成</el-button>
<el-button @click="generateClientId">随机生成</el-button>
</template>
</el-input>
</el-form-item>
<el-divider border-style="dashed" />
<div class="section-title">MQTT v5 选项</div>
</el-col>
<el-col :span="8">
<el-form-item label="协议版本">
<el-select v-model="form.protocolVersion" style="width: 100%">
<el-option value="5.0" label="MQTT 5.0" />
<el-option value="3.1.1" label="MQTT 3.1.1" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- MQTT 选项 -->
<div class="section-title">
{{ form.protocolVersion === '5.0' ? 'MQTT v5 选项' : 'MQTT 选项' }}
</div>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="Clean Start">
<el-form-item :label="form.protocolVersion === '5.0' ? 'Clean Start' : 'Clean Session'">
<el-switch v-model="form.cleanStart" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="form.protocolVersion === '5.0'" :span="8">
<el-form-item label="Session Expiry">
<el-input-number
v-model="form.sessionExpiryInterval"
:min="0"
style="width: 100%"
/>
<span class="unit">秒</span>
<el-input-number v-model="form.sessionExpiryInterval" :min="0" :value-on-clear="null" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Keep Alive">
<el-input-number
v-model="form.keepAlive"
:min="10"
:max="3600"
style="width: 100%"
/>
<span class="unit">秒</span>
<el-input-number v-model="form.keepAlive" :min="0" :max="65535" :value-on-clear="null" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-row v-if="form.protocolVersion === '5.0'" :gutter="12">
<el-col :span="8">
<el-form-item label="Receive Max">
<el-input-number
v-model="form.receiveMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
<el-form-item label="Receive Maximum">
<el-input-number v-model="form.receiveMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Max Packet Size">
<el-input-number
v-model="form.maximumPacketSize"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
<el-input-number v-model="form.maximumPacketSize" :min="0" :value-on-clear="null" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Topic Alias Max">
<el-input-number
v-model="form.topicAliasMaximum"
:min="0"
:value-on-clear="null"
style="width: 100%"
/>
<el-input-number v-model="form.topicAliasMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div style="display: flex; justify-content: space-between; width: 100%;">
<el-button type="info" link @click="handleViewDebugLog">查看调试日志</el-button>
<div>
<el-button @click="handleCancel">取消</el-button>
<el-button
:loading="testing"
:disabled="testing"
@click="handleTestConnection"
>
测试连接
</el-button>
<el-button :loading="testing" :disabled="testing" @click="handleTestConnection">测试连接</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.section-title {
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 13px;
color: var(--el-text-color-primary);
margin-bottom: 10px;
padding-top: 6px;
border-top: 1px solid var(--el-border-color-lighter);
}
.section-actions { display: flex; align-items: center; gap: 4px; font-weight: normal; }
.autosave-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
.preset-group-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.unit {
.preset-tab {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 4px;
background: var(--el-fill-color-light);
}
.preset-tab:hover { background: var(--el-fill-color); }
.preset-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
.preset-tab-menu { opacity: 0.5; font-size: 14px; }
.preset-tab-menu:hover { opacity: 1; }
</style>
+228 -89
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { MqttConnection } from '../../types/mqtt'
import { useConnections } from '../../stores/useConnections'
@@ -7,18 +7,85 @@ import { toConnectionDto } from '../../types/mqtt'
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
import ConnectionForm from './ConnectionForm.vue'
const { connections, add, update, remove, updateStatus } = useConnections()
const {
connections,
add,
update,
remove,
updateStatus,
getGroups,
renameGroup,
deleteGroup,
exportConnections,
importConnections,
} = useConnections()
const showForm = ref(false)
const editingConnection = ref<MqttConnection | null>(null)
const selectedGroup = ref<string | null>(null) // null = 全部
const importInput = ref<HTMLInputElement | null>(null)
// ─── Connection actions ────────────────────────────────────────────────────────
// ─── 分组 ──────────────────────────────────────────────────────────────────
const groups = computed(() => getGroups())
const filteredConnections = computed(() => {
if (selectedGroup.value === null) return connections.value
if (selectedGroup.value === '__ungrouped__') {
return connections.value.filter((c) => !c.group)
}
return connections.value.filter((c) => c.group === selectedGroup.value)
})
const groupCounts = computed(() => {
const map: Record<string, number> = {}
for (const c of connections.value) {
const key = c.group || '__ungrouped__'
map[key] = (map[key] || 0) + 1
}
return map
})
/** 列表内就地修改连接分组(清空 = 未分组,输入新名称 = 直接创建分组) */
function handleGroupChange(conn: MqttConnection, group: string | undefined) {
update(conn.id, { group: group || '' })
}
async function handleRenameGroup(group: string) {
const { value } = await ElMessageBox.prompt('重命名分组', '重命名', {
inputValue: group,
inputPattern: /\S+/,
inputErrorMessage: '分组名称不能为空',
}).catch(() => ({ value: undefined }))
if (value && value !== group) {
renameGroup(group, value)
if (selectedGroup.value === group) selectedGroup.value = value
ElMessage.success('分组已重命名')
}
}
async function handleDeleteGroup(group: string) {
const count = groupCounts.value[group] || 0
try {
await ElMessageBox.confirm(
`删除分组 "${group}"?组内 ${count} 个连接将变为未分组`,
'确认删除',
{ type: 'warning' },
)
} catch {
return
}
deleteGroup(group)
if (selectedGroup.value === group) selectedGroup.value = null
ElMessage.success('分组已删除')
}
// ─── 连接操作 ──────────────────────────────────────────────────────────────
async function handleConnect(conn: MqttConnection) {
try {
updateStatus(conn.id, 'connecting')
await mqttConnect(toConnectionDto(conn))
// Status will be updated by the event listener
} catch (e: any) {
updateStatus(conn.id, 'error', String(e))
ElMessage.error(`连接失败: ${e}`)
@@ -35,7 +102,7 @@ async function handleDisconnect(conn: MqttConnection) {
}
}
// ─── CRUD ──────────────────────────────────────────────────────────────────────
// ─── CRUD ──────────────────────────────────────────────────────────────────
function handleAdd() {
editingConnection.value = null
@@ -49,14 +116,11 @@ function handleEdit(conn: MqttConnection) {
function handleSave(data: MqttConnection) {
if (editingConnection.value) {
// Update existing
update(editingConnection.value.id, data)
ElMessage.success('连接已更新')
} else {
// Add new
const newConn = { ...data, id: crypto.randomUUID() }
add()
// Replace the default with our data
const idx = connections.value.length - 1
connections.value[idx] = { ...connections.value[idx], ...newConn }
ElMessage.success('连接已创建')
@@ -79,31 +143,59 @@ async function handleDelete(conn: MqttConnection) {
}
}
// ─── Status helpers ────────────────────────────────────────────────────────────
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
function handleExport() {
const json = exportConnections()
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mqttx_connections_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${connections.value.length} 个连接(含明文密码,请妥善保管)`)
}
function handleImportClick() {
importInput.value?.click()
}
async function handleImportFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const { imported, skipped } = importConnections(text)
if (imported === 0 && skipped > 0) {
ElMessage.warning(`全部 ${skipped} 个连接与现有内容重复,未导入`)
} else {
ElMessage.success(skipped > 0 ? `导入 ${imported} 个连接,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个连接`)
}
} catch (e) {
ElMessage.error(`导入失败: ${e}`)
} finally {
if (importInput.value) importInput.value.value = ''
}
}
// ─── Status helpers ────────────────────────────────────────────────────────
function statusText(status: string): string {
switch (status) {
case 'connected':
return '已连接'
case 'connecting':
return '连接中...'
case 'error':
return '错误'
default:
return '已断开'
case 'connected': return '已连接'
case 'connecting': return '连接中...'
case 'error': return '错误'
default: return '已断开'
}
}
function statusType(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'connecting':
return 'warning'
case 'error':
return 'danger'
default:
return 'info'
case 'connected': return 'success'
case 'connecting': return 'warning'
case 'error': return 'danger'
default: return 'info'
}
}
</script>
@@ -112,21 +204,78 @@ function statusType(status: string): string {
<div class="connection-list">
<div class="list-header">
<h3>连接管理</h3>
<el-button type="primary" size="small" @click="handleAdd">
+ 新增连接
</el-button>
<div class="header-actions">
<el-button size="small" @click="handleImportClick">导入</el-button>
<el-button size="small" :disabled="connections.length === 0" @click="handleExport">导出</el-button>
<el-button type="primary" size="small" @click="handleAdd">+ 新增连接</el-button>
</div>
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportFile" />
</div>
<el-table :data="connections" style="width: 100%" stripe>
<!-- 分组侧栏 -->
<div v-if="groups.length > 0" class="group-tabs">
<div
:class="['group-tab', selectedGroup === null && 'active']"
@click="selectedGroup = null"
>
全部
<span class="badge">{{ connections.length }}</span>
</div>
<div
:class="['group-tab', selectedGroup === '__ungrouped__' && 'active']"
@click="selectedGroup = '__ungrouped__'"
>
未分组
<span class="badge">{{ groupCounts['__ungrouped__'] || 0 }}</span>
</div>
<div
v-for="g in groups"
:key="g"
:class="['group-tab', selectedGroup === g && 'active']"
@click="selectedGroup = g"
>
<span class="group-name">{{ g }}</span>
<span class="badge">{{ groupCounts[g] || 0 }}</span>
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenameGroup(g) : handleDeleteGroup(g)">
<span class="group-menu" @click.stop>&#8230;</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除分组</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 连接表格 -->
<el-table :data="filteredConnections" style="width: 100%" stripe>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="分组" width="130">
<template #default="scope">
<el-select
:model-value="scope.row.group"
placeholder="未分组"
size="small"
filterable
allow-create
default-first-option
clearable
style="width: 100%"
@change="(g: string) => handleGroupChange(scope.row, g)"
>
<el-option v-for="g in groups" :key="g" :label="g" :value="g" />
</el-select>
</template>
</el-table-column>
<el-table-column label="地址" min-width="180">
<template #default="scope">
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
</template>
</el-table-column>
<el-table-column label="协议" width="60">
<template #default>
<el-tag size="small">v5</el-tag>
<template #default="scope">
<el-tag size="small">{{ scope.row.protocolVersion === '3.1.1' ? 'v3' : 'v5' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
@@ -145,35 +294,16 @@ function statusType(status: string): string {
type="success"
:loading="scope.row.status === 'connecting'"
@click="handleConnect(scope.row)"
>
连接
</el-button>
<el-button
v-else
size="small"
@click="handleDisconnect(scope.row)"
>
断开
</el-button>
<el-button
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
>连接</el-button>
<el-button v-else size="small" @click="handleDisconnect(scope.row)">断开</el-button>
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="connections.length === 0" class="empty-hint">
暂无连接,点击上方按钮创建
<div v-if="filteredConnections.length === 0" class="empty-hint">
{{ connections.length === 0 ? '暂无连接,点击上方按钮创建' : '当前分组下暂无连接' }}
</div>
<ConnectionForm
@@ -185,46 +315,55 @@ function statusType(status: string): string {
</template>
<style scoped>
.connection-list {
height: 100%;
}
.connection-list { height: 100%; }
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
.list-header h3 { margin: 0; font-size: 16px; }
.header-actions { display: flex; gap: 6px; }
/* 分组标签 */
.group-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
.group-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
background: var(--el-fill-color-light);
transition: all 0.15s;
}
.status-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-indicator.connected {
background: var(--el-color-success);
}
.status-indicator.connecting {
background: var(--el-color-warning);
animation: pulse 1s infinite;
}
.status-indicator.error {
background: var(--el-color-danger);
}
.status-indicator.disconnected {
background: var(--el-text-color-disabled);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
.group-tab:hover { background: var(--el-fill-color); }
.group-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
.group-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
font-size: 11px;
padding: 0 5px;
border-radius: 8px;
background: var(--el-fill-color-dark);
color: var(--el-text-color-secondary);
line-height: 16px;
}
.group-tab.active .badge { background: var(--el-color-primary); color: #fff; }
.group-menu { cursor: pointer; font-size: 14px; opacity: 0.5; }
.group-menu:hover { opacity: 1; }
.empty-hint { text-align: center; padding: 40px; color: var(--el-text-color-placeholder); }
.status-indicator { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
.status-indicator.connected { background: var(--el-color-success); }
.status-indicator.connecting { background: var(--el-color-warning); animation: pulse 1s infinite; }
.status-indicator.error { background: var(--el-color-danger); }
.status-indicator.disconnected { background: var(--el-text-color-disabled); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
</style>
+9 -1
View File
@@ -60,6 +60,13 @@ const connectedConnections = computed(() =>
props.connections.filter((c) => c.status === 'connected'),
)
const selectedConnection = computed(() =>
props.connections.find((c) => c.id === selectedConnectionId.value),
)
/** MQTT 3.1.1 连接不支持 v5 属性 */
const isV5Connection = computed(() => selectedConnection.value?.protocolVersion !== '3.1.1')
const activePreset = computed(() =>
props.presets.find((p) => p.id === activePresetId.value),
)
@@ -440,6 +447,7 @@ function onJsonPayloadChange(value: JsonValue) {
<span v-if="presetVariables.length" class="toggle-badge">{{ presetVariables.length }}</span>
</el-button>
<el-button
v-if="isV5Connection"
size="small"
:class="['toggle-btn', { active: showV5Props }]"
@click="showV5Props = !showV5Props"
@@ -496,7 +504,7 @@ function onJsonPayloadChange(value: JsonValue) {
<!-- v5 Props -->
<transition name="slide">
<div v-if="showV5Props" class="expand-section">
<div v-if="showV5Props && isV5Connection" class="expand-section">
<div class="v5-grid">
<div class="field">
<label>Content Type</label>
+125
View File
@@ -0,0 +1,125 @@
import { ref, watch } from 'vue'
import type { AliyunPreset } from '../types/mqtt'
import { defaultAliyunPreset } from '../types/mqtt'
const STORAGE_KEY = 'mqttx_aliyun_presets'
function loadPresets(): AliyunPreset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as AliyunPreset[]
return parsed.map((p) => ({
...defaultAliyunPreset(),
...p,
id: p.id || crypto.randomUUID(),
group: p.group ?? '',
}))
} catch {
return []
}
}
function savePresets(presets: AliyunPreset[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
} catch {}
}
const presets = ref<AliyunPreset[]>(loadPresets())
watch(presets, (val) => savePresets(val), { deep: true })
export function useAliyunPresets() {
function add(preset?: Partial<AliyunPreset>): AliyunPreset {
const p: AliyunPreset = { ...defaultAliyunPreset(), ...preset }
presets.value.push(p)
return p
}
function update(id: string, data: Partial<AliyunPreset>) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) {
presets.value[idx] = { ...presets.value[idx], ...data }
}
}
function remove(id: string) {
const idx = presets.value.findIndex((p) => p.id === id)
if (idx !== -1) presets.value.splice(idx, 1)
}
function getById(id: string): AliyunPreset | undefined {
return presets.value.find((p) => p.id === id)
}
// ─── 分组 ────────────────────────────────────────────────────────────────
function getGroups(): string[] {
const set = new Set<string>()
for (const p of presets.value) {
if (p.group) set.add(p.group)
}
return Array.from(set).sort()
}
function renameGroup(oldName: string, newName: string) {
for (const p of presets.value) {
if (p.group === oldName) p.group = newName
}
}
function deleteGroup(group: string) {
for (const p of presets.value) {
if (p.group === group) p.group = ''
}
}
// ─── 导入 / 导出 ────────────────────────────────────────────────────────
function exportPresets(ids?: string[]): string {
const list = ids
? presets.value.filter((p) => ids.includes(p.id))
: presets.value
return JSON.stringify(list, null, 2)
}
/** 按 实例ID+GroupID+设备ID+AK 去重,返回导入/跳过数量 */
function importPresets(json: string): { imported: number; skipped: number } {
const parsed = JSON.parse(json) as Partial<AliyunPreset>[]
const seen = new Set(
presets.value.map((p) => `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`),
)
let imported = 0
let skipped = 0
for (const item of parsed) {
const p: AliyunPreset = {
...defaultAliyunPreset(),
...item,
id: crypto.randomUUID(),
}
const key = `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`
if (seen.has(key)) {
skipped++
continue
}
seen.add(key)
presets.value.push(p)
imported++
}
return { imported, skipped }
}
return {
presets,
add,
update,
remove,
getById,
getGroups,
renameGroup,
deleteGroup,
exportPresets,
importPresets,
}
}
+83 -5
View File
@@ -9,9 +9,10 @@ function loadConnections(): MqttConnection[] {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as MqttConnection[]
// Ensure each connection has runtime status fields
return parsed.map((c) => ({
...c,
protocolVersion: c.protocolVersion ?? '5.0',
group: c.group ?? '',
status: 'disconnected' as const,
lastError: '',
}))
@@ -22,17 +23,15 @@ function loadConnections(): MqttConnection[] {
function saveConnections(connections: MqttConnection[]) {
try {
// Strip runtime fields before persisting
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
} catch {
// localStorage full or unavailable — silently ignore
// localStorage full or unavailable
}
}
const connections = ref<MqttConnection[]>(loadConnections())
// Auto-persist on changes
watch(
connections,
(val) => {
@@ -42,7 +41,7 @@ watch(
)
export function useConnections() {
function add() {
function add(): MqttConnection {
const conn = defaultConnection()
connections.value.push(conn)
return conn
@@ -82,6 +81,79 @@ export function useConnections() {
return connections.value.filter((c) => c.status === 'connected')
}
// ─── 分组操作 ──────────────────────────────────────────────────────────────
/** 获取所有分组名称(去重) */
function getGroups(): string[] {
const set = new Set<string>()
for (const c of connections.value) {
if (c.group) set.add(c.group)
}
return Array.from(set).sort()
}
/** 按分组获取连接列表 */
function getByGroup(group: string): MqttConnection[] {
return connections.value.filter((c) => c.group === group)
}
/** 重命名分组 */
function renameGroup(oldName: string, newName: string) {
for (const c of connections.value) {
if (c.group === oldName) {
c.group = newName
}
}
}
/** 删除分组(将组内连接移到未分组) */
function deleteGroup(group: string) {
for (const c of connections.value) {
if (c.group === group) {
c.group = ''
}
}
}
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
/** 导出为 JSON 字符串(去除运行时字段) */
function exportConnections(ids?: string[]): string {
const list = ids
? connections.value.filter((c) => ids.includes(c.id))
: connections.value
const data = list.map(({ status: _, lastError: __, ...rest }) => rest)
return JSON.stringify(data, null, 2)
}
/** 从 JSON 导入,按名称+地址+端口+ClientID 去重,返回导入/跳过数量 */
function importConnections(json: string): { imported: number; skipped: number } {
const parsed = JSON.parse(json) as Partial<MqttConnection>[]
const seen = new Set(
connections.value.map((c) => `${c.name}|${c.host}|${c.port}|${c.clientId}`),
)
let imported = 0
let skipped = 0
for (const item of parsed) {
const conn: MqttConnection = {
...defaultConnection(),
...item,
id: crypto.randomUUID(), // 始终生成新 ID 避免冲突
status: 'disconnected',
lastError: '',
}
const key = `${conn.name}|${conn.host}|${conn.port}|${conn.clientId}`
if (seen.has(key)) {
skipped++
continue
}
seen.add(key)
connections.value.push(conn)
imported++
}
return { imported, skipped }
}
return {
connections,
add,
@@ -90,5 +162,11 @@ export function useConnections() {
getById,
updateStatus,
getConnected,
getGroups,
getByGroup,
renameGroup,
deleteGroup,
exportConnections,
importConnections,
}
}
+4
View File
@@ -149,6 +149,10 @@ export async function mqttTestConnection(conn: MqttConnectionDto): Promise<strin
return await invoke<string>('mqtt_test_connection', { conn })
}
export async function mqttReadDebugLog(): Promise<string> {
return await invoke<string>('mqtt_read_debug_log')
}
export async function mqttGetLogDir(): Promise<string> {
return await invoke<string>('mqtt_get_log_dir')
}
+100
View File
@@ -7,6 +7,98 @@ export interface Subscription {
qos: number
}
// ─── Aliyun Preset ─────────────────────────────────────────────────────────────
/** 阿里云鉴权模式 */
export type AliyunAuthMode = 'token' | 'device_credential'
export interface AliyunPreset {
/** 唯一 ID */
id: string
/** 预设名称 */
name: string
/** 分组 */
group: string
/** 实例 ID,如 post-cn-4591dq2ra1i */
instanceId: string
/** 鉴权模式 */
authMode: AliyunAuthMode
/** Access Key ID(Token 模式必填) */
accessKeyId: string
/** Access Key Secret(Token 模式必填) */
accessKeySecret: string
/** Group ID,如 GID-prod */
groupId: string
/** 设备 ID,用于拼接 ClientId */
deviceId: string
/** 设备 Access Key ID(一机一密模式必填) */
deviceAccessKeyId: string
/** 设备 Access Key Secret(一机一密模式必填) */
deviceAccessKeySecret: string
}
export function defaultAliyunPreset(): AliyunPreset {
return {
id: crypto.randomUUID(),
name: '',
group: '',
instanceId: '',
authMode: 'token',
accessKeyId: '',
accessKeySecret: '',
groupId: '',
deviceId: `device_${Math.random().toString(36).slice(2, 10)}`,
deviceAccessKeyId: '',
deviceAccessKeySecret: '',
}
}
/** 计算阿里云 MQTT 签名: HMAC-SHA1(Secret, ClientId) -> Base64 (使用 Web Crypto API) */
export async function calculateAliyunSignature(clientId: string, secret: string): Promise<string> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(clientId))
return bytesToBase64(new Uint8Array(signature))
}
/** 根据阿里云预设生成连接参数 */
export async function generateAliyunConnection(preset: AliyunPreset): Promise<Pick<MqttConnection, 'host' | 'port' | 'username' | 'password' | 'clientId' | 'protocolVersion' | 'cleanStart'>> {
const clientId = `${preset.groupId}@@@${preset.deviceId}`
const username = `Signature|${preset.accessKeyId}|${preset.instanceId}`
const password = await calculateAliyunSignature(clientId, preset.accessKeySecret)
return {
host: `${preset.instanceId}.mqtt.aliyuncs.com`,
port: 1883,
username,
password,
clientId,
protocolVersion: '3.1.1',
cleanStart: true,
}
}
function bytesToBase64(bytes: Uint8Array): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i]
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[(b0 >> 2) & 0x3f]
result += chars[((b0 << 4) | (b1 >> 4)) & 0x3f]
result += i + 1 < bytes.length ? chars[((b1 << 2) | (b2 >> 6)) & 0x3f] : '='
result += i + 2 < bytes.length ? chars[b2 & 0x3f] : '='
}
return result
}
// ─── Connection ────────────────────────────────────────────────────────────────
export interface MqttConnection {
@@ -17,10 +109,14 @@ export interface MqttConnection {
username: string
password: string
clientId: string
/** MQTT 协议版本:3.1.1 / 5.0 */
protocolVersion: '3.1.1' | '5.0'
cleanStart: boolean
sessionExpiryInterval: number
keepAlive: number
ssl: boolean
/** 分组名称,空字符串表示未分组 */
group: string
receiveMaximum: number | null
maximumPacketSize: number | null
topicAliasMaximum: number | null
@@ -38,10 +134,12 @@ export function defaultConnection(): MqttConnection {
username: '',
password: '',
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
protocolVersion: '5.0',
cleanStart: true,
sessionExpiryInterval: 0,
keepAlive: 60,
ssl: false,
group: '',
receiveMaximum: null,
maximumPacketSize: null,
topicAliasMaximum: null,
@@ -112,6 +210,7 @@ export interface MqttConnectionDto {
username: string
password: string
client_id: string
protocol_version: string
clean_start: boolean
session_expiry_interval: number
keep_alive: number
@@ -130,6 +229,7 @@ export function toConnectionDto(conn: MqttConnection): MqttConnectionDto {
username: conn.username,
password: conn.password,
client_id: conn.clientId,
protocol_version: conn.protocolVersion,
clean_start: conn.cleanStart,
session_expiry_interval: conn.sessionExpiryInterval,
keep_alive: conn.keepAlive,