feat: 阿里云 MQTT 连接预设、连接分组与 MQTT 3.1.1 支持
- 后端引入 rumqttc v4 客户端,支持 MQTT 3.1.1 连接/发布/订阅,协议版本随连接下发 - 连接表单:阿里云预设管理(分组、导入导出、去抖自动保存),退出云模式需确认,mqtts 时端口跟随 8883 - 连接列表:分组列就地编辑(输入即创建),空分组提示优化 - 导入按内容去重并提示跳过数量,导出提示含明文密钥 - 调试日志密码脱敏;修复多处取消弹窗的 unhandled rejection
This commit is contained in:
Generated
+24
@@ -2248,6 +2248,7 @@ dependencies = [
|
|||||||
name = "mqttx"
|
name = "mqttx"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"rumqttc-v4-next",
|
||||||
"rumqttc-v5-next",
|
"rumqttc-v5-next",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3107,6 +3108,29 @@ dependencies = [
|
|||||||
"tokio-rustls",
|
"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]]
|
[[package]]
|
||||||
name = "rumqttc-v5-next"
|
name = "rumqttc-v5-next"
|
||||||
version = "0.34.0"
|
version = "0.34.0"
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ tauri-plugin-process = "2"
|
|||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"] }
|
tokio = { version = "1", features = ["sync"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
|||||||
+534
-166
@@ -5,6 +5,7 @@ use rumqttc::{
|
|||||||
AsyncClient, Broker, ConnectionError, Event, EventLoop, MqttOptions, Outgoing,
|
AsyncClient, Broker, ConnectionError, Event, EventLoop, MqttOptions, Outgoing,
|
||||||
PublishOptions,
|
PublishOptions,
|
||||||
};
|
};
|
||||||
|
use rumqttc_v4 as rv4;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -13,6 +14,29 @@ use std::sync::Arc;
|
|||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tokio::sync::{RwLock, watch};
|
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 ───────────────────────────────────────────────────────────────
|
// ─── DTO structs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -24,6 +48,9 @@ pub struct MqttConnectionDto {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub client_id: 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 clean_start: bool,
|
||||||
pub session_expiry_interval: u32,
|
pub session_expiry_interval: u32,
|
||||||
pub keep_alive: u64,
|
pub keep_alive: u64,
|
||||||
@@ -33,6 +60,10 @@ pub struct MqttConnectionDto {
|
|||||||
pub topic_alias_maximum: Option<u16>,
|
pub topic_alias_maximum: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_protocol_version() -> String {
|
||||||
|
"5.0".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PublishDto {
|
pub struct PublishDto {
|
||||||
pub connection_id: String,
|
pub connection_id: String,
|
||||||
@@ -183,8 +214,13 @@ impl LogWriter {
|
|||||||
|
|
||||||
// ─── State management ──────────────────────────────────────────────────────────
|
// ─── State management ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum ClientKind {
|
||||||
|
V5(AsyncClient),
|
||||||
|
V4(rv4::AsyncClient),
|
||||||
|
}
|
||||||
|
|
||||||
struct ClientHandle {
|
struct ClientHandle {
|
||||||
client: AsyncClient,
|
client: ClientKind,
|
||||||
cancel_tx: watch::Sender<bool>,
|
cancel_tx: watch::Sender<bool>,
|
||||||
connected: Arc<AtomicBool>,
|
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 {
|
fn extract_publish_info(publish: &Publish) -> MqttMessageEvent {
|
||||||
let payload_str = String::from_utf8_lossy(&publish.payload).to_string();
|
let payload_str = String::from_utf8_lossy(&publish.payload).to_string();
|
||||||
let props = publish.properties.as_ref();
|
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 {
|
fn describe_publish_properties(props: &Option<PublishProperties>) -> String {
|
||||||
props.as_ref().map(|p| {
|
props.as_ref().map(|p| {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
@@ -308,7 +380,7 @@ fn describe_publish_properties(props: &Option<PublishProperties>) -> String {
|
|||||||
|
|
||||||
// ─── Background event loop task ────────────────────────────────────────────────
|
// ─── Background event loop task ────────────────────────────────────────────────
|
||||||
|
|
||||||
fn spawn_event_loop(
|
fn spawn_event_loop_v5(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
mut eventloop: EventLoop,
|
mut eventloop: EventLoop,
|
||||||
mut cancel_rx: watch::Receiver<bool>,
|
mut cancel_rx: watch::Receiver<bool>,
|
||||||
@@ -361,13 +433,10 @@ fn spawn_event_loop(
|
|||||||
emit_log(&app_handle, &connection_id, "error", "connack",
|
emit_log(&app_handle, &connection_id, "error", "connack",
|
||||||
format!("连接被拒绝: {:?} (code: {})", connack.code, reason_code), None);
|
format!("连接被拒绝: {:?} (code: {})", connack.code, reason_code), None);
|
||||||
}
|
}
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(
|
||||||
connection_id: connection_id.clone(),
|
&connection_id, "connected", None,
|
||||||
status: "connected".to_string(),
|
Some(connack.session_present), Some(reason_code),
|
||||||
error: None,
|
);
|
||||||
session_present: Some(connack.session_present),
|
|
||||||
reason_code: Some(reason_code),
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,13 +458,11 @@ fn spawn_event_loop(
|
|||||||
format!("收到断开连接: {reason_desc} (code: {reason_code})"),
|
format!("收到断开连接: {reason_desc} (code: {reason_code})"),
|
||||||
Some(props_info).filter(|d| !d.is_empty()),
|
Some(props_info).filter(|d| !d.is_empty()),
|
||||||
);
|
);
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(
|
||||||
connection_id: connection_id.clone(),
|
&connection_id, "disconnected",
|
||||||
status: "disconnected".to_string(),
|
Some(format!("Disconnect reason: {reason_desc}")),
|
||||||
error: Some(format!("Disconnect reason: {reason_desc}")),
|
None, Some(reason_code),
|
||||||
session_present: None,
|
);
|
||||||
reason_code: Some(reason_code),
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -545,13 +612,7 @@ fn spawn_event_loop(
|
|||||||
connected.store(false, Ordering::SeqCst);
|
connected.store(false, Ordering::SeqCst);
|
||||||
emit_log(&app_handle, &connection_id, "info", "disconnect_sent",
|
emit_log(&app_handle, &connection_id, "info", "disconnect_sent",
|
||||||
"已发送断开连接请求".to_string(), None);
|
"已发送断开连接请求".to_string(), None);
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(&connection_id, "disconnected", None, None, None);
|
||||||
connection_id: connection_id.clone(),
|
|
||||||
status: "disconnected".to_string(),
|
|
||||||
error: None,
|
|
||||||
session_present: None,
|
|
||||||
reason_code: None,
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -560,13 +621,7 @@ fn spawn_event_loop(
|
|||||||
connected.store(false, Ordering::SeqCst);
|
connected.store(false, Ordering::SeqCst);
|
||||||
emit_log(&app_handle, &connection_id, "info", "requests_done",
|
emit_log(&app_handle, &connection_id, "info", "requests_done",
|
||||||
"所有请求已完成,连接关闭".to_string(), None);
|
"所有请求已完成,连接关闭".to_string(), None);
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(&connection_id, "disconnected", None, None, None);
|
||||||
connection_id: connection_id.clone(),
|
|
||||||
status: "disconnected".to_string(),
|
|
||||||
error: None,
|
|
||||||
session_present: None,
|
|
||||||
reason_code: None,
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -575,13 +630,9 @@ fn spawn_event_loop(
|
|||||||
connected.store(false, Ordering::SeqCst);
|
connected.store(false, Ordering::SeqCst);
|
||||||
emit_log(&app_handle, &connection_id, "error", "connection_error",
|
emit_log(&app_handle, &connection_id, "error", "connection_error",
|
||||||
format!("连接错误: {e}"), None);
|
format!("连接错误: {e}"), None);
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(
|
||||||
connection_id: connection_id.clone(),
|
&connection_id, "error", Some(e.to_string()), None, None,
|
||||||
status: "error".to_string(),
|
);
|
||||||
error: Some(e.to_string()),
|
|
||||||
session_present: None,
|
|
||||||
reason_code: None,
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -618,13 +669,210 @@ fn spawn_event_loop(
|
|||||||
connected.store(false, Ordering::SeqCst);
|
connected.store(false, Ordering::SeqCst);
|
||||||
emit_log(&app_handle, &connection_id, "info", "disconnect_cancelled",
|
emit_log(&app_handle, &connection_id, "info", "disconnect_cancelled",
|
||||||
"用户取消连接".to_string(), None);
|
"用户取消连接".to_string(), None);
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(&connection_id, "disconnected", None, None, None);
|
||||||
connection_id: connection_id.clone(),
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
status: "disconnected".to_string(),
|
break;
|
||||||
error: None,
|
}
|
||||||
session_present: None,
|
}
|
||||||
reason_code: None,
|
}
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -641,28 +889,15 @@ async fn mqtt_connect(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
conn: MqttConnectionDto,
|
conn: MqttConnectionDto,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let broker = Broker::tcp(&conn.host, conn.port);
|
// 调试日志:打印连接参数(密码脱敏,避免明文落盘)
|
||||||
let mut mqttopts = MqttOptions::new(&conn.client_id, broker);
|
let debug_info = format!(
|
||||||
mqttopts.set_keep_alive(conn.keep_alive as u16);
|
"MQTT连接参数:\n host: {}\n port: {}\n client_id: {}\n username: {}\n password: ***\n protocol: {}\n clean_start: {}",
|
||||||
mqttopts.set_clean_start(conn.clean_start);
|
conn.host, conn.port, conn.client_id, conn.username,
|
||||||
|
conn.protocol_version, conn.clean_start
|
||||||
if conn.session_expiry_interval > 0 {
|
);
|
||||||
mqttopts.set_session_expiry_interval(Some(conn.session_expiry_interval));
|
write_debug_log(&debug_info);
|
||||||
}
|
emit_log(&app_handle, &conn.id, "debug", "connect_params", debug_info.clone(), None);
|
||||||
|
eprintln!("[MQTT DEBUG] {}", debug_info);
|
||||||
if !conn.username.is_empty() {
|
|
||||||
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rm) = conn.receive_maximum {
|
|
||||||
mqttopts.set_receive_maximum(Some(rm));
|
|
||||||
}
|
|
||||||
if let Some(mps) = conn.maximum_packet_size {
|
|
||||||
mqttopts.set_max_packet_size(Some(mps));
|
|
||||||
}
|
|
||||||
if let Some(tam) = conn.topic_alias_maximum {
|
|
||||||
mqttopts.set_topic_alias_max(Some(tam));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel existing connection if any
|
// Cancel existing connection if any
|
||||||
{
|
{
|
||||||
@@ -672,36 +907,74 @@ async fn mqtt_connect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (client, eventloop) = AsyncClient::builder(mqttopts).build();
|
|
||||||
let connected = Arc::new(AtomicBool::new(false));
|
let connected = Arc::new(AtomicBool::new(false));
|
||||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
|
||||||
let handle = ClientHandle {
|
let is_v4 = conn.protocol_version == "3.1.1";
|
||||||
client: client.clone(),
|
|
||||||
cancel_tx,
|
let client_kind = if is_v4 {
|
||||||
connected: connected.clone(),
|
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);
|
||||||
|
mqttopts.set_clean_start(conn.clean_start);
|
||||||
|
|
||||||
|
if conn.session_expiry_interval > 0 {
|
||||||
|
mqttopts.set_session_expiry_interval(Some(conn.session_expiry_interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !conn.username.is_empty() {
|
||||||
|
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rm) = conn.receive_maximum {
|
||||||
|
mqttopts.set_receive_maximum(Some(rm));
|
||||||
|
}
|
||||||
|
if let Some(mps) = conn.maximum_packet_size {
|
||||||
|
mqttopts.set_max_packet_size(Some(mps));
|
||||||
|
}
|
||||||
|
if let Some(tam) = conn.topic_alias_maximum {
|
||||||
|
mqttopts.set_topic_alias_max(Some(tam));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, eventloop) = AsyncClient::builder(mqttopts).build();
|
||||||
|
let handle = ClientHandle {
|
||||||
|
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;
|
let mut clients = state.clients.write().await;
|
||||||
clients.insert(conn.id.clone(), handle);
|
clients.insert(conn.id.clone(), client_kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit connecting status
|
// Emit connecting status
|
||||||
let status_event = MqttStatusEvent {
|
let status_event = make_status(&conn.id, "connecting", None, None, None);
|
||||||
connection_id: conn.id.clone(),
|
|
||||||
status: "connecting".to_string(),
|
|
||||||
error: None,
|
|
||||||
session_present: None,
|
|
||||||
reason_code: None,
|
|
||||||
};
|
|
||||||
let _ = app_handle.emit("mqtt:status", &status_event);
|
let _ = app_handle.emit("mqtt:status", &status_event);
|
||||||
|
|
||||||
emit_log(&app_handle, &conn.id, "info", "connect",
|
emit_log(&app_handle, &conn.id, "info", "connect",
|
||||||
format!("正在连接 {0}:{1}", conn.host, conn.port), None);
|
format!("正在连接 {0}:{1} (MQTT {2})", conn.host, conn.port, conn.protocol_version),
|
||||||
|
Some(format!("protocol_version: {}", conn.protocol_version)));
|
||||||
// Spawn background task
|
|
||||||
spawn_event_loop(conn.id, eventloop, cancel_rx, connected, app_handle);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -710,45 +983,87 @@ async fn mqtt_connect(
|
|||||||
async fn mqtt_test_connection(conn: MqttConnectionDto) -> Result<String, String> {
|
async fn mqtt_test_connection(conn: MqttConnectionDto) -> Result<String, String> {
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
let test_id = format!("{}_test_{}", conn.client_id, std::time::SystemTime::now()
|
// 调试日志:打印测试连接参数(密码脱敏,避免明文落盘)
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let debug_info = format!(
|
||||||
.unwrap_or_default()
|
"MQTT测试连接参数:\n host: {}\n port: {}\n client_id: {}\n username: {}\n password: ***\n protocol: {}",
|
||||||
.as_millis());
|
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);
|
// 测试连接使用原始 client_id,不修改它
|
||||||
let mut mqttopts = MqttOptions::new(&test_id, broker);
|
// 因为 password 是基于 client_id 计算的签名
|
||||||
mqttopts.set_keep_alive(10);
|
let is_v4 = conn.protocol_version == "3.1.1";
|
||||||
mqttopts.set_clean_start(true);
|
|
||||||
|
|
||||||
if !conn.username.is_empty() {
|
let result = if is_v4 {
|
||||||
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
|
let broker = rv4::Broker::tcp(&conn.host, conn.port);
|
||||||
}
|
let mut mqttopts = rv4::MqttOptions::new(&conn.client_id, broker);
|
||||||
|
mqttopts.set_keep_alive(10);
|
||||||
let (client, mut eventloop) = AsyncClient::builder(mqttopts).build();
|
mqttopts.set_clean_session(true);
|
||||||
|
if !conn.username.is_empty() {
|
||||||
let result = timeout(std::time::Duration::from_secs(5), async {
|
mqttopts.set_credentials(conn.username.clone(), conn.password.clone());
|
||||||
loop {
|
|
||||||
match eventloop.poll().await {
|
|
||||||
Ok(Event::Incoming(Packet::ConnAck(connack))) => {
|
|
||||||
if connack.code == ConnectReturnCode::Success {
|
|
||||||
return Ok("连接测试成功!".to_string());
|
|
||||||
} else {
|
|
||||||
return Err(format!("连接被拒绝: {:?}", connack.code));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Incoming(Packet::Disconnect(disconnect))) => {
|
|
||||||
return Err(format!("连接被拒绝: {:?}", disconnect.reason_code));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(format!("连接失败: {}", e));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).await;
|
|
||||||
|
|
||||||
// Drop client to close the connection
|
let (_client, mut eventloop) = rv4::AsyncClient::builder(mqttopts).build();
|
||||||
drop(client);
|
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 (_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))) => {
|
||||||
|
if connack.code == ConnectReturnCode::Success {
|
||||||
|
return Ok("连接测试成功!".to_string());
|
||||||
|
} else {
|
||||||
|
return Err(format!("连接被拒绝: {:?}", connack.code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Incoming(Packet::Disconnect(disconnect))) => {
|
||||||
|
return Err(format!("连接被拒绝: {:?}", disconnect.reason_code));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!("连接失败: {}", e));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).await
|
||||||
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -789,15 +1104,6 @@ async fn mqtt_publish(
|
|||||||
let qos = qos_from_u8(publish.qos);
|
let qos = qos_from_u8(publish.qos);
|
||||||
let payload = publish.payload.into_bytes();
|
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",
|
emit_log(&app_handle, &publish.connection_id, "info", "publish_sent",
|
||||||
format!("发布消息到 {topic}: {size} 字节 (QoS {qos}, retain={retain})",
|
format!("发布消息到 {topic}: {size} 字节 (QoS {qos}, retain={retain})",
|
||||||
topic = publish.topic,
|
topic = publish.topic,
|
||||||
@@ -808,19 +1114,44 @@ async fn mqtt_publish(
|
|||||||
Some(format!("payload: {}", String::from_utf8_lossy(&payload).to_string().chars().take(200).collect::<String>())),
|
Some(format!("payload: {}", String::from_utf8_lossy(&payload).to_string().chars().take(200).collect::<String>())),
|
||||||
);
|
);
|
||||||
|
|
||||||
let options = PublishOptions::new(qos)
|
match &handle.client {
|
||||||
.retain(publish.retain)
|
ClientKind::V5(client) => {
|
||||||
.properties(props);
|
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());
|
||||||
|
|
||||||
handle
|
let options = PublishOptions::new(qos)
|
||||||
.client
|
.retain(publish.retain)
|
||||||
.publish(publish.topic, payload, options)
|
.properties(props);
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
client
|
||||||
emit_log(&app_handle, &publish.connection_id, "error", "publish_error",
|
.publish(publish.topic, payload, options)
|
||||||
format!("发布失败: {e}"), None);
|
.await
|
||||||
e.to_string()
|
.map_err(|e| {
|
||||||
})?;
|
emit_log(&app_handle, &publish.connection_id, "error", "publish_error",
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -842,23 +1173,40 @@ async fn mqtt_subscribe(
|
|||||||
format!("订阅主题: {topic} (QoS {qos})", topic = subscribe.topic, qos = subscribe.qos), None);
|
format!("订阅主题: {topic} (QoS {qos})", topic = subscribe.topic, qos = subscribe.qos), None);
|
||||||
|
|
||||||
// Use tracked subscribe to wait for SubAck result
|
// Use tracked subscribe to wait for SubAck result
|
||||||
let notice = handle
|
match &handle.client {
|
||||||
.client
|
ClientKind::V5(client) => {
|
||||||
.subscribe_tracked(subscribe.topic, qos)
|
let notice = client
|
||||||
.await
|
.subscribe_tracked(subscribe.topic, qos)
|
||||||
.map_err(|e| {
|
.await
|
||||||
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_error",
|
.map_err(|e| {
|
||||||
format!("订阅请求失败: {e}"), None);
|
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_error",
|
||||||
e.to_string()
|
format!("订阅请求失败: {e}"), None);
|
||||||
})?;
|
e.to_string()
|
||||||
|
})?;
|
||||||
// Wait for SubAck and check return codes
|
notice.wait_completion_async().await.map_err(|e| {
|
||||||
notice.wait_completion_async().await.map_err(|e| {
|
let err_msg = format!("订阅被拒绝: {e}");
|
||||||
let err_msg = format!("订阅被拒绝: {e}");
|
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_rejected",
|
||||||
emit_log(&app_handle, &subscribe.connection_id, "error", "subscribe_rejected",
|
err_msg.clone(), None);
|
||||||
err_msg.clone(), None);
|
err_msg
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -878,15 +1226,22 @@ async fn mqtt_unsubscribe(
|
|||||||
emit_log(&app_handle, &connection_id, "info", "unsubscribe",
|
emit_log(&app_handle, &connection_id, "info", "unsubscribe",
|
||||||
format!("取消订阅主题: {topic}"), None);
|
format!("取消订阅主题: {topic}"), None);
|
||||||
|
|
||||||
handle
|
match &handle.client {
|
||||||
.client
|
ClientKind::V5(client) => {
|
||||||
.unsubscribe(topic)
|
client.unsubscribe(topic).await.map_err(|e| {
|
||||||
.await
|
emit_log(&app_handle, &connection_id, "error", "unsubscribe_error",
|
||||||
.map_err(|e| {
|
format!("取消订阅失败: {e}"), None);
|
||||||
emit_log(&app_handle, &connection_id, "error", "unsubscribe_error",
|
e.to_string()
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -931,6 +1286,18 @@ fn mqtt_get_log_dir(app_handle: AppHandle) -> Result<String, String> {
|
|||||||
Ok(dir.to_string_lossy().to_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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let log_writer = std::env::current_dir()
|
let log_writer = std::env::current_dir()
|
||||||
@@ -960,7 +1327,8 @@ pub fn run() {
|
|||||||
mqtt_get_connection_status,
|
mqtt_get_connection_status,
|
||||||
mqtt_test_connection,
|
mqtt_test_connection,
|
||||||
mqtt_get_log_dir,
|
mqtt_get_log_dir,
|
||||||
|
mqtt_read_debug_log,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { MqttConnection } from '../../types/mqtt'
|
import type { MqttConnection, AliyunPreset } from '../../types/mqtt'
|
||||||
import { defaultConnection } from '../../types/mqtt'
|
import { defaultConnection, defaultAliyunPreset, generateAliyunConnection } from '../../types/mqtt'
|
||||||
import { mqttTestConnection } from '../../stores/useMqttBridge'
|
import { mqttTestConnection, mqttReadDebugLog } from '../../stores/useMqttBridge'
|
||||||
|
import { useConnections } from '../../stores/useConnections'
|
||||||
|
import { useAliyunPresets } from '../../stores/useAliyunPresets'
|
||||||
import { toConnectionDto } from '../../types/mqtt'
|
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<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
connection: MqttConnection | null
|
connection: MqttConnection | null
|
||||||
@@ -18,24 +23,299 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const form = ref<MqttConnection>(defaultConnection())
|
const form = ref<MqttConnection>(defaultConnection())
|
||||||
const testing = ref(false)
|
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(
|
watch(
|
||||||
() => props.connection,
|
() => props.connection,
|
||||||
(conn) => {
|
(conn) => {
|
||||||
if (conn) {
|
if (conn) {
|
||||||
form.value = { ...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 {
|
} else {
|
||||||
form.value = defaultConnection()
|
form.value = defaultConnection()
|
||||||
|
cloudPreset.value = ''
|
||||||
|
aliyunPreset.value = null
|
||||||
|
selectedPresetId.value = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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() {
|
function generateClientId() {
|
||||||
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
|
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
async function handleSave() {
|
||||||
|
await flushPendingPreset()
|
||||||
if (!form.value.name.trim()) {
|
if (!form.value.name.trim()) {
|
||||||
ElMessage.warning('请输入连接名称')
|
ElMessage.warning('请输入连接名称')
|
||||||
return
|
return
|
||||||
@@ -57,6 +337,7 @@ function handleCancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTestConnection() {
|
async function handleTestConnection() {
|
||||||
|
await flushPendingPreset()
|
||||||
if (!form.value.host.trim()) {
|
if (!form.value.host.trim()) {
|
||||||
ElMessage.warning('请输入主机地址')
|
ElMessage.warning('请输入主机地址')
|
||||||
return
|
return
|
||||||
@@ -66,12 +347,24 @@ async function handleTestConnection() {
|
|||||||
const msg = await mqttTestConnection(toConnectionDto(form.value))
|
const msg = await mqttTestConnection(toConnectionDto(form.value))
|
||||||
ElMessage.success(msg)
|
ElMessage.success(msg)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e instanceof Error ? e.message : String(e)
|
ElMessage.error(e instanceof Error ? e.message : String(e))
|
||||||
ElMessage.error(err)
|
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,9 +381,148 @@ async function handleTestConnection() {
|
|||||||
label-position="top"
|
label-position="top"
|
||||||
size="small"
|
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>…</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-form-item label="名称">
|
||||||
<el-input v-model="form.name" placeholder="给连接起个名字" />
|
<el-input v-model="form.name" placeholder="给连接起个名字" />
|
||||||
</el-form-item>
|
</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-row :gutter="12">
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="协议">
|
<el-form-item label="协议">
|
||||||
@@ -107,126 +539,127 @@ async function handleTestConnection() {
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="端口">
|
<el-form-item label="端口">
|
||||||
<el-input-number
|
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
|
||||||
v-model="form.port"
|
|
||||||
:min="1"
|
|
||||||
:max="65535"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input v-model="form.username" placeholder="(可选)" />
|
<el-input v-model="form.username" placeholder="可选" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="密码">
|
<el-form-item label="密码">
|
||||||
<el-input
|
<el-input v-model="form.password" type="password" show-password placeholder="可选" />
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="(可选)"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="Client ID">
|
|
||||||
<el-input v-model="form.clientId" placeholder="自动生成即可">
|
|
||||||
<template #append>
|
|
||||||
<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-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="8">
|
<el-col :span="16">
|
||||||
<el-form-item label="Clean Start">
|
<el-form-item label="Client ID">
|
||||||
<el-switch v-model="form.cleanStart" />
|
<el-input v-model="form.clientId" placeholder="客户端 ID">
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="generateClientId">随机生成</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<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="form.protocolVersion === '5.0' ? 'Clean Start' : 'Clean Session'">
|
||||||
|
<el-switch v-model="form.cleanStart" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="form.protocolVersion === '5.0'" :span="8">
|
||||||
<el-form-item label="Session Expiry">
|
<el-form-item label="Session Expiry">
|
||||||
<el-input-number
|
<el-input-number v-model="form.sessionExpiryInterval" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||||
v-model="form.sessionExpiryInterval"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<span class="unit">秒</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="Keep Alive">
|
<el-form-item label="Keep Alive">
|
||||||
<el-input-number
|
<el-input-number v-model="form.keepAlive" :min="0" :max="65535" :value-on-clear="null" style="width: 100%" />
|
||||||
v-model="form.keepAlive"
|
|
||||||
:min="10"
|
|
||||||
:max="3600"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<span class="unit">秒</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="12">
|
<el-row v-if="form.protocolVersion === '5.0'" :gutter="12">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="Receive Max">
|
<el-form-item label="Receive Maximum">
|
||||||
<el-input-number
|
<el-input-number v-model="form.receiveMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||||
v-model="form.receiveMaximum"
|
|
||||||
:min="0"
|
|
||||||
:value-on-clear="null"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="Max Packet Size">
|
<el-form-item label="Max Packet Size">
|
||||||
<el-input-number
|
<el-input-number v-model="form.maximumPacketSize" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||||
v-model="form.maximumPacketSize"
|
|
||||||
:min="0"
|
|
||||||
:value-on-clear="null"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="Topic Alias Max">
|
<el-form-item label="Topic Alias Max">
|
||||||
<el-input-number
|
<el-input-number v-model="form.topicAliasMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||||
v-model="form.topicAliasMaximum"
|
|
||||||
:min="0"
|
|
||||||
:value-on-clear="null"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleCancel">取消</el-button>
|
<div style="display: flex; justify-content: space-between; width: 100%;">
|
||||||
<el-button
|
<el-button type="info" link @click="handleViewDebugLog">查看调试日志</el-button>
|
||||||
:loading="testing"
|
<div>
|
||||||
:disabled="testing"
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
@click="handleTestConnection"
|
<el-button :loading="testing" :disabled="testing" @click="handleTestConnection">测试连接</el-button>
|
||||||
>
|
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||||
测试连接
|
</div>
|
||||||
</el-button>
|
</div>
|
||||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
color: var(--el-text-color-primary);
|
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;
|
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;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
background: var(--el-fill-color-light);
|
||||||
margin-left: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
.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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { MqttConnection } from '../../types/mqtt'
|
import type { MqttConnection } from '../../types/mqtt'
|
||||||
import { useConnections } from '../../stores/useConnections'
|
import { useConnections } from '../../stores/useConnections'
|
||||||
@@ -7,18 +7,85 @@ import { toConnectionDto } from '../../types/mqtt'
|
|||||||
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
|
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
|
||||||
import ConnectionForm from './ConnectionForm.vue'
|
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 showForm = ref(false)
|
||||||
const editingConnection = ref<MqttConnection | null>(null)
|
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) {
|
async function handleConnect(conn: MqttConnection) {
|
||||||
try {
|
try {
|
||||||
updateStatus(conn.id, 'connecting')
|
updateStatus(conn.id, 'connecting')
|
||||||
await mqttConnect(toConnectionDto(conn))
|
await mqttConnect(toConnectionDto(conn))
|
||||||
// Status will be updated by the event listener
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
updateStatus(conn.id, 'error', String(e))
|
updateStatus(conn.id, 'error', String(e))
|
||||||
ElMessage.error(`连接失败: ${e}`)
|
ElMessage.error(`连接失败: ${e}`)
|
||||||
@@ -35,7 +102,7 @@ async function handleDisconnect(conn: MqttConnection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CRUD ──────────────────────────────────────────────────────────────────────
|
// ─── CRUD ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
editingConnection.value = null
|
editingConnection.value = null
|
||||||
@@ -49,14 +116,11 @@ function handleEdit(conn: MqttConnection) {
|
|||||||
|
|
||||||
function handleSave(data: MqttConnection) {
|
function handleSave(data: MqttConnection) {
|
||||||
if (editingConnection.value) {
|
if (editingConnection.value) {
|
||||||
// Update existing
|
|
||||||
update(editingConnection.value.id, data)
|
update(editingConnection.value.id, data)
|
||||||
ElMessage.success('连接已更新')
|
ElMessage.success('连接已更新')
|
||||||
} else {
|
} else {
|
||||||
// Add new
|
|
||||||
const newConn = { ...data, id: crypto.randomUUID() }
|
const newConn = { ...data, id: crypto.randomUUID() }
|
||||||
add()
|
add()
|
||||||
// Replace the default with our data
|
|
||||||
const idx = connections.value.length - 1
|
const idx = connections.value.length - 1
|
||||||
connections.value[idx] = { ...connections.value[idx], ...newConn }
|
connections.value[idx] = { ...connections.value[idx], ...newConn }
|
||||||
ElMessage.success('连接已创建')
|
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 {
|
function statusText(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'connected':
|
case 'connected': return '已连接'
|
||||||
return '已连接'
|
case 'connecting': return '连接中...'
|
||||||
case 'connecting':
|
case 'error': return '错误'
|
||||||
return '连接中...'
|
default: return '已断开'
|
||||||
case 'error':
|
|
||||||
return '错误'
|
|
||||||
default:
|
|
||||||
return '已断开'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusType(status: string): string {
|
function statusType(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'connected':
|
case 'connected': return 'success'
|
||||||
return 'success'
|
case 'connecting': return 'warning'
|
||||||
case 'connecting':
|
case 'error': return 'danger'
|
||||||
return 'warning'
|
default: return 'info'
|
||||||
case 'error':
|
|
||||||
return 'danger'
|
|
||||||
default:
|
|
||||||
return 'info'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -112,21 +204,78 @@ function statusType(status: string): string {
|
|||||||
<div class="connection-list">
|
<div class="connection-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<h3>连接管理</h3>
|
<h3>连接管理</h3>
|
||||||
<el-button type="primary" size="small" @click="handleAdd">
|
<div class="header-actions">
|
||||||
+ 新增连接
|
<el-button size="small" @click="handleImportClick">导入</el-button>
|
||||||
</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>
|
</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>…</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 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">
|
<el-table-column label="地址" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
|
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="协议" width="60">
|
<el-table-column label="协议" width="60">
|
||||||
<template #default>
|
<template #default="scope">
|
||||||
<el-tag size="small">v5</el-tag>
|
<el-tag size="small">{{ scope.row.protocolVersion === '3.1.1' ? 'v3' : 'v5' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="120">
|
<el-table-column label="状态" width="120">
|
||||||
@@ -145,35 +294,16 @@ function statusType(status: string): string {
|
|||||||
type="success"
|
type="success"
|
||||||
:loading="scope.row.status === 'connecting'"
|
:loading="scope.row.status === 'connecting'"
|
||||||
@click="handleConnect(scope.row)"
|
@click="handleConnect(scope.row)"
|
||||||
>
|
>连接</el-button>
|
||||||
连接
|
<el-button v-else size="small" @click="handleDisconnect(scope.row)">断开</el-button>
|
||||||
</el-button>
|
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
<el-button
|
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div v-if="connections.length === 0" class="empty-hint">
|
<div v-if="filteredConnections.length === 0" class="empty-hint">
|
||||||
暂无连接,点击上方按钮创建
|
{{ connections.length === 0 ? '暂无连接,点击上方按钮创建' : '当前分组下暂无连接' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionForm
|
<ConnectionForm
|
||||||
@@ -185,46 +315,55 @@ function statusType(status: string): string {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.connection-list {
|
.connection-list { height: 100%; }
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.list-header {
|
.list-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.list-header h3 {
|
.list-header h3 { margin: 0; font-size: 16px; }
|
||||||
margin: 0;
|
.header-actions { display: flex; gap: 6px; }
|
||||||
font-size: 16px;
|
|
||||||
|
/* 分组标签 */
|
||||||
|
.group-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.empty-hint {
|
.group-tab {
|
||||||
text-align: center;
|
display: inline-flex;
|
||||||
padding: 40px;
|
align-items: center;
|
||||||
color: var(--el-text-color-placeholder);
|
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 {
|
.group-tab:hover { background: var(--el-fill-color); }
|
||||||
display: inline-block;
|
.group-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
|
||||||
width: 6px;
|
.group-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
height: 6px;
|
.badge {
|
||||||
border-radius: 50%;
|
font-size: 11px;
|
||||||
margin-right: 4px;
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-dark);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
.status-indicator.connected {
|
.group-tab.active .badge { background: var(--el-color-primary); color: #fff; }
|
||||||
background: var(--el-color-success);
|
.group-menu { cursor: pointer; font-size: 14px; opacity: 0.5; }
|
||||||
}
|
.group-menu:hover { opacity: 1; }
|
||||||
.status-indicator.connecting {
|
|
||||||
background: var(--el-color-warning);
|
.empty-hint { text-align: center; padding: 40px; color: var(--el-text-color-placeholder); }
|
||||||
animation: pulse 1s infinite;
|
.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.error {
|
.status-indicator.connecting { background: var(--el-color-warning); animation: pulse 1s infinite; }
|
||||||
background: var(--el-color-danger);
|
.status-indicator.error { background: var(--el-color-danger); }
|
||||||
}
|
.status-indicator.disconnected { background: var(--el-text-color-disabled); }
|
||||||
.status-indicator.disconnected {
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
background: var(--el-text-color-disabled);
|
</style>
|
||||||
}
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ const connectedConnections = computed(() =>
|
|||||||
props.connections.filter((c) => c.status === 'connected'),
|
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(() =>
|
const activePreset = computed(() =>
|
||||||
props.presets.find((p) => p.id === activePresetId.value),
|
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>
|
<span v-if="presetVariables.length" class="toggle-badge">{{ presetVariables.length }}</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="isV5Connection"
|
||||||
size="small"
|
size="small"
|
||||||
:class="['toggle-btn', { active: showV5Props }]"
|
:class="['toggle-btn', { active: showV5Props }]"
|
||||||
@click="showV5Props = !showV5Props"
|
@click="showV5Props = !showV5Props"
|
||||||
@@ -496,7 +504,7 @@ function onJsonPayloadChange(value: JsonValue) {
|
|||||||
|
|
||||||
<!-- v5 Props -->
|
<!-- v5 Props -->
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-if="showV5Props" class="expand-section">
|
<div v-if="showV5Props && isV5Connection" class="expand-section">
|
||||||
<div class="v5-grid">
|
<div class="v5-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Content Type</label>
|
<label>Content Type</label>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ function loadConnections(): MqttConnection[] {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const parsed = JSON.parse(raw) as MqttConnection[]
|
const parsed = JSON.parse(raw) as MqttConnection[]
|
||||||
// Ensure each connection has runtime status fields
|
|
||||||
return parsed.map((c) => ({
|
return parsed.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
protocolVersion: c.protocolVersion ?? '5.0',
|
||||||
|
group: c.group ?? '',
|
||||||
status: 'disconnected' as const,
|
status: 'disconnected' as const,
|
||||||
lastError: '',
|
lastError: '',
|
||||||
}))
|
}))
|
||||||
@@ -22,17 +23,15 @@ function loadConnections(): MqttConnection[] {
|
|||||||
|
|
||||||
function saveConnections(connections: MqttConnection[]) {
|
function saveConnections(connections: MqttConnection[]) {
|
||||||
try {
|
try {
|
||||||
// Strip runtime fields before persisting
|
|
||||||
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
|
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage full or unavailable — silently ignore
|
// localStorage full or unavailable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = ref<MqttConnection[]>(loadConnections())
|
const connections = ref<MqttConnection[]>(loadConnections())
|
||||||
|
|
||||||
// Auto-persist on changes
|
|
||||||
watch(
|
watch(
|
||||||
connections,
|
connections,
|
||||||
(val) => {
|
(val) => {
|
||||||
@@ -42,7 +41,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export function useConnections() {
|
export function useConnections() {
|
||||||
function add() {
|
function add(): MqttConnection {
|
||||||
const conn = defaultConnection()
|
const conn = defaultConnection()
|
||||||
connections.value.push(conn)
|
connections.value.push(conn)
|
||||||
return conn
|
return conn
|
||||||
@@ -82,6 +81,79 @@ export function useConnections() {
|
|||||||
return connections.value.filter((c) => c.status === 'connected')
|
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 {
|
return {
|
||||||
connections,
|
connections,
|
||||||
add,
|
add,
|
||||||
@@ -90,5 +162,11 @@ export function useConnections() {
|
|||||||
getById,
|
getById,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
getConnected,
|
getConnected,
|
||||||
|
getGroups,
|
||||||
|
getByGroup,
|
||||||
|
renameGroup,
|
||||||
|
deleteGroup,
|
||||||
|
exportConnections,
|
||||||
|
importConnections,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export async function mqttTestConnection(conn: MqttConnectionDto): Promise<strin
|
|||||||
return await invoke<string>('mqtt_test_connection', { conn })
|
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> {
|
export async function mqttGetLogDir(): Promise<string> {
|
||||||
return await invoke<string>('mqtt_get_log_dir')
|
return await invoke<string>('mqtt_get_log_dir')
|
||||||
}
|
}
|
||||||
+101
-1
@@ -7,6 +7,98 @@ export interface Subscription {
|
|||||||
qos: number
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Connection ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MqttConnection {
|
export interface MqttConnection {
|
||||||
@@ -17,10 +109,14 @@ export interface MqttConnection {
|
|||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
clientId: string
|
clientId: string
|
||||||
|
/** MQTT 协议版本:3.1.1 / 5.0 */
|
||||||
|
protocolVersion: '3.1.1' | '5.0'
|
||||||
cleanStart: boolean
|
cleanStart: boolean
|
||||||
sessionExpiryInterval: number
|
sessionExpiryInterval: number
|
||||||
keepAlive: number
|
keepAlive: number
|
||||||
ssl: boolean
|
ssl: boolean
|
||||||
|
/** 分组名称,空字符串表示未分组 */
|
||||||
|
group: string
|
||||||
receiveMaximum: number | null
|
receiveMaximum: number | null
|
||||||
maximumPacketSize: number | null
|
maximumPacketSize: number | null
|
||||||
topicAliasMaximum: number | null
|
topicAliasMaximum: number | null
|
||||||
@@ -38,10 +134,12 @@ export function defaultConnection(): MqttConnection {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
|
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
protocolVersion: '5.0',
|
||||||
cleanStart: true,
|
cleanStart: true,
|
||||||
sessionExpiryInterval: 0,
|
sessionExpiryInterval: 0,
|
||||||
keepAlive: 60,
|
keepAlive: 60,
|
||||||
ssl: false,
|
ssl: false,
|
||||||
|
group: '',
|
||||||
receiveMaximum: null,
|
receiveMaximum: null,
|
||||||
maximumPacketSize: null,
|
maximumPacketSize: null,
|
||||||
topicAliasMaximum: null,
|
topicAliasMaximum: null,
|
||||||
@@ -112,6 +210,7 @@ export interface MqttConnectionDto {
|
|||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
client_id: string
|
client_id: string
|
||||||
|
protocol_version: string
|
||||||
clean_start: boolean
|
clean_start: boolean
|
||||||
session_expiry_interval: number
|
session_expiry_interval: number
|
||||||
keep_alive: number
|
keep_alive: number
|
||||||
@@ -130,6 +229,7 @@ export function toConnectionDto(conn: MqttConnection): MqttConnectionDto {
|
|||||||
username: conn.username,
|
username: conn.username,
|
||||||
password: conn.password,
|
password: conn.password,
|
||||||
client_id: conn.clientId,
|
client_id: conn.clientId,
|
||||||
|
protocol_version: conn.protocolVersion,
|
||||||
clean_start: conn.cleanStart,
|
clean_start: conn.cleanStart,
|
||||||
session_expiry_interval: conn.sessionExpiryInterval,
|
session_expiry_interval: conn.sessionExpiryInterval,
|
||||||
keep_alive: conn.keepAlive,
|
keep_alive: conn.keepAlive,
|
||||||
@@ -197,4 +297,4 @@ export interface MqttLogEvent {
|
|||||||
event: string
|
event: string
|
||||||
message: string
|
message: string
|
||||||
details: string | null
|
details: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user