Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
037694f6f8 | ||
|
|
f2b7af5824 | ||
|
|
0f89e142e9 | ||
|
|
9723495f12 | ||
|
|
4aaf584451 | ||
|
|
330a9a48fd | ||
|
|
e233911d5e | ||
|
|
821b895d04 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frost-mqtt-client",
|
"name": "frost-mqtt-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
|
"description": "一个使用 AI 开发的多功能 MQTT 桌面客户端,基于 Electron + Vue 3 + Naive UI 构建。",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Frost-ZX",
|
"author": "Frost-ZX",
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ export function registerIpcHandlers() {
|
|||||||
return mqttManager.importServers(serverList);
|
return mqttManager.importServers(serverList);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 快速发送 ====================
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:get-quick-send-list', () => {
|
||||||
|
return mqttManager.getQuickSendList();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:save-quick-send-list', (_, list) => {
|
||||||
|
mqttManager.saveQuickSendList(list);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 连接管理 ====================
|
// ==================== 连接管理 ====================
|
||||||
|
|
||||||
ipcMain.handle('mqtt:connect', (_, id) => {
|
ipcMain.handle('mqtt:connect', (_, id) => {
|
||||||
@@ -140,4 +151,26 @@ export function registerIpcHandlers() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 安全管理 ====================
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:get-security-status', () => {
|
||||||
|
return mqttManager.getSecurityStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:setup-master-password', (_, password) => {
|
||||||
|
return mqttManager.setupMasterPassword(password);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:unlock', (_, password) => {
|
||||||
|
return mqttManager.unlock(password);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:lock', () => {
|
||||||
|
return mqttManager.lock();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('mqtt:remove-master-password', (_, password) => {
|
||||||
|
return mqttManager.removeMasterPassword(password);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { createPasswordVerifier, decryptSecret, deriveKey, encryptSecret, verifyPassword } from './security.js';
|
||||||
|
|
||||||
import mqtt from 'mqtt';
|
import mqtt from 'mqtt';
|
||||||
|
|
||||||
@@ -35,6 +37,10 @@ class MqttManager {
|
|||||||
this.listeners = new Set(); // IPC callback functions
|
this.listeners = new Set(); // IPC callback functions
|
||||||
this.msgIdCounter = 0; // 用于生成唯一消息 id
|
this.msgIdCounter = 0; // 用于生成唯一消息 id
|
||||||
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json');
|
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json');
|
||||||
|
this.quickSendList = [];
|
||||||
|
this.security = null;
|
||||||
|
this.masterKey = null;
|
||||||
|
this.locked = false;
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,16 +82,30 @@ class MqttManager {
|
|||||||
let raw = readFileSync(this.dataPath, 'utf-8');
|
let raw = readFileSync(this.dataPath, 'utf-8');
|
||||||
let data = JSON.parse(raw);
|
let data = JSON.parse(raw);
|
||||||
|
|
||||||
|
this.security = data.security || null;
|
||||||
|
this.locked = Boolean(this.security?.enabled);
|
||||||
|
|
||||||
if (data.servers) {
|
if (data.servers) {
|
||||||
data.servers.forEach((s) => {
|
data.servers.forEach((s) => {
|
||||||
|
|
||||||
|
let encryptedPassword = s.passwordEncrypted || '';
|
||||||
|
|
||||||
s.status = 'disconnected';
|
s.status = 'disconnected';
|
||||||
|
s.password = this.locked ? '' : (s.password || '');
|
||||||
|
|
||||||
|
if (encryptedPassword) {
|
||||||
|
s.passwordEncrypted = encryptedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
s.topics = (s.topics || []).map((t) => ({
|
s.topics = (s.topics || []).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
|
||||||
comment: t.comment ?? '',
|
comment: t.comment ?? '',
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.servers.set(s.id, s);
|
this.servers.set(s.id, s);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +121,12 @@ class MqttManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.quickSendList)) {
|
||||||
|
this.quickSendList = data.quickSendList.filter((item) => {
|
||||||
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.initDefaultServers();
|
this.initDefaultServers();
|
||||||
}
|
}
|
||||||
@@ -117,13 +143,19 @@ class MqttManager {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
let data = {
|
let data = {
|
||||||
|
security: this.security,
|
||||||
servers: Array.from(this.servers.values()).map((s) => ({
|
servers: Array.from(this.servers.values()).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
|
password: this.security?.enabled ? undefined : (s.password || ''),
|
||||||
|
passwordEncrypted: this.security?.enabled && s.password
|
||||||
|
? encryptSecret(s.password, this.masterKey)
|
||||||
|
: (this.security?.enabled ? s.passwordEncrypted || '' : undefined),
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
topics: s.topics.map((t) => ({ ...t, subscribed: false })),
|
topics: s.topics.map((t) => ({ ...t, subscribed: false })),
|
||||||
})),
|
})),
|
||||||
messages: Object.fromEntries(this.messages),
|
messages: Object.fromEntries(this.messages),
|
||||||
publishHistory: Object.fromEntries(this.publishHistory),
|
publishHistory: Object.fromEntries(this.publishHistory),
|
||||||
|
quickSendList: this.quickSendList,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8');
|
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
@@ -206,11 +238,109 @@ class MqttManager {
|
|||||||
|
|
||||||
// ==================== 服务器管理 ====================
|
// ==================== 服务器管理 ====================
|
||||||
|
|
||||||
|
getSecurityStatus() {
|
||||||
|
return { enabled: Boolean(this.security?.enabled), locked: this.locked };
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMasterPassword(password) {
|
||||||
|
|
||||||
|
this.assertPassword(password);
|
||||||
|
|
||||||
|
if (this.security?.enabled) {
|
||||||
|
throw new Error('主密码已设置');
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = randomBytes(16).toString('base64');
|
||||||
|
|
||||||
|
this.masterKey = deriveKey(password, Buffer.from(salt, 'base64'));
|
||||||
|
this.security = { enabled: true, salt, verifier: createPasswordVerifier(this.masterKey) };
|
||||||
|
this.locked = false;
|
||||||
|
this.saveData();
|
||||||
|
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(password) {
|
||||||
|
|
||||||
|
if (!this.security?.enabled) {
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = deriveKey(password, Buffer.from(this.security.salt, 'base64'));
|
||||||
|
|
||||||
|
if (!verifyPassword(key, this.security.verifier)) {
|
||||||
|
throw new Error('主密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.servers.forEach((server) => {
|
||||||
|
server.password = server.passwordEncrypted ? decryptSecret(server.passwordEncrypted, key) : '';
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('服务器密码解密失败,数据可能已损坏');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.masterKey = key;
|
||||||
|
this.locked = false;
|
||||||
|
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
|
||||||
|
if (!this.security?.enabled || this.locked) {
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clients.forEach((_, id) => this.disconnect(id));
|
||||||
|
this.servers.forEach((server) => {
|
||||||
|
server.passwordEncrypted = server.password
|
||||||
|
? encryptSecret(server.password, this.masterKey)
|
||||||
|
: server.passwordEncrypted || '';
|
||||||
|
server.password = '';
|
||||||
|
});
|
||||||
|
this.saveData();
|
||||||
|
this.masterKey = null;
|
||||||
|
this.locked = true;
|
||||||
|
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMasterPassword(password) {
|
||||||
|
|
||||||
|
if (!this.security?.enabled) {
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unlock(password);
|
||||||
|
this.security = null;
|
||||||
|
this.masterKey = null;
|
||||||
|
this.locked = false;
|
||||||
|
this.saveData();
|
||||||
|
|
||||||
|
return this.getSecurityStatus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPassword(password) {
|
||||||
|
if (typeof password !== 'string' || password.length < 4) {
|
||||||
|
throw new Error('主密码至少需要 4 个字符');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertUnlocked() {
|
||||||
|
if (this.locked) throw new Error('应用已锁定,请先解锁');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有服务器列表
|
* 获取所有服务器列表
|
||||||
* @returns {MqttServer[]} 服务器配置数组
|
* @returns {MqttServer[]} 服务器配置数组
|
||||||
*/
|
*/
|
||||||
getServers() {
|
getServers() {
|
||||||
|
this.assertUnlocked();
|
||||||
return Array.from(this.servers.values()).map((s) => ({
|
return Array.from(this.servers.values()).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
password: s.password || '',
|
password: s.password || '',
|
||||||
@@ -223,6 +353,7 @@ class MqttManager {
|
|||||||
* @returns {MqttServer | null} 服务器配置或 null
|
* @returns {MqttServer | null} 服务器配置或 null
|
||||||
*/
|
*/
|
||||||
getServer(id) {
|
getServer(id) {
|
||||||
|
this.assertUnlocked();
|
||||||
return this.servers.get(id) || null;
|
return this.servers.get(id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +416,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
addServer(config) {
|
addServer(config) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||||
let server = {
|
let server = {
|
||||||
id,
|
id,
|
||||||
@@ -310,6 +443,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
updateServer(id, config) {
|
updateServer(id, config) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(id);
|
let server = this.servers.get(id);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -329,6 +464,7 @@ class MqttManager {
|
|||||||
* @param {string} id - 服务器 id
|
* @param {string} id - 服务器 id
|
||||||
*/
|
*/
|
||||||
deleteServer(id) {
|
deleteServer(id) {
|
||||||
|
this.assertUnlocked();
|
||||||
// 先断开连接
|
// 先断开连接
|
||||||
this.disconnect(id);
|
this.disconnect(id);
|
||||||
this.servers.delete(id);
|
this.servers.delete(id);
|
||||||
@@ -342,7 +478,11 @@ class MqttManager {
|
|||||||
* @returns {ServerConfig[]} 服务器配置数组
|
* @returns {ServerConfig[]} 服务器配置数组
|
||||||
*/
|
*/
|
||||||
exportServers() {
|
exportServers() {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let schemaKeys = Object.keys(this.getServerConfigSchema());
|
let schemaKeys = Object.keys(this.getServerConfigSchema());
|
||||||
|
|
||||||
return Array.from(this.servers.values()).map((s) => {
|
return Array.from(this.servers.values()).map((s) => {
|
||||||
let exported = {};
|
let exported = {};
|
||||||
for (let key of schemaKeys) {
|
for (let key of schemaKeys) {
|
||||||
@@ -351,6 +491,7 @@ class MqttManager {
|
|||||||
exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t));
|
exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t));
|
||||||
return exported;
|
return exported;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,6 +514,9 @@ class MqttManager {
|
|||||||
* @returns {MqttServer[]} 导入后的服务器列表
|
* @returns {MqttServer[]} 导入后的服务器列表
|
||||||
*/
|
*/
|
||||||
importServers(serverList) {
|
importServers(serverList) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
if (!Array.isArray(serverList)) {
|
if (!Array.isArray(serverList)) {
|
||||||
throw new Error('导入格式错误:必须是服务器配置数组');
|
throw new Error('导入格式错误:必须是服务器配置数组');
|
||||||
}
|
}
|
||||||
@@ -388,6 +532,7 @@ class MqttManager {
|
|||||||
this.publishHistory.clear();
|
this.publishHistory.clear();
|
||||||
|
|
||||||
serverList.forEach((config) => {
|
serverList.forEach((config) => {
|
||||||
|
|
||||||
if (!config.name || !config.host) {
|
if (!config.name || !config.host) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,10 +548,44 @@ class MqttManager {
|
|||||||
this.servers.set(id, server);
|
this.servers.set(id, server);
|
||||||
this.messages.set(id, []);
|
this.messages.set(id, []);
|
||||||
this.publishHistory.set(id, []);
|
this.publishHistory.set(id, []);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveData();
|
this.saveData();
|
||||||
|
|
||||||
return this.getServers();
|
return this.getServers();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 快速发送 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取快速发送列表
|
||||||
|
* @returns {QuickSendItem[]} 快速发送项数组
|
||||||
|
*/
|
||||||
|
getQuickSendList() {
|
||||||
|
this.assertUnlocked();
|
||||||
|
return this.quickSendList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存快速发送列表
|
||||||
|
* @param {QuickSendItem[]} list - 快速发送项数组
|
||||||
|
*/
|
||||||
|
saveQuickSendList(list) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.quickSendList = list.filter((item) => {
|
||||||
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saveData();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 连接管理 ====================
|
// ==================== 连接管理 ====================
|
||||||
@@ -418,6 +597,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
connect(id) {
|
connect(id) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(id);
|
let server = this.servers.get(id);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -519,6 +700,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
disconnect(id) {
|
disconnect(id) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let client = this.clients.get(id);
|
let client = this.clients.get(id);
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
@@ -582,10 +765,15 @@ class MqttManager {
|
|||||||
* @returns {MqttTopic | null} 添加后的主题对象或 null
|
* @returns {MqttTopic | null} 添加后的主题对象或 null
|
||||||
*/
|
*/
|
||||||
addTopic(serverId, topic, qos = 1, comment = '') {
|
addTopic(serverId, topic, qos = 1, comment = '') {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.topics.find((t) => t.topic === topic)) {
|
if (server.topics.find((t) => t.topic === topic)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -614,6 +802,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
removeTopic(serverId, topic) {
|
removeTopic(serverId, topic) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -639,6 +829,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
subscribe(serverId, topic) {
|
subscribe(serverId, topic) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -663,6 +855,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
unsubscribe(serverId, topic) {
|
unsubscribe(serverId, topic) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let client = this.clients.get(serverId);
|
let client = this.clients.get(serverId);
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
@@ -695,6 +889,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
updateTopic(serverId, topicId, newTopic, newQos, comment = '') {
|
updateTopic(serverId, topicId, newTopic, newQos, comment = '') {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -753,6 +949,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
subscribeAll(serverId) {
|
subscribeAll(serverId) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let client = this.clients.get(serverId);
|
let client = this.clients.get(serverId);
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
@@ -803,6 +1001,8 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
publish(serverId, topic, payload, opts = {}) {
|
publish(serverId, topic, payload, opts = {}) {
|
||||||
|
|
||||||
|
this.assertUnlocked();
|
||||||
|
|
||||||
let client = this.clients.get(serverId);
|
let client = this.clients.get(serverId);
|
||||||
let server = this.servers.get(serverId);
|
let server = this.servers.get(serverId);
|
||||||
|
|
||||||
@@ -882,6 +1082,7 @@ class MqttManager {
|
|||||||
* @returns {MqttMessage[]} 消息数组
|
* @returns {MqttMessage[]} 消息数组
|
||||||
*/
|
*/
|
||||||
getMessages(serverId) {
|
getMessages(serverId) {
|
||||||
|
this.assertUnlocked();
|
||||||
return this.messages.get(serverId) || [];
|
return this.messages.get(serverId) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +1092,7 @@ class MqttManager {
|
|||||||
* @returns {PublishRecord[]} 发布历史数组
|
* @returns {PublishRecord[]} 发布历史数组
|
||||||
*/
|
*/
|
||||||
getPublishHistory(serverId) {
|
getPublishHistory(serverId) {
|
||||||
|
this.assertUnlocked();
|
||||||
return this.publishHistory.get(serverId) || [];
|
return this.publishHistory.get(serverId) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,6 +1101,7 @@ class MqttManager {
|
|||||||
* @param {string} serverId - 服务器 id
|
* @param {string} serverId - 服务器 id
|
||||||
*/
|
*/
|
||||||
clearMessages(serverId) {
|
clearMessages(serverId) {
|
||||||
|
this.assertUnlocked();
|
||||||
this.messages.set(serverId, []);
|
this.messages.set(serverId, []);
|
||||||
this.saveData();
|
this.saveData();
|
||||||
}
|
}
|
||||||
@@ -908,6 +1111,7 @@ class MqttManager {
|
|||||||
* @param {string} serverId - 服务器 id
|
* @param {string} serverId - 服务器 id
|
||||||
*/
|
*/
|
||||||
clearPublishHistory(serverId) {
|
clearPublishHistory(serverId) {
|
||||||
|
this.assertUnlocked();
|
||||||
this.publishHistory.set(serverId, []);
|
this.publishHistory.set(serverId, []);
|
||||||
this.saveData();
|
this.saveData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const VERIFIER = 'frost-mqtt-master-password-v1';
|
||||||
|
|
||||||
|
export function deriveKey(password, salt) {
|
||||||
|
return scryptSync(password, salt, KEY_LENGTH, { N: 16384, r: 8, p: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPasswordVerifier(key) {
|
||||||
|
return createHash('sha256').update(key).update(VERIFIER).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(key, verifier) {
|
||||||
|
let expected = Buffer.from(createPasswordVerifier(key), 'hex');
|
||||||
|
let actual = Buffer.from(verifier || '', 'hex');
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(secret, key) {
|
||||||
|
let iv = randomBytes(12);
|
||||||
|
let cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
let encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
||||||
|
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(value, key) {
|
||||||
|
let parts = String(value || '').split(':');
|
||||||
|
if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('加密数据格式无效');
|
||||||
|
let decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(parts[3], 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import { electronAPI } from '@electron-toolkit/preload';
|
|||||||
const api = {
|
const api = {
|
||||||
|
|
||||||
// 服务器管理
|
// 服务器管理
|
||||||
|
getSecurityStatus: () => ipcRenderer.invoke('mqtt:get-security-status'),
|
||||||
|
setupMasterPassword: (password) => ipcRenderer.invoke('mqtt:setup-master-password', password),
|
||||||
|
unlock: (password) => ipcRenderer.invoke('mqtt:unlock', password),
|
||||||
|
lock: () => ipcRenderer.invoke('mqtt:lock'),
|
||||||
|
removeMasterPassword: (password) => ipcRenderer.invoke('mqtt:remove-master-password', password),
|
||||||
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
|
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
|
||||||
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
|
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
|
||||||
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
|
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
|
||||||
@@ -15,6 +20,10 @@ const api = {
|
|||||||
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
|
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
|
||||||
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
|
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
|
||||||
|
|
||||||
|
// 快速发送
|
||||||
|
getQuickSendList: () => ipcRenderer.invoke('mqtt:get-quick-send-list'),
|
||||||
|
saveQuickSendList: (list) => ipcRenderer.invoke('mqtt:save-quick-send-list', list),
|
||||||
|
|
||||||
// 连接管理
|
// 连接管理
|
||||||
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
|
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
|
||||||
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
|
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui';
|
import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, NCard, NInput, NButton, NSpace, zhCN, dateZhCN } from 'naive-ui';
|
||||||
import { useMqttStore } from './stores/mqtt.js';
|
import { useMqttStore } from './stores/mqtt.js';
|
||||||
|
|
||||||
import Sidebar from './components/Sidebar.vue';
|
import Sidebar from './components/Sidebar.vue';
|
||||||
import ServerModal from './components/ServerModal.vue';
|
import ServerModal from './components/ServerModal.vue';
|
||||||
|
import SecurityModal from './components/SecurityModal.vue';
|
||||||
import Dashboard from './views/Dashboard.vue';
|
import Dashboard from './views/Dashboard.vue';
|
||||||
import Subscribe from './views/Subscribe.vue';
|
import Subscribe from './views/Subscribe.vue';
|
||||||
import Publish from './views/Publish.vue';
|
import Publish from './views/Publish.vue';
|
||||||
@@ -40,13 +41,19 @@ const themeOverrides = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 当前激活的标签页
|
/** 当前激活的标签页 */
|
||||||
const activeTab = ref('dashboard');
|
const activeTab = ref('dashboard');
|
||||||
// 是否显示服务器编辑弹窗
|
|
||||||
|
/** 是否显示服务器编辑弹窗 */
|
||||||
const showServerModal = ref(false);
|
const showServerModal = ref(false);
|
||||||
// 当前编辑的服务器数据,null 表示新增
|
|
||||||
|
/** 当前编辑的服务器数据,null 表示新增 */
|
||||||
const editServerData = ref(null);
|
const editServerData = ref(null);
|
||||||
|
|
||||||
|
const showSecurityModal = ref(false);
|
||||||
|
const unlockPassword = ref('');
|
||||||
|
const unlockError = ref('');
|
||||||
|
|
||||||
// 组件挂载时初始化 Store
|
// 组件挂载时初始化 Store
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.init();
|
await store.init();
|
||||||
@@ -76,6 +83,16 @@ function handleServerSaved() {
|
|||||||
// 刷新数据
|
// 刷新数据
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUnlock() {
|
||||||
|
try {
|
||||||
|
await store.unlock(unlockPassword.value);
|
||||||
|
unlockPassword.value = '';
|
||||||
|
unlockError.value = '';
|
||||||
|
} catch (e) {
|
||||||
|
unlockError.value = e.message || '解锁失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换标签页
|
* 切换标签页
|
||||||
* @param {string} tab - 标签页名称
|
* @param {string} tab - 标签页名称
|
||||||
@@ -103,10 +120,15 @@ function handleSwitchTab(tab) {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
@add-server="handleAddServer"
|
@add-server="handleAddServer"
|
||||||
@edit-server="handleEditServer"
|
@edit-server="handleEditServer"
|
||||||
|
@lock="store.lock"
|
||||||
|
@security="showSecurityModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<n-layout content-class="main-layout-content">
|
<n-layout
|
||||||
|
v-if="!store.security.value.locked"
|
||||||
|
content-class="main-layout-content"
|
||||||
|
>
|
||||||
<n-tabs
|
<n-tabs
|
||||||
v-model:value="activeTab"
|
v-model:value="activeTab"
|
||||||
type="line"
|
type="line"
|
||||||
@@ -165,8 +187,41 @@ function handleSwitchTab(tab) {
|
|||||||
:edit-server="editServerData"
|
:edit-server="editServerData"
|
||||||
@saved="handleServerSaved"
|
@saved="handleServerSaved"
|
||||||
/>
|
/>
|
||||||
|
<SecurityModal v-model:show="showSecurityModal" />
|
||||||
|
|
||||||
</n-layout>
|
</n-layout>
|
||||||
|
<div
|
||||||
|
v-if="store.security.value.locked"
|
||||||
|
class="lock-screen"
|
||||||
|
>
|
||||||
|
<n-card
|
||||||
|
title="应用已锁定"
|
||||||
|
class="lock-card"
|
||||||
|
>
|
||||||
|
<p>请输入主密码以继续使用 Frost MQTT Client</p>
|
||||||
|
<n-input
|
||||||
|
v-model:value="unlockPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="handleUnlock"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="unlockError"
|
||||||
|
class="lock-error"
|
||||||
|
>{{ unlockError }}</div>
|
||||||
|
<n-space
|
||||||
|
justify="end"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!unlockPassword"
|
||||||
|
@click="handleUnlock"
|
||||||
|
>解锁</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
</n-config-provider>
|
</n-config-provider>
|
||||||
@@ -215,6 +270,22 @@ html, body, #app {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lock-screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--n-color) 88%, transparent);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-card {
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 0 16px 0;
|
padding: 0 0 16px 0;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { NModal, NCard, NForm, NFormItem, NInput, NButton, NSpace, useMessage } from 'naive-ui';
|
||||||
|
import { useMqttStore } from '../stores/mqtt.js';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show']);
|
||||||
|
const store = useMqttStore();
|
||||||
|
const msg = useMessage();
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
|
||||||
|
if (password.value.length < 4 || password.value !== confirmPassword.value) {
|
||||||
|
msg.error(password.value.length < 4 ? '主密码至少需要 4 个字符' : '两次输入的主密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.setupMasterPassword(password.value);
|
||||||
|
password.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
emit('update:show', false);
|
||||||
|
msg.success('主密码已设置,服务器密码已加密保存');
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e.message || '设置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
try {
|
||||||
|
await store.removeMasterPassword(password.value);
|
||||||
|
password.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
emit('update:show', false);
|
||||||
|
msg.success('主密码已移除');
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e.message || '移除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
:mask-closable="false"
|
||||||
|
@update:show="$emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-card
|
||||||
|
style="width: 420px; max-width: 90vw"
|
||||||
|
:title="store.security.value.enabled ? '移除主密码' : '设置主密码'"
|
||||||
|
>
|
||||||
|
<n-form label-placement="top">
|
||||||
|
<n-form-item label="主密码">
|
||||||
|
<n-input
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item
|
||||||
|
v-if="!store.security.value.enabled"
|
||||||
|
label="确认主密码"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
@keyup.enter="save"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="store.security.value.enabled"
|
||||||
|
type="error"
|
||||||
|
@click="remove"
|
||||||
|
>移除并解密保存</n-button>
|
||||||
|
<n-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
@click="save"
|
||||||
|
>设置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,7 @@ import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui';
|
|||||||
import { useMqttStore } from '../stores/mqtt.js';
|
import { useMqttStore } from '../stores/mqtt.js';
|
||||||
|
|
||||||
// 组件事件定义
|
// 组件事件定义
|
||||||
const emit = defineEmits(['add-server', 'edit-server']);
|
const emit = defineEmits(['add-server', 'edit-server', 'lock', 'security']);
|
||||||
|
|
||||||
// 全局状态与消息/对话框 API
|
// 全局状态与消息/对话框 API
|
||||||
const store = useMqttStore();
|
const store = useMqttStore();
|
||||||
@@ -203,6 +203,22 @@ async function handleImportFile(event) {
|
|||||||
@click="handleExport"
|
@click="handleExport"
|
||||||
>导出配置</n-button>
|
>导出配置</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="sidebar-import-export"
|
||||||
|
:class="{ 'sidebar-single-action': !store.security.value.enabled }"
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-if="store.security.value.enabled"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
@click="emit('lock')"
|
||||||
|
>锁定应用</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
@click="emit('security')"
|
||||||
|
>{{ store.security.value.enabled ? '主密码设置' : '设置主密码' }}</n-button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -389,4 +405,8 @@ async function handleImportFile(event) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-single-action {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const publishHistory = ref([]);
|
|||||||
*/
|
*/
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const security = ref({ enabled: false, locked: false });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IPC 监听器清理函数集合
|
* IPC 监听器清理函数集合
|
||||||
* @type {Array<() => void>}
|
* @type {Array<() => void>}
|
||||||
@@ -264,6 +266,12 @@ export function useMqttStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
security.value = await window.api.getSecurityStatus();
|
||||||
|
|
||||||
|
if (security.value.locked) {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let data = await window.api.getServers();
|
let data = await window.api.getServers();
|
||||||
|
|
||||||
servers.value = data;
|
servers.value = data;
|
||||||
@@ -279,6 +287,32 @@ export function useMqttStore() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function unlock(password) {
|
||||||
|
await window.api.unlock(password);
|
||||||
|
security.value = await window.api.getSecurityStatus();
|
||||||
|
let data = await window.api.getServers();
|
||||||
|
servers.value = data;
|
||||||
|
sortServers();
|
||||||
|
servers.value.forEach((srv) => sortTopics(srv));
|
||||||
|
setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupMasterPassword(password) {
|
||||||
|
security.value = await window.api.setupMasterPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lock() {
|
||||||
|
security.value = await window.api.lock();
|
||||||
|
servers.value = [];
|
||||||
|
activeServerId.value = null;
|
||||||
|
messages.value = [];
|
||||||
|
publishHistory.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMasterPassword(password) {
|
||||||
|
security.value = await window.api.removeMasterPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 服务器管理 ====================
|
// ==================== 服务器管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -558,6 +592,7 @@ export function useMqttStore() {
|
|||||||
messages,
|
messages,
|
||||||
publishHistory,
|
publishHistory,
|
||||||
loading,
|
loading,
|
||||||
|
security,
|
||||||
// 计算属性
|
// 计算属性
|
||||||
connectedServers,
|
connectedServers,
|
||||||
totalTopics,
|
totalTopics,
|
||||||
@@ -565,6 +600,10 @@ export function useMqttStore() {
|
|||||||
totalMessages,
|
totalMessages,
|
||||||
// 方法
|
// 方法
|
||||||
init,
|
init,
|
||||||
|
unlock,
|
||||||
|
setupMasterPassword,
|
||||||
|
lock,
|
||||||
|
removeMasterPassword,
|
||||||
setActiveServer,
|
setActiveServer,
|
||||||
addServer,
|
addServer,
|
||||||
updateServer,
|
updateServer,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useMqttStore } from '../stores/mqtt.js';
|
import { useMqttStore } from '../stores/mqtt.js';
|
||||||
import { NCard, NSpace, NInput, NButton, NEmpty, useMessage } from 'naive-ui';
|
import { NCard, NSelect, NButton, NEmpty, useMessage } from 'naive-ui';
|
||||||
|
|
||||||
// 全局状态与消息 API
|
// 全局状态与消息 API
|
||||||
const store = useMqttStore();
|
const store = useMqttStore();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
// 主题过滤关键字
|
// 已选中的过滤主题列表
|
||||||
const filter = ref('');
|
const filter = ref([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制主题到剪贴板
|
* 复制主题到剪贴板
|
||||||
@@ -24,22 +24,65 @@ async function copyTopic(topic) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据过滤关键字筛选后的消息列表
|
* 可过滤的主题选项(来自已配置主题和已接收消息)
|
||||||
|
* @type {import('vue').ComputedRef<Array<{label: string, value: string}>>}
|
||||||
|
*/
|
||||||
|
const topicOptions = computed(() => {
|
||||||
|
|
||||||
|
let server = store.activeServer.value;
|
||||||
|
let serverTopics = server?.topics?.map((t) => t.topic) || [];
|
||||||
|
let messageTopics = store.messages.value.map((m) => m.topic);
|
||||||
|
let all = [...new Set([...serverTopics, ...messageTopics])];
|
||||||
|
|
||||||
|
all.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return all.map((topic) => ({ label: topic, value: topic }));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息主题是否匹配指定的过滤主题(支持 + 单层通配符与 # 多层通配符)
|
||||||
|
* @param {string} filterTopic - 过滤主题,可包含 + 或 #
|
||||||
|
* @param {string} messageTopic - 消息主题
|
||||||
|
* @returns {boolean} 是否匹配
|
||||||
|
*/
|
||||||
|
function topicMatchesFilter(filterTopic, messageTopic) {
|
||||||
|
|
||||||
|
if (filterTopic === '#') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTopic.endsWith('/#')) {
|
||||||
|
let prefix = filterTopic.slice(0, -2);
|
||||||
|
return messageTopic === prefix || messageTopic.startsWith(prefix + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterParts = filterTopic.split('/');
|
||||||
|
let messageParts = messageTopic.split('/');
|
||||||
|
|
||||||
|
if (filterParts.length !== messageParts.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterParts.every((part, index) => part === '+' || part === messageParts[index]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据选中主题筛选后的消息列表(支持 + / # 通配符)
|
||||||
* @type {import('vue').ComputedRef<Array<Object>>}
|
* @type {import('vue').ComputedRef<Array<Object>>}
|
||||||
*/
|
*/
|
||||||
const filteredMessages = computed(() => {
|
const filteredMessages = computed(() => {
|
||||||
|
|
||||||
if (!filter.value.trim()) {
|
if (!filter.value || filter.value.length === 0) {
|
||||||
return store.messages.value;
|
return store.messages.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
let f = filter.value.trim().toLowerCase();
|
return store.messages.value.filter((m) =>
|
||||||
|
filter.value.some((f) => topicMatchesFilter(f, m.topic))
|
||||||
|
);
|
||||||
|
|
||||||
return store.messages.value.filter((m) => {
|
});
|
||||||
return m.topic.toLowerCase().includes(f);
|
|
||||||
});
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空当前活动服务器的消息列表
|
* 清空当前活动服务器的消息列表
|
||||||
@@ -79,10 +122,8 @@ function formatPayload(payload) {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-space
|
<div
|
||||||
align="center"
|
style="display: flex; flex-direction: column; gap: 8px; width: 100%"
|
||||||
justify="space-between"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span>消息监控</span>
|
<span>消息监控</span>
|
||||||
@@ -90,20 +131,26 @@ function formatPayload(payload) {
|
|||||||
style="font-size: 12px; opacity: 0.55; margin-left: 8px"
|
style="font-size: 12px; opacity: 0.55; margin-left: 8px"
|
||||||
>实时查看接收和发送的 MQTT 消息</span>
|
>实时查看接收和发送的 MQTT 消息</span>
|
||||||
</div>
|
</div>
|
||||||
<n-space>
|
<div
|
||||||
<n-input
|
style="display: flex; align-items: center; gap: 8px; width: 100%"
|
||||||
|
>
|
||||||
|
<n-select
|
||||||
v-model:value="filter"
|
v-model:value="filter"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="过滤主题..."
|
placeholder="过滤主题..."
|
||||||
style="width: 180px"
|
style="flex: 1 1 auto; max-width: 480px; min-width: 0"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
clearable
|
clearable
|
||||||
|
max-tag-count="responsive"
|
||||||
|
:options="topicOptions"
|
||||||
/>
|
/>
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleClear"
|
@click="handleClear"
|
||||||
>清空消息</n-button>
|
>清空消息</n-button>
|
||||||
</n-space>
|
</div>
|
||||||
</n-space>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="filteredMessages.length === 0"
|
v-if="filteredMessages.length === 0"
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ const store = useMqttStore();
|
|||||||
const msg = useMessage();
|
const msg = useMessage();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
|
|
||||||
// localStorage 持久化 key
|
// 旧版 localStorage key,用于数据迁移
|
||||||
const storageKey = 'mqtt-client.quick-send-list';
|
const oldStorageKey = 'mqtt-client.quick-send-list';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} QuickSendItem
|
* @typedef {Object} QuickSendItem
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
* @property {string} topic
|
* @property {string} topic
|
||||||
|
* @property {string} comment
|
||||||
* @property {string} payload
|
* @property {string} payload
|
||||||
* @property {0|1|2} qos
|
* @property {0|1|2} qos
|
||||||
* @property {boolean} retain
|
* @property {boolean} retain
|
||||||
@@ -24,6 +25,9 @@ const storageKey = 'mqtt-client.quick-send-list';
|
|||||||
/** @type {import('vue').Ref<QuickSendItem[]>} */
|
/** @type {import('vue').Ref<QuickSendItem[]>} */
|
||||||
const quickList = ref([]);
|
const quickList = ref([]);
|
||||||
|
|
||||||
|
// 列表是否已从本地文件加载完成
|
||||||
|
const quickListLoaded = ref(false);
|
||||||
|
|
||||||
// 表单弹窗显隐
|
// 表单弹窗显隐
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
const isEdit = ref(false);
|
const isEdit = ref(false);
|
||||||
@@ -31,30 +35,38 @@ const isEdit = ref(false);
|
|||||||
// 表单数据
|
// 表单数据
|
||||||
const formId = ref('');
|
const formId = ref('');
|
||||||
const formTopic = ref('');
|
const formTopic = ref('');
|
||||||
|
const formComment = ref('');
|
||||||
const formPayload = ref('');
|
const formPayload = ref('');
|
||||||
const formQos = ref(1);
|
const formQos = ref(1);
|
||||||
const formRetain = ref(false);
|
const formRetain = ref(false);
|
||||||
|
|
||||||
// 从 localStorage 恢复列表
|
// 从旧版 localStorage 迁移数据
|
||||||
try {
|
function migrateFromLocalStorage() {
|
||||||
|
try {
|
||||||
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
let saved = JSON.parse(localStorage.getItem(oldStorageKey) || '[]');
|
||||||
|
if (Array.isArray(saved) && saved.length > 0) {
|
||||||
if (Array.isArray(saved)) {
|
quickList.value = saved.filter((item) => {
|
||||||
quickList.value = saved.filter((item) => {
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
});
|
||||||
});
|
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略旧数据解析错误
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem(oldStorageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(storageKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表变化时持久化
|
// 列表变化时持久化到本地文件
|
||||||
watch(
|
watch(
|
||||||
quickList,
|
quickList,
|
||||||
() => {
|
() => {
|
||||||
localStorage.setItem(storageKey, JSON.stringify(quickList.value));
|
if (!quickListLoaded.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.api.saveQuickSendList(JSON.parse(JSON.stringify(quickList.value))).catch((e) => {
|
||||||
|
console.error('保存快速发送列表失败:', e);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
@@ -111,6 +123,7 @@ function handleAdd() {
|
|||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
formId.value = '';
|
formId.value = '';
|
||||||
formTopic.value = '';
|
formTopic.value = '';
|
||||||
|
formComment.value = '';
|
||||||
formPayload.value = '';
|
formPayload.value = '';
|
||||||
formQos.value = 1;
|
formQos.value = 1;
|
||||||
formRetain.value = false;
|
formRetain.value = false;
|
||||||
@@ -125,6 +138,7 @@ function handleEdit(item) {
|
|||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
formId.value = item.id;
|
formId.value = item.id;
|
||||||
formTopic.value = item.topic;
|
formTopic.value = item.topic;
|
||||||
|
formComment.value = item.comment || '';
|
||||||
formPayload.value = item.payload;
|
formPayload.value = item.payload;
|
||||||
formQos.value = item.qos;
|
formQos.value = item.qos;
|
||||||
formRetain.value = item.retain;
|
formRetain.value = item.retain;
|
||||||
@@ -143,6 +157,7 @@ function handleSave() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let comment = formComment.value.trim();
|
||||||
let payload = formPayload.value;
|
let payload = formPayload.value;
|
||||||
let qos = formQos.value;
|
let qos = formQos.value;
|
||||||
let retain = formRetain.value;
|
let retain = formRetain.value;
|
||||||
@@ -155,6 +170,7 @@ function handleSave() {
|
|||||||
quickList.value[idx] = {
|
quickList.value[idx] = {
|
||||||
...quickList.value[idx],
|
...quickList.value[idx],
|
||||||
topic,
|
topic,
|
||||||
|
comment,
|
||||||
payload,
|
payload,
|
||||||
qos,
|
qos,
|
||||||
retain,
|
retain,
|
||||||
@@ -166,6 +182,7 @@ function handleSave() {
|
|||||||
quickList.value.push({
|
quickList.value.push({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
topic,
|
topic,
|
||||||
|
comment,
|
||||||
payload,
|
payload,
|
||||||
qos,
|
qos,
|
||||||
retain,
|
retain,
|
||||||
@@ -461,7 +478,23 @@ function updateColumnCount(width) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
let list = await window.api.getQuickSendList();
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
quickList.value = list.filter((item) => {
|
||||||
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (quickList.value.length === 0) {
|
||||||
|
migrateFromLocalStorage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载快速发送列表失败:', e);
|
||||||
|
} finally {
|
||||||
|
quickListLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
for (let entry of entries) {
|
for (let entry of entries) {
|
||||||
updateColumnCount(entry.contentRect.width);
|
updateColumnCount(entry.contentRect.width);
|
||||||
@@ -582,6 +615,11 @@ onUnmounted(() => {
|
|||||||
>删除</n-button>
|
>删除</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.comment"
|
||||||
|
class="quick-comment"
|
||||||
|
:title="item.comment"
|
||||||
|
>{{ item.comment }}</div>
|
||||||
<pre
|
<pre
|
||||||
v-if="item.payload"
|
v-if="item.payload"
|
||||||
class="quick-payload"
|
class="quick-payload"
|
||||||
@@ -616,6 +654,12 @@ onUnmounted(() => {
|
|||||||
placeholder="例如: sensor/device01/temperature"
|
placeholder="例如: sensor/device01/temperature"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
<n-form-item label="备注">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formComment"
|
||||||
|
placeholder="可选,用于标识该快速消息的用途"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
<n-grid
|
<n-grid
|
||||||
cols="2"
|
cols="2"
|
||||||
x-gap="12"
|
x-gap="12"
|
||||||
@@ -692,21 +736,21 @@ onUnmounted(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
|
||||||
&.columns-1 {
|
&.columns-1 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.columns-2 {
|
&.columns-2 {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
&.columns-3 {
|
&.columns-3 {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +845,15 @@ onUnmounted(() => {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-comment {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--frost-color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-payload {
|
.quick-payload {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 快速发送列表持久化到本地文件 `frost-mqtt-data.json`。
|
||||||
|
- 快速消息支持备注。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 解决“快速发送”模块中,主题名称较长时,外层卡片缩小到一定程度后不能再随外层容器缩小而缩小的问题。
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-07-21
|
||||||
|
|
||||||
|
- 初始版本发布。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- [X] 过滤主题功能支持下拉
|
||||||
|
- [X] 订阅主题备注
|
||||||
Reference in New Issue
Block a user