7 Commits
8 changed files with 209 additions and 42 deletions
+1 -1
View File
@@ -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",
+11
View File
@@ -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) => {
+32
View File
@@ -35,6 +35,7 @@ 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.loadData(); this.loadData();
} }
@@ -101,6 +102,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();
} }
@@ -124,6 +131,7 @@ class MqttManager {
})), })),
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');
@@ -409,6 +417,30 @@ class MqttManager {
return this.getServers(); return this.getServers();
} }
// ==================== 快速发送 ====================
/**
* 获取快速发送列表
* @returns {QuickSendItem[]} 快速发送项数组
*/
getQuickSendList() {
return this.quickSendList;
}
/**
* 保存快速发送列表
* @param {QuickSendItem[]} list - 快速发送项数组
*/
saveQuickSendList(list) {
if (!Array.isArray(list)) {
return;
}
this.quickSendList = list.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
this.saveData();
}
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
/** /**
+4
View File
@@ -15,6 +15,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),
+66 -19
View File
@@ -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,23 +24,66 @@ 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"
+69 -16
View File
@@ -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 迁移数据
function migrateFromLocalStorage() {
try { try {
let saved = JSON.parse(localStorage.getItem(oldStorageKey) || '[]');
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]'); 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 { } catch {
localStorage.removeItem(storageKey); // 忽略旧数据解析错误
} finally {
localStorage.removeItem(oldStorageKey);
}
} }
// 列表变化时持久化 // 列表变化时持久化到本地文件
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;
+16
View File
@@ -0,0 +1,16 @@
# CHANGELOG
## [1.0.1] - 2026-07-21
### Added
- 快速发送列表持久化到本地文件 `frost-mqtt-data.json`。
- 快速消息支持备注。
### Fixed
- 解决“快速发送”模块中,主题名称较长时,外层卡片缩小到一定程度后不能再随外层容器缩小而缩小的问题。
## [1.0.0] - 2026-07-21
- 初始版本发布。
+4
View File
@@ -0,0 +1,4 @@
# TODO
- [X] 过滤主题功能支持下拉
- [X] 订阅主题备注