Compare commits

..

8 Commits

17 changed files with 2071 additions and 114 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"noEmit": true,
"strict": false,
"baseUrl": ".",
"paths": {
"@types/*": ["types/*"]
}
},
"include": [
"src/**/*",
"types/**/*"
],
"exclude": [
"node_modules",
"dist",
"out"
]
}
+20 -17
View File
@@ -5,9 +5,13 @@ import { registerIpcHandlers } from './ipc-handlers.js';
import icon from '../../resources/icon.png?asset';
/**
* 创建主窗口
* @returns {BrowserWindow} 主窗口实例
*/
function createWindow() {
// Create the browser window.
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
@@ -22,17 +26,19 @@ function createWindow() {
},
});
// 窗口准备就绪后显示,避免白屏
mainWindow.on('ready-to-show', () => {
mainWindow.show();
});
// 拦截新窗口打开请求,使用系统默认浏览器打开外部链接
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
// 基于 electron-vite cli 的热更新
// 开发环境加载远程 URL,生产环境加载本地 HTML 文件
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
@@ -41,17 +47,16 @@ function createWindow() {
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
// 当 Electron 完成初始化并准备好创建浏览器窗口时调用此方法
// 某些 API 只能在此事件触发后使用
app.whenReady().then(() => {
// Set app user model id for windows
// 为 Windows 设置应用用户模型 ID
electronApp.setAppUserModelId('com.electron');
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
// 开发环境通过 F12 打开/关闭 DevTools
// 生产环境忽略 CommandOrControl + R 刷新
// 详见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
@@ -62,8 +67,7 @@ app.whenReady().then(() => {
createWindow();
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
// macOS 上,当点击应用图标且没有其他窗口打开时,通常会重新创建一个窗口
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
@@ -71,14 +75,13 @@ app.whenReady().then(() => {
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
// 当所有窗口关闭时退出应用,macOS 除外
// 在 macOS 上,应用及其菜单栏通常会保持活动状态,直到用户通过 Cmd + Q 显式退出
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
// 在这个文件中,你可以包含应用特定的主进程代码
// 也可以将它们放到单独的文件中,然后在这里引入
+25 -6
View File
@@ -2,15 +2,26 @@ import { ipcMain, BrowserWindow } from 'electron';
import mqttManager from './mqtt-manager.js';
/**
* 注册所有 IPC 处理器
* 将渲染进程的请求转发给 MQTT 管理器,并把 MQTT 事件推送给渲染进程
*/
export function registerIpcHandlers() {
// 获取主窗口(用于推送事件)
/**
* 获取当前所有窗口中的第一个主窗口
* @returns {BrowserWindow|null} 主窗口实例或 null
*/
function getMainWindow() {
const windows = BrowserWindow.getAllWindows();
return windows.length > 0 ? windows[0] : null;
}
// 向渲染进程发送事件
/**
* 向渲染进程发送事件
* @param {string} channel - IPC 通道名称
* @param {*} data - 要发送的数据
*/
function sendToRenderer(channel, data) {
const win = getMainWindow();
if (win && !win.isDestroyed()) {
@@ -58,6 +69,14 @@ export function registerIpcHandlers() {
return true;
});
ipcMain.handle('mqtt:export-servers', () => {
return mqttManager.exportServers();
});
ipcMain.handle('mqtt:import-servers', (_, serverList) => {
return mqttManager.importServers(serverList);
});
// ==================== 连接管理 ====================
ipcMain.handle('mqtt:connect', (_, id) => {
@@ -71,8 +90,8 @@ export function registerIpcHandlers() {
// ==================== 主题管理 ====================
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
return mqttManager.addTopic(serverId, topic, qos);
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos, comment) => {
return mqttManager.addTopic(serverId, topic, qos, comment);
});
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
@@ -91,8 +110,8 @@ export function registerIpcHandlers() {
return mqttManager.subscribeAll(serverId);
});
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos);
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos, comment) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos, comment);
});
// ==================== 消息发布 ====================
+298 -38
View File
@@ -4,6 +4,27 @@ import { join } from 'path';
import mqtt from 'mqtt';
// ==================== 类型定义 ====================
/**
* 从公共类型定义文件引入 MQTT 相关类型
* @typedef {import('@types/jsdoc').MqttQoS} MqttQoS
* @typedef {import('@types/jsdoc').MqttServerStatus} MqttServerStatus
* @typedef {import('@types/jsdoc').MqttTopic} MqttTopic
* @typedef {import('@types/jsdoc').TopicConfig} TopicConfig
* @typedef {import('@types/jsdoc').ServerConfig} ServerConfig
* @typedef {import('@types/jsdoc').MqttServer} MqttServer
* @typedef {import('@types/jsdoc').PublishOptions} PublishOptions
* @typedef {import('@types/jsdoc').MqttMessage} MqttMessage
* @typedef {import('@types/jsdoc').PublishRecord} PublishRecord
*/
// ==================== 类定义 ====================
/**
* MQTT 管理器
* 负责 MQTT 服务器配置持久化、连接管理、主题订阅/发布、消息存储等核心逻辑
*/
class MqttManager {
constructor() {
@@ -17,6 +38,10 @@ class MqttManager {
this.loadData();
}
/**
* 生成唯一消息 id
* @returns {string} 消息唯一标识
*/
nextMsgId() {
this.msgIdCounter = (this.msgIdCounter + 1) % Number.MAX_SAFE_INTEGER;
return 'msg_' + Date.now() + '_' + this.msgIdCounter;
@@ -24,6 +49,10 @@ class MqttManager {
// ==================== 数据持久化 ====================
/**
* 获取用户数据目录,若不存在则创建
* @returns {string} 用户数据目录路径
*/
getDataDir() {
let dir = app.getPath('userData');
@@ -36,6 +65,10 @@ class MqttManager {
}
/**
* 从本地文件加载服务器、消息和发布历史数据
* 文件不存在或解析失败时初始化默认服务器
*/
loadData() {
try {
if (existsSync(this.dataPath)) {
@@ -49,6 +82,7 @@ class MqttManager {
s.topics = (s.topics || []).map((t) => ({
...t,
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
comment: t.comment ?? '',
subscribed: false,
}));
this.servers.set(s.id, s);
@@ -76,6 +110,9 @@ class MqttManager {
}
}
/**
* 将当前服务器、消息和发布历史保存到本地文件
*/
saveData() {
try {
@@ -96,6 +133,9 @@ class MqttManager {
}
}
/**
* 初始化默认示例服务器
*/
initDefaultServers() {
let defaults = [
@@ -115,8 +155,8 @@ class MqttManager {
reconnectInterval: 5000,
status: 'disconnected',
topics: [
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false },
{ id: 'topic_default_002', topic: 'device/status', qos: 2, subscribed: false },
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, comment: '', subscribed: false },
{ id: 'topic_default_002', topic: 'device/status', qos: 2, comment: '', subscribed: false },
],
},
];
@@ -133,14 +173,27 @@ class MqttManager {
// ==================== 监听器 ====================
/**
* 添加 MQTT 事件监听器
* @param {Function} callback - 回调函数,接收 (event, data) 参数
*/
addListener(callback) {
this.listeners.add(callback);
}
/**
* 移除 MQTT 事件监听器
* @param {Function} callback - 要移除的回调函数
*/
removeListener(callback) {
this.listeners.delete(callback);
}
/**
* 通知所有监听器
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
notify(event, data) {
this.listeners.forEach((cb) => {
try {
@@ -153,34 +206,89 @@ class MqttManager {
// ==================== 服务器管理 ====================
/**
* 获取所有服务器列表
* @returns {MqttServer[]} 服务器配置数组
*/
getServers() {
return Array.from(this.servers.values()).map((s) => ({
...s,
password: s.password ? '******' : '',
password: s.password || '',
}));
}
/**
* 根据 id 获取服务器配置
* @param {string} id - 服务器 id
* @returns {MqttServer | null} 服务器配置或 null
*/
getServer(id) {
return this.servers.get(id) || null;
}
/**
* 获取服务器配置字段 schema
* @returns {Record<string, Function>} 字段名到处理函数的映射
*/
getServerConfigSchema() {
return {
name: (c, e) => c.name ?? e?.name,
host: (c, e) => c.host ?? e?.host,
port: (c, e) => c.port || e?.port || 1883,
protocol: (c, e) => c.protocol || e?.protocol || 'mqtt',
clientId: (c, e) => c.clientId || e?.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
username: (c, e) => c.username ?? e?.username ?? '',
password: (c, e, opts) => c.password ?? (opts.keepExistingPassword ? e?.password : '') ?? '',
keepAlive: (c, e) => c.keepAlive || e?.keepAlive || 60,
cleanSession: (c) => c.cleanSession !== false,
connectTimeout: (c, e) => c.connectTimeout ?? e?.connectTimeout ?? 10000,
reconnect: (c) => c.reconnect !== false,
reconnectInterval: (c, e) => c.reconnectInterval ?? e?.reconnectInterval ?? 5000,
};
}
/**
* 根据配置构建服务器字段
* @param {ServerConfig} config - 新配置
* @param {MqttServer | null} existing - 已有服务器配置
* @param {{ keepExistingPassword?: boolean }} options - 可选项
* @returns {ServerConfig} 处理后的字段对象
*/
buildServerFields(config, existing = null, options = {}) {
let schema = this.getServerConfigSchema();
let fields = {};
for (let key of Object.keys(schema)) {
fields[key] = schema[key](config, existing, options);
}
return fields;
}
/**
* 构建主题对象
* @param {TopicConfig} t - 原始主题数据
* @returns {MqttTopic} 标准化后的主题对象
*/
buildTopic(t) {
return {
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
topic: t.topic,
qos: t.qos ?? 1,
comment: t.comment ?? '',
subscribed: false,
};
}
/**
* 添加服务器
* @param {ServerConfig} config - 服务器配置
* @returns {MqttServer} 创建后的服务器对象
*/
addServer(config) {
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
let server = {
id,
name: config.name,
host: config.host,
port: config.port || 1883,
protocol: config.protocol || 'mqtt',
clientId: config.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
username: config.username || '',
password: config.password || '',
keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false,
connectTimeout: config.connectTimeout ?? 10000,
reconnect: config.reconnect !== false,
reconnectInterval: config.reconnectInterval ?? 5000,
...this.buildServerFields(config),
status: 'disconnected',
topics: [],
};
@@ -194,6 +302,12 @@ class MqttManager {
}
/**
* 更新服务器配置
* @param {string} id - 服务器 id
* @param {ServerConfig} config - 新的服务器配置
* @returns {MqttServer | null} 更新后的服务器对象或 null
*/
updateServer(id, config) {
let server = this.servers.get(id);
@@ -202,23 +316,7 @@ class MqttManager {
return null;
}
Object.assign(server, {
name: config.name,
host: config.host,
port: config.port || 1883,
protocol: config.protocol || 'mqtt',
clientId: config.clientId || server.clientId,
username: config.username || '',
keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false,
connectTimeout: config.connectTimeout ?? server.connectTimeout ?? 10000,
reconnect: config.reconnect !== false,
reconnectInterval: config.reconnectInterval ?? server.reconnectInterval ?? 5000,
});
if (config.password) {
server.password = config.password;
}
Object.assign(server, this.buildServerFields(config, server, { keepExistingPassword: true }));
this.saveData();
@@ -226,6 +324,10 @@ class MqttManager {
}
/**
* 删除服务器,并断开其连接
* @param {string} id - 服务器 id
*/
deleteServer(id) {
// 先断开连接
this.disconnect(id);
@@ -235,8 +337,85 @@ class MqttManager {
this.saveData();
}
/**
* 导出所有服务器配置(不含运行时状态)
* @returns {ServerConfig[]} 服务器配置数组
*/
exportServers() {
let schemaKeys = Object.keys(this.getServerConfigSchema());
return Array.from(this.servers.values()).map((s) => {
let exported = {};
for (let key of schemaKeys) {
exported[key] = s[key];
}
exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t));
return exported;
});
}
/**
* 构建导出用的主题对象
* @param {MqttTopic} t - 主题对象
* @returns {TopicConfig} 导出主题对象
*/
buildTopicExport(t) {
return {
id: t.id,
topic: t.topic,
qos: t.qos,
comment: t.comment ?? '',
};
}
/**
* 导入服务器配置列表,会覆盖现有配置
* @param {ServerConfig[]} serverList - 服务器配置数组
* @returns {MqttServer[]} 导入后的服务器列表
*/
importServers(serverList) {
if (!Array.isArray(serverList)) {
throw new Error('导入格式错误:必须是服务器配置数组');
}
// 断开所有现有连接
this.clients.forEach((client, id) => {
this.disconnect(id);
});
this.clients.clear();
this.servers.clear();
this.messages.clear();
this.publishHistory.clear();
serverList.forEach((config) => {
if (!config.name || !config.host) {
return;
}
let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
let server = {
id,
...this.buildServerFields(config),
status: 'disconnected',
topics: (config.topics || []).map((t) => this.buildTopic(t)),
};
this.servers.set(id, server);
this.messages.set(id, []);
this.publishHistory.set(id, []);
});
this.saveData();
return this.getServers();
}
// ==================== 连接管理 ====================
/**
* 连接指定服务器
* @param {string} id - 服务器 id
* @returns {boolean} 是否成功发起连接
*/
connect(id) {
let server = this.servers.get(id);
@@ -334,6 +513,10 @@ class MqttManager {
}
}
/**
* 断开指定服务器的连接
* @param {string} id - 服务器 id
*/
disconnect(id) {
let client = this.clients.get(id);
@@ -356,6 +539,14 @@ class MqttManager {
}
// ==================== 订阅管理 ====================
/**
* 执行订阅操作(内部方法)
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @param {PublishOptions} opts - 订阅选项
* @returns {boolean} 是否发起订阅
*/
_doSubscribe(serverId, topic, opts) {
let client = this.clients.get(serverId);
@@ -382,18 +573,27 @@ class MqttManager {
}
addTopic(serverId, topic, qos = 1) {
/**
* 为主题列表添加新主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @param {MqttQoS} qos - QoS 等级
* @param {string} comment - 主题备注
* @returns {MqttTopic | null} 添加后的主题对象或 null
*/
addTopic(serverId, topic, qos = 1, comment = '') {
let server = this.servers.get(serverId);
if (!server) {
return false;
return null;
}
if (server.topics.find((t) => t.topic === topic)) {
return false;
return null;
}
let topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
server.topics.push({ id: topicId, topic, qos, subscribed: false });
let newTopic = { id: topicId, topic, qos, comment, subscribed: false };
server.topics.push(newTopic);
this.saveData();
@@ -402,10 +602,16 @@ class MqttManager {
this._doSubscribe(serverId, topic, { qos });
}
return true;
return newTopic;
}
/**
* 移除主题并取消订阅
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {boolean} 是否移除成功
*/
removeTopic(serverId, topic) {
let server = this.servers.get(serverId);
@@ -425,6 +631,12 @@ class MqttManager {
}
/**
* 订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {boolean} 是否发起订阅
*/
subscribe(serverId, topic) {
let server = this.servers.get(serverId);
@@ -443,6 +655,12 @@ class MqttManager {
}
/**
* 取消订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {boolean} 是否发起取消订阅
*/
unsubscribe(serverId, topic) {
let client = this.clients.get(serverId);
@@ -466,7 +684,16 @@ class MqttManager {
}
updateTopic(serverId, topicId, newTopic, newQos) {
/**
* 更新主题信息,必要时取消旧主题并重新订阅新主题
* @param {string} serverId - 服务器 id
* @param {string} topicId - 主题 id
* @param {string} newTopic - 新的主题名称
* @param {number} newQos - 新的 QoS 等级
* @param {string} comment - 新的主题备注
* @returns {boolean} 是否更新成功
*/
updateTopic(serverId, topicId, newTopic, newQos, comment = '') {
let server = this.servers.get(serverId);
@@ -499,6 +726,7 @@ class MqttManager {
t.topic = newTopic;
t.qos = newQos;
t.comment = comment;
// 如果已连接且此前已订阅,重新订阅新主题
if (server.status === 'connected' && wasSubscribed) {
@@ -511,12 +739,18 @@ class MqttManager {
topic: newTopic,
subscribed: t.subscribed,
topicId,
comment: t.comment,
});
return true;
}
/**
* 批量订阅服务器下所有未订阅的主题
* @param {string} serverId - 服务器 id
* @returns {boolean} 是否发起批量订阅
*/
subscribeAll(serverId) {
let client = this.clients.get(serverId);
@@ -559,6 +793,14 @@ class MqttManager {
// ==================== 消息发布 ====================
/**
* 向指定主题发布消息
* @param {string} serverId - 服务器 id
* @param {string} topic - 目标主题
* @param {string} payload - 消息内容
* @param {PublishOptions} opts - 发布选项
* @returns {boolean} 是否发起发布
*/
publish(serverId, topic, payload, opts = {}) {
let client = this.clients.get(serverId);
@@ -634,19 +876,37 @@ class MqttManager {
// ==================== 消息管理 ====================
/**
* 获取指定服务器的消息列表
* @param {string} serverId - 服务器 id
* @returns {MqttMessage[]} 消息数组
*/
getMessages(serverId) {
return this.messages.get(serverId) || [];
}
/**
* 获取指定服务器的发布历史
* @param {string} serverId - 服务器 id
* @returns {PublishRecord[]} 发布历史数组
*/
getPublishHistory(serverId) {
return this.publishHistory.get(serverId) || [];
}
/**
* 清空指定服务器的消息列表
* @param {string} serverId - 服务器 id
*/
clearMessages(serverId) {
this.messages.set(serverId, []);
this.saveData();
}
/**
* 清空指定服务器的发布历史
* @param {string} serverId - 服务器 id
*/
clearPublishHistory(serverId) {
this.publishHistory.set(serverId, []);
this.saveData();
+11 -7
View File
@@ -1,7 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron';
import { electronAPI } from '@electron-toolkit/preload';
// Custom APIs for renderer
/**
* 渲染进程可调用的自定义 API
* 封装了所有与主进程通信的 IPC 调用及事件监听
*/
const api = {
// 服务器管理
@@ -9,19 +12,22 @@ const api = {
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
exportServers: () => ipcRenderer.invoke('mqtt:export-servers'),
importServers: (serverList) => ipcRenderer.invoke('mqtt:import-servers', serverList),
// 连接管理
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
// 主题管理
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos),
addTopic: (serverId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos, comment),
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
updateTopic: (serverId, topicId, topic, qos) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos),
updateTopic: (serverId, topicId, topic, qos, comment) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos, comment),
// 消息发布
publish: (serverId, topic, payload, opts) =>
@@ -62,9 +68,7 @@ const api = {
};
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
// 根据是否启用上下文隔离,使用 contextBridge 暴露 API 或挂载到 window
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
+29
View File
@@ -9,9 +9,12 @@ import Dashboard from './views/Dashboard.vue';
import Subscribe from './views/Subscribe.vue';
import Publish from './views/Publish.vue';
import Messages from './views/Messages.vue';
import QuickSend from './views/QuickSend.vue';
// 全局状态
const store = useMqttStore();
// 主题覆盖配置
const themeOverrides = {
common: {
primaryColor: '#0ea5a0',
@@ -37,28 +40,46 @@ const themeOverrides = {
},
};
// 当前激活的标签页
const activeTab = ref('dashboard');
// 是否显示服务器编辑弹窗
const showServerModal = ref(false);
// 当前编辑的服务器数据,null 表示新增
const editServerData = ref(null);
// 组件挂载时初始化 Store
onMounted(async () => {
await store.init();
});
/**
* 打开新增服务器弹窗
*/
function handleAddServer() {
editServerData.value = null;
showServerModal.value = true;
}
/**
* 打开编辑服务器弹窗
* @param {import('@types/jsdoc').MqttServer} server - 要编辑的服务器对象
*/
function handleEditServer(server) {
editServerData.value = server;
showServerModal.value = true;
}
/**
* 服务器保存后的回调
*/
function handleServerSaved() {
// 刷新数据
}
/**
* 切换标签页
* @param {string} tab - 标签页名称
*/
function handleSwitchTab(tab) {
activeTab.value = tab;
}
@@ -128,6 +149,14 @@ function handleSwitchTab(tab) {
</div>
</div>
</n-tab-pane>
<n-tab-pane
name="quick-send"
tab="快速发送"
>
<div class="tab-content">
<QuickSend />
</div>
</n-tab-pane>
</n-tabs>
</n-layout>
@@ -3,16 +3,19 @@ import { ref, watch } from 'vue';
import { NModal, NForm, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js';
// 弹窗显示状态与编辑数据
const props = defineProps({
show: Boolean,
editServer: Object,
});
// 更新 show、保存完成事件
const emit = defineEmits(['update:show', 'saved']);
const store = useMqttStore();
const msg = useMessage();
// 表单引用与数据
const formRef = ref(null);
const form = ref({
name: '',
@@ -29,12 +32,14 @@ const form = ref({
reconnectInterval: 5000,
});
// 表单校验规则
const rules = {
name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
host: { required: true, message: '请输入主机地址', trigger: 'blur' },
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' },
};
// 协议下拉选项
const protocolOptions = [
{ label: 'mqtt:// (默认)', value: 'mqtt' },
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
@@ -42,8 +47,12 @@ const protocolOptions = [
{ label: 'wss:// (WebSocket TLS)', value: 'wss' },
];
// 是否为编辑模式
const isEdit = ref(false);
/**
* 监听弹窗显示状态,打开时初始化表单
*/
watch(
() => props.show,
(val) => {
@@ -57,7 +66,7 @@ watch(
protocol: props.editServer.protocol || 'mqtt',
clientId: props.editServer.clientId || '',
username: props.editServer.username || '',
password: '',
password: props.editServer.password || '',
keepAlive: props.editServer.keepAlive || 60,
cleanSession: props.editServer.cleanSession !== false,
connectTimeout: props.editServer.connectTimeout ?? 10000,
@@ -85,6 +94,9 @@ watch(
}
);
/**
* 保存服务器配置
*/
async function handleSave() {
try {
@@ -192,6 +204,7 @@ async function handleSave() {
<n-input
v-model:value="form.password"
type="password"
show-password-on="click"
placeholder="可选"
/>
</n-form-item-gi>
@@ -1,12 +1,23 @@
<script setup>
import { ref } from 'vue';
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui';
import { useMqttStore } from '../stores/mqtt.js';
// 组件事件定义
const emit = defineEmits(['add-server', 'edit-server']);
// 全局状态与消息/对话框 API
const store = useMqttStore();
const message = useMessage();
const dialog = useDialog();
// 文件输入控件引用,用于导入配置
const fileInput = ref(null);
/**
* 切换服务器连接状态
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
async function handleConnect(server) {
if (server.status === 'connected') {
await store.disconnectServer(server.id);
@@ -16,10 +27,18 @@ async function handleConnect(server) {
}
}
/**
* 编辑服务器
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleEdit(server) {
emit('edit-server', server);
}
/**
* 删除服务器,弹出确认对话框
* @param {import('@types/jsdoc').MqttServer} server - 服务器对象
*/
function handleDelete(server) {
dialog.warning({
title: '确认删除',
@@ -32,6 +51,50 @@ function handleDelete(server) {
},
});
}
/**
* 导出服务器配置到本地 JSON 文件
*/
async function handleExport() {
try {
let data = await store.exportServers();
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = `frost-mqtt-servers-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
message.success('服务器配置已导出');
} catch (e) {
message.error('导出失败: ' + (e.message || '未知错误'));
}
}
/**
* 触发文件导入点击
*/
function handleImportClick() {
fileInput.value?.click();
}
/**
* 处理导入的 JSON 配置文件
* @param {Event} event - 文件选择事件
*/
async function handleImportFile(event) {
let file = event.target.files?.[0];
if (!file) return;
try {
let text = await file.text();
let data = JSON.parse(text);
await store.importServers(data);
message.success('服务器配置已导入');
} catch (e) {
message.error('导入失败: ' + (e.message || '未知错误'));
}
event.target.value = '';
}
</script>
<template>
@@ -50,6 +113,7 @@ function handleDelete(server) {
>
<div class="sidebar-container">
<!-- 侧边栏头部 Logo 与标题 -->
<div class="sidebar-header">
<div class="sidebar-logo">F</div>
<div>
@@ -58,6 +122,7 @@ function handleDelete(server) {
</div>
</div>
<!-- 服务器列表 -->
<div class="server-list">
<div
v-for="srv in store.servers.value"
@@ -119,12 +184,32 @@ function handleDelete(server) {
</div>
</div>
<!-- 侧边栏底部操作按钮 -->
<div class="sidebar-footer">
<n-button
type="primary"
block
@click="$emit('add-server')"
> + 添加服务器</n-button>
<div class="sidebar-import-export">
<n-button
size="small"
block
@click="handleImportClick"
>导入配置</n-button>
<n-button
size="small"
block
@click="handleExport"
>导出配置</n-button>
</div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</div>
</div>
@@ -297,4 +382,11 @@ function handleDelete(server) {
border-top: 1px solid var(--n-border-color);
flex-shrink: 0;
}
.sidebar-import-export {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
</style>
+3
View File
@@ -2,6 +2,9 @@ import { createApp } from 'vue';
import App from './App.vue';
/**
* 创建并挂载 Vue 应用
*/
const app = createApp(App);
app.mount('#app');
+311 -13
View File
@@ -1,15 +1,129 @@
import { ref, computed } from 'vue';
// 全局状态(单例)
// ==================== 类型定义 ====================
/**
* 从公共类型定义文件引入 MQTT 相关类型
* @typedef {import('@types/jsdoc').MqttQoS} MqttQoS
* @typedef {import('@types/jsdoc').MqttServerStatus} MqttServerStatus
* @typedef {import('@types/jsdoc').MqttTopic} MqttTopic
* @typedef {import('@types/jsdoc').TopicConfig} TopicConfig
* @typedef {import('@types/jsdoc').ServerConfig} ServerConfig
* @typedef {import('@types/jsdoc').MqttServer} MqttServer
* @typedef {import('@types/jsdoc').PublishOptions} PublishOptions
* @typedef {import('@types/jsdoc').MqttMessage} MqttMessage
* @typedef {import('@types/jsdoc').PublishRecord} PublishRecord
*/
/**
* @typedef {Object} StatusChangeData 服务器状态变更事件数据
* @property {string} id 服务器 id
* @property {MqttServerStatus} status 新状态
* @property {string} [error] 错误信息(状态为 error 时存在)
*/
/**
* @typedef {Object} MessageEventData 消息接收事件数据
* @property {string} serverId 所属服务器 id
* @property {MqttMessage} message 消息对象
*/
/**
* @typedef {Object} SubscriptionChangeData 订阅状态变更事件数据
* @property {string} serverId 所属服务器 id
* @property {string} topic 主题名称
* @property {string} [topicId] 主题 id
* @property {string} [comment] 主题备注
* @property {boolean} subscribed 是否已订阅
*/
/**
* @typedef {Object} MqttStore
* @property {import('vue').Ref<MqttServer[]>} servers 所有服务器列表
* @property {import('vue').Ref<string | null>} activeServerId 当前活动服务器 ID
* @property {import('vue').ComputedRef<MqttServer | null>} activeServer 当前活动服务器对象
* @property {import('vue').Ref<MqttMessage[]>} messages 当前活动服务器消息列表
* @property {import('vue').Ref<PublishRecord[]>} publishHistory 当前活动服务器发布历史
* @property {import('vue').Ref<boolean>} loading 全局加载状态
* @property {import('vue').ComputedRef<MqttServer[]>} connectedServers 已连接服务器列表
* @property {import('vue').ComputedRef<number>} totalTopics 所有服务器主题总数
* @property {import('vue').ComputedRef<number>} subscribedTopics 已订阅主题总数
* @property {import('vue').ComputedRef<number>} totalMessages 当前活动服务器消息总数
* @property {() => Promise<void>} init 初始化 Store
* @property {(id: string | null) => void} setActiveServer 设置当前活动服务器
* @property {(config: ServerConfig) => Promise<MqttServer>} addServer 添加服务器
* @property {(id: string, config: ServerConfig) => Promise<MqttServer | null>} updateServer 更新服务器
* @property {(id: string) => Promise<void>} deleteServer 删除服务器
* @property {() => Promise<ServerConfig[]>} exportServers 导出服务器配置
* @property {(serverList: ServerConfig[]) => Promise<MqttServer[]>} importServers 导入服务器配置
* @property {(id: string) => Promise<boolean>} connectServer 连接服务器
* @property {(id: string) => Promise<void>} disconnectServer 断开服务器
* @property {(serverId: string, topic: string, qos: MqttQoS, comment: string) => Promise<MqttTopic | null>} addTopic 添加主题
* @property {(serverId: string, topic: string) => Promise<void>} removeTopic 移除主题
* @property {(serverId: string, topic: string) => Promise<boolean>} subscribeTopic 订阅主题
* @property {(serverId: string, topic: string) => Promise<boolean>} unsubscribeTopic 取消订阅主题
* @property {(serverId: string) => Promise<boolean>} subscribeAllTopics 批量订阅所有主题
* @property {(serverId: string, topicId: string, topic: string, qos: MqttQoS, comment: string) => Promise<boolean>} updateTopic 更新主题
* @property {(serverId: string, topic: string, payload: string, opts: PublishOptions) => Promise<boolean>} publishMessage 发布消息
* @property {() => Promise<void>} refreshMessages 刷新消息列表
* @property {() => Promise<void>} refreshPublishHistory 刷新发布历史
* @property {() => Promise<void>} clearMessages 清空消息列表
* @property {() => Promise<void>} clearPublishHistory 清空发布历史
*/
// ==================== 全局状态(单例) ====================
/**
* 所有已配置的服务器列表,按名称排序
* @type {import('vue').Ref<MqttServer[]>}
*/
const servers = ref([]);
/**
* 当前在界面中选中的服务器 ID
* @type {import('vue').Ref<string | null>}
*/
const activeServerId = ref(null);
/**
* 当前活动服务器的消息列表(包含订阅接收与本地发布的消息)
* @type {import('vue').Ref<MqttMessage[]>}
*/
const messages = ref([]);
/**
* 当前活动服务器的发布历史记录
* @type {import('vue').Ref<PublishRecord[]>}
*/
const publishHistory = ref([]);
/**
* Store 初始化或异步操作时的全局加载状态
* @type {import('vue').Ref<boolean>}
*/
const loading = ref(false);
// 清理函数集合
/**
* IPC 监听器清理函数集合
* @type {Array<() => void>}
*/
let cleanupFns = [];
/**
* 对指定服务器的主题列表按主题名称升序排序
* @param {MqttServer} server - 服务器对象
* @returns {void}
*/
function sortTopics(server) {
if (server && server.topics) {
server.topics.sort((a, b) => a.topic.localeCompare(b.topic));
}
}
/**
* 设置主进程事件监听器
* 包括状态变化、消息接收、订阅变化等事件
*/
function setupListeners() {
// 清理旧监听器
@@ -17,61 +131,106 @@ function setupListeners() {
cleanupFns = [];
cleanupFns.push(
window.api.onStatusChange((data) => {
const srv = servers.value.find((s) => s.id === data.id);
window.api.onStatusChange((/** @type {StatusChangeData} */ data) => {
let srv = servers.value.find((s) => {
return s.id === data.id;
});
if (srv) {
srv.status = data.status;
if (data.status === 'disconnected') {
srv.topics.forEach((t) => {
t.subscribed = false;
});
}
}
})
);
cleanupFns.push(
window.api.onMessage((data) => {
window.api.onMessage((/** @type {MessageEventData} */ data) => {
if (data.serverId === activeServerId.value) {
messages.value.unshift(data.message);
if (messages.value.length > 500) {
messages.value.pop();
}
}
})
);
cleanupFns.push(
window.api.onSubscriptionChange((data) => {
window.api.onSubscriptionChange((/** @type {SubscriptionChangeData} */ data) => {
if (data.serverId === activeServerId.value) {
const srv = servers.value.find((s) => s.id === data.serverId);
let srv = servers.value.find((s) => {
return s.id === data.serverId;
});
if (srv) {
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId);
let t = srv.topics.find((t) => {
return t.topic === data.topic || t.id === data.topicId;
});
if (t) {
t.subscribed = data.subscribed;
if (typeof data.comment === 'string') {
t.comment = data.comment;
}
if (data.topic && data.topic !== t.topic) {
t.topic = data.topic;
sortTopics(srv);
}
}
}
}
})
);
}
/**
* MQTT 全局状态管理 Store
* @returns {MqttStore} 包含状态、计算属性和方法的对象
*/
export function useMqttStore() {
/**
* 当前选中的服务器
* @type {import('vue').ComputedRef<MqttServer | null>}
*/
let activeServer = computed(
() => servers.value.find((s) => s.id === activeServerId.value) || null
);
/**
* 已连接的服务器列表
* @type {import('vue').ComputedRef<MqttServer[]>}
*/
let connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'));
/**
* 所有服务器主题总数
* @type {import('vue').ComputedRef<number>}
*/
let totalTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
);
/**
* 已订阅主题总数
* @type {import('vue').ComputedRef<number>}
*/
let subscribedTopics = computed(() =>
servers.value.reduce(
(sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0),
@@ -79,13 +238,26 @@ export function useMqttStore() {
)
);
/**
* 当前活动服务器消息总数
* @type {import('vue').ComputedRef<number>}
*/
let totalMessages = computed(() => messages.value.length);
/**
* 按服务器名称对服务器列表排序(中文环境)
* @returns {void}
*/
function sortServers() {
servers.value.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
}
// ==================== 初始化 ====================
/**
* 初始化 Store,从主进程加载服务器列表并设置监听
* @returns {Promise<void>}
*/
async function init() {
loading.value = true;
@@ -96,6 +268,7 @@ export function useMqttStore() {
servers.value = data;
sortServers();
servers.value.forEach((srv) => sortTopics(srv));
setupListeners();
} catch (e) {
@@ -107,6 +280,11 @@ export function useMqttStore() {
}
// ==================== 服务器管理 ====================
/**
* 设置当前活动服务器并刷新相关数据
* @param {string|null} id - 服务器 id
*/
function setActiveServer(id) {
activeServerId.value = id;
@@ -117,6 +295,11 @@ export function useMqttStore() {
}
/**
* 添加服务器
* @param {ServerConfig} config - 服务器配置
* @returns {Promise<MqttServer>} 添加后的服务器对象
*/
async function addServer(config) {
let server = await window.api.addServer(config);
@@ -132,6 +315,12 @@ export function useMqttStore() {
}
/**
* 更新服务器配置
* @param {string} id - 服务器 id
* @param {ServerConfig} config - 新的服务器配置
* @returns {Promise<MqttServer | null>} 更新后的服务器对象或 null
*/
async function updateServer(id, config) {
let result = await window.api.updateServer(id, config);
@@ -148,6 +337,11 @@ export function useMqttStore() {
}
/**
* 删除服务器
* @param {string} id - 服务器 id
* @returns {Promise<void>}
*/
async function deleteServer(id) {
await window.api.deleteServer(id);
@@ -161,23 +355,67 @@ export function useMqttStore() {
}
/**
* 导出所有服务器配置
* @returns {Promise<ServerConfig[]>} 服务器配置数组
*/
async function exportServers() {
return await window.api.exportServers();
}
/**
* 导入服务器配置列表
* @param {ServerConfig[]} serverList - 服务器配置数组
* @returns {Promise<MqttServer[]>} 导入后的服务器列表
*/
async function importServers(serverList) {
let data = await window.api.importServers(serverList);
servers.value = data;
sortServers();
servers.value.forEach((srv) => sortTopics(srv));
if (servers.value.length > 0 && !activeServerId.value) {
activeServerId.value = servers.value[0].id;
}
return data;
}
/**
* 连接指定服务器
* @param {string} id - 服务器 id
* @returns {Promise<boolean>} 是否成功发起连接
*/
async function connectServer(id) {
return await window.api.connect(id);
}
/**
* 断开指定服务器
* @param {string} id - 服务器 id
* @returns {Promise<void>}
*/
async function disconnectServer(id) {
await window.api.disconnect(id);
}
// ==================== 主题管理 ====================
async function addTopic(serverId, topic, qos) {
let result = await window.api.addTopic(serverId, topic, qos);
/**
* 为指定服务器添加主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @param {MqttQoS} qos - QoS 等级
* @param {string} comment - 主题备注
* @returns {Promise<MqttTopic | null>} 添加后的主题对象或 null
*/
async function addTopic(serverId, topic, qos, comment) {
let result = await window.api.addTopic(serverId, topic, qos, comment);
if (result) {
let srv = servers.value.find((s) => s.id === serverId);
if (srv) {
srv.topics.push({ topic, qos, subscribed: false });
srv.topics.push(result);
sortTopics(srv);
}
}
@@ -185,6 +423,12 @@ export function useMqttStore() {
}
/**
* 移除指定服务器的主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<void>}
*/
async function removeTopic(serverId, topic) {
await window.api.removeTopic(serverId, topic);
@@ -193,27 +437,63 @@ export function useMqttStore() {
if (srv) {
srv.topics = srv.topics.filter((t) => t.topic !== topic);
sortTopics(srv);
}
}
/**
* 订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<boolean>} 是否发起订阅
*/
async function subscribeTopic(serverId, topic) {
return await window.api.subscribe(serverId, topic);
}
/**
* 取消订阅指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 主题名称
* @returns {Promise<boolean>} 是否发起取消订阅
*/
async function unsubscribeTopic(serverId, topic) {
return await window.api.unsubscribe(serverId, topic);
}
/**
* 批量订阅指定服务器下所有未订阅主题
* @param {string} serverId - 服务器 id
* @returns {Promise<boolean>} 是否发起批量订阅
*/
async function subscribeAllTopics(serverId) {
return await window.api.subscribeAll(serverId);
}
async function updateTopic(serverId, topicId, topic, qos) {
return await window.api.updateTopic(serverId, topicId, topic, qos);
/**
* 更新指定服务器的主题信息
* @param {string} serverId - 服务器 id
* @param {string} topicId - 主题 id
* @param {string} topic - 新的主题名称
* @param {MqttQoS} qos - 新的 QoS 等级
* @param {string} comment - 新的主题备注
* @returns {Promise<boolean>} 是否更新成功
*/
async function updateTopic(serverId, topicId, topic, qos, comment) {
return await window.api.updateTopic(serverId, topicId, topic, qos, comment);
}
// ==================== 消息发布 ====================
/**
* 发布消息到指定主题
* @param {string} serverId - 服务器 id
* @param {string} topic - 目标主题
* @param {string} payload - 消息内容
* @param {PublishOptions} opts - 发布选项
* @returns {Promise<boolean>} 是否发起发布
*/
async function publishMessage(serverId, topic, payload, opts) {
let result = await window.api.publish(serverId, topic, payload, opts);
@@ -228,18 +508,30 @@ export function useMqttStore() {
// ==================== 消息管理 ====================
/**
* 刷新当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function refreshMessages() {
if (activeServerId.value) {
messages.value = await window.api.getMessages(activeServerId.value);
}
}
/**
* 刷新当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function refreshPublishHistory() {
if (activeServerId.value) {
publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
}
}
/**
* 清空当前活动服务器的消息列表
* @returns {Promise<void>}
*/
async function clearMessages() {
if (activeServerId.value) {
await window.api.clearMessages(activeServerId.value);
@@ -247,6 +539,10 @@ export function useMqttStore() {
}
}
/**
* 清空当前活动服务器的发布历史
* @returns {Promise<void>}
*/
async function clearPublishHistory() {
if (activeServerId.value) {
await window.api.clearPublishHistory(activeServerId.value);
@@ -273,6 +569,8 @@ export function useMqttStore() {
addServer,
updateServer,
deleteServer,
exportServers,
importServers,
connectServer,
disconnectServer,
addTopic,
+13 -2
View File
@@ -2,9 +2,18 @@
import { useMqttStore } from '../stores/mqtt.js';
import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
// 全局状态与消息 API
const store = useMqttStore();
const msg = useMessage();
// 组件事件定义
const emit = defineEmits(['switch-tab', 'edit-server']);
/**
* 处理快捷操作:切换标签页并选中服务器
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
* @param {string} action - 目标标签页名称
*/
function handleAction(srv, action) {
if (srv.id === store.activeServerId.value) {
emit('switch-tab', action);
@@ -14,6 +23,10 @@ function handleAction(srv, action) {
}
}
/**
* 切换当前服务器连接状态
* @param {import('@types/jsdoc').MqttServer} srv - 服务器对象
*/
async function handleToggleConnection(srv) {
if (srv.status === 'connected') {
await store.disconnectServer(srv.id);
@@ -22,8 +35,6 @@ async function handleToggleConnection(srv) {
await store.connectServer(srv.id);
}
}
const emit = defineEmits(['switch-tab', 'edit-server']);
</script>
<template>
+18
View File
@@ -3,11 +3,17 @@ import { ref, computed } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NInput, NButton, NEmpty, useMessage } from 'naive-ui';
// 全局状态与消息 API
const store = useMqttStore();
const message = useMessage();
// 主题过滤关键字
const filter = ref('');
/**
* 复制主题到剪贴板
* @param {string} topic - 主题名称
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
@@ -17,6 +23,10 @@ async function copyTopic(topic) {
}
}
/**
* 根据过滤关键字筛选后的消息列表
* @type {import('vue').ComputedRef<Array<Object>>}
*/
const filteredMessages = computed(() => {
if (!filter.value.trim()) {
@@ -31,10 +41,18 @@ const filteredMessages = computed(() => {
})
/**
* 清空当前活动服务器的消息列表
*/
async function handleClear() {
await store.clearMessages();
}
/**
* 格式化消息 Payload,若可解析为 JSON 则格式化输出
* @param {string} payload - 消息内容
* @returns {string} 格式化后的内容
*/
function formatPayload(payload) {
try {
return JSON.stringify(JSON.parse(payload), null, 2);
+24
View File
@@ -3,19 +3,26 @@ import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NTabs, NTabPane, useMessage } from 'naive-ui';
// 全局状态与消息 API
const store = useMqttStore();
const msg = useMessage();
// 发布表单状态
const topic = ref('');
const payload = ref('');
const qos = ref(1);
const retain = ref(false);
// 当前激活的标签页
const activePublishTab = ref('publish');
// localStorage 持久化 key
const formStorageKey = 'mqtt-client.publish-form';
const publishTabStorageKey = 'mqtt-client.publish-active-tab';
let formReady = false;
// 从 localStorage 恢复发布表单
try {
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
@@ -40,6 +47,7 @@ try {
localStorage.removeItem(formStorageKey);
}
// 从 localStorage 恢复当前标签页
try {
let savedTab = localStorage.getItem(publishTabStorageKey);
@@ -52,6 +60,7 @@ try {
localStorage.removeItem(publishTabStorageKey);
}
// 表单变化时持久化
watch([topic, payload, qos, retain], () => {
if (!formReady) {
@@ -70,18 +79,23 @@ watch([topic, payload, qos, retain], () => {
});
// 标签页变化时持久化
watch(activePublishTab, (tab) => {
localStorage.setItem(publishTabStorageKey, tab);
});
formReady = true;
// QoS 下拉选项
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
/**
* 发布消息到当前服务器
*/
async function handlePublish() {
let srv = store.activeServer.value;
@@ -111,6 +125,9 @@ async function handlePublish() {
}
/**
* 格式化 Payload 为 JSON
*/
function handleFormatJson() {
try {
@@ -124,10 +141,17 @@ function handleFormatJson() {
}
}
/**
* 清空发布历史
*/
async function handleClearHistory() {
await store.clearPublishHistory();
}
/**
* 复制主题到剪贴板
* @param {string} topic - 主题名称
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
+839
View File
@@ -0,0 +1,839 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NModal, NForm, NGrid, NGridItem, NCheckbox, useMessage, useDialog } from 'naive-ui';
// 全局状态与消息 API
const store = useMqttStore();
const msg = useMessage();
const dialog = useDialog();
// localStorage 持久化 key
const storageKey = 'mqtt-client.quick-send-list';
/**
* @typedef {Object} QuickSendItem
* @property {string} id
* @property {string} topic
* @property {string} payload
* @property {0|1|2} qos
* @property {boolean} retain
*/
// 快速发送列表
/** @type {import('vue').Ref<QuickSendItem[]>} */
const quickList = ref([]);
// 表单弹窗显隐
const showModal = ref(false);
const isEdit = ref(false);
// 表单数据
const formId = ref('');
const formTopic = ref('');
const formPayload = ref('');
const formQos = ref(1);
const formRetain = ref(false);
// 从 localStorage 恢复列表
try {
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
if (Array.isArray(saved)) {
quickList.value = saved.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
}
} catch {
localStorage.removeItem(storageKey);
}
// 列表变化时持久化
watch(
quickList,
() => {
localStorage.setItem(storageKey, JSON.stringify(quickList.value));
},
{ deep: true }
);
// QoS 下拉选项
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
// 当前是否有选中的服务器
const hasActiveServer = computed(() => Boolean(store.activeServer.value));
// 拖拽排序状态
/** @type {import('vue').Ref<number>} */
const dragIndex = ref(-1);
/** @type {import('vue').Ref<QuickSendItem | null>} */
const dragItem = ref(null);
// 列表容器引用与响应式布局
/** @type {import('vue').Ref<HTMLElement | null>} */
const containerRef = ref(null);
const columnCount = ref(1);
/** @type {ResizeObserver | null} */
let resizeObserver = null;
// 批量操作状态
/** @type {import('vue').Ref<string[]>} */
const selectedIds = ref([]);
/** @type {import('vue').Ref<HTMLInputElement | null>} */
const fileInput = ref(null);
// 是否已全选
const isAllSelected = computed(() => {
return quickList.value.length > 0 && selectedIds.value.length === quickList.value.length;
});
// 是否有选中项
const hasSelected = computed(() => selectedIds.value.length > 0);
/**
* 生成唯一 id
* @returns {string}
*/
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* 打开新增弹窗
*/
function handleAdd() {
isEdit.value = false;
formId.value = '';
formTopic.value = '';
formPayload.value = '';
formQos.value = 1;
formRetain.value = false;
showModal.value = true;
}
/**
* 打开编辑弹窗
* @param {QuickSendItem} item
*/
function handleEdit(item) {
isEdit.value = true;
formId.value = item.id;
formTopic.value = item.topic;
formPayload.value = item.payload;
formQos.value = item.qos;
formRetain.value = item.retain;
showModal.value = true;
}
/**
* 保存快速发送项
*/
function handleSave() {
let topic = formTopic.value.trim();
if (!topic) {
msg.warning('请输入目标主题');
return;
}
let payload = formPayload.value;
let qos = formQos.value;
let retain = formRetain.value;
if (isEdit.value) {
let idx = quickList.value.findIndex((item) => item.id === formId.value);
if (idx !== -1) {
quickList.value[idx] = {
...quickList.value[idx],
topic,
payload,
qos,
retain,
};
}
} else {
quickList.value.push({
id: generateId(),
topic,
payload,
qos,
retain,
});
}
showModal.value = false;
}
/**
* 删除快速发送项
* @param {QuickSendItem} item
*/
function handleDelete(item) {
dialog.warning({
title: '确认删除',
content: `确定要删除主题 "${item.topic}" 的快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((i) => i.id !== item.id);
},
});
}
/**
* 开始拖拽
* @param {DragEvent} e
* @param {QuickSendItem} item
* @param {number} index
*/
function handleDragStart(e, item, index) {
dragIndex.value = index;
dragItem.value = item;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.id);
}
}
/**
* 拖拽经过目标项
* @param {DragEvent} e
* @param {number} index
*/
function handleDragOver(e, index) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
}
/**
* 放置拖拽项
* @param {DragEvent} e
* @param {number} index
*/
function handleDrop(e, index) {
e.preventDefault();
if (dragIndex.value === -1 || dragIndex.value === index) {
return;
}
let item = quickList.value[dragIndex.value];
quickList.value.splice(dragIndex.value, 1);
quickList.value.splice(index, 0, item);
}
/**
* 拖拽结束
*/
function handleDragEnd() {
dragIndex.value = -1;
dragItem.value = null;
}
/**
* 发送消息到当前服务器
* @param {QuickSendItem} item
*/
async function handleSend(item) {
let srv = store.activeServer.value;
if (!srv) {
msg.warning('请先在左侧选择一个 MQTT 服务器');
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
let result = await store.publishMessage(srv.id, item.topic, item.payload, {
qos: item.qos,
retain: item.retain,
});
if (result) {
msg.success('消息已发布到: ' + item.topic);
}
}
/**
* 复制主题到剪贴板
* @param {string} topic
*/
async function copyTopic(topic) {
try {
await navigator.clipboard.writeText(topic);
msg.success('主题已复制');
} catch {
msg.error('复制失败');
}
}
/**
* 格式化消息内容为 JSON
*/
function handleFormatJson() {
try {
let obj = JSON.parse(formPayload.value);
formPayload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch {
msg.warning('无效的 JSON 格式');
}
}
/**
* 选中或取消选中单个快速消息
* @param {string} id
* @param {boolean} checked
*/
function handleSelectItem(id, checked) {
if (checked) {
selectedIds.value.push(id);
} else {
selectedIds.value = selectedIds.value.filter((i) => i !== id);
}
}
/**
* 切换全选状态
*/
function handleToggleSelectAll() {
if (isAllSelected.value) {
selectedIds.value = [];
} else {
selectedIds.value = quickList.value.map((item) => item.id);
}
}
/**
* 批量删除选中的快速消息
*/
function handleBatchDelete() {
if (!hasSelected.value) {
return;
}
dialog.warning({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedIds.value.length} 条快速消息吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
quickList.value = quickList.value.filter((item) => !selectedIds.value.includes(item.id));
selectedIds.value = [];
},
});
}
/**
* 导出快速消息列表为 JSON 文件
*/
function handleExport() {
let data = quickList.value;
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = `frost-mqtt-quick-send-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
msg.success('快速消息已导出');
}
/**
* 触发文件导入点击
*/
function handleImportClick() {
fileInput.value?.click();
}
/**
* 处理导入的 JSON 文件
* @param {Event} event
*/
function handleImportFile(event) {
let file = event.target.files?.[0];
if (!file) {
return;
}
let reader = new FileReader();
reader.onload = (e) => {
try {
let data = JSON.parse(String(e.target?.result || ''));
if (!Array.isArray(data)) {
msg.error('导入失败: 文件内容不是数组');
return;
}
let validItems = data.filter((item) => {
return item && typeof item.id === 'string' && typeof item.topic === 'string';
});
if (validItems.length === 0) {
msg.warning('未找到有效的快速消息');
return;
}
dialog.create({
title: '选择导入方式',
content: `检测到 ${validItems.length} 条快速消息,请选择导入方式:`,
positiveText: '追加',
negativeText: '覆盖',
class: 'quick-send-dialog',
onPositiveClick: () => {
let existingIds = new Set(quickList.value.map((item) => item.id));
let itemsToAdd = validItems.map((item) => {
if (existingIds.has(item.id)) {
return { ...item, id: generateId() };
}
return item;
});
quickList.value.push(...itemsToAdd);
selectedIds.value = [];
msg.success(`已追加 ${itemsToAdd.length} 条快速消息`);
},
onNegativeClick: () => {
quickList.value = validItems;
selectedIds.value = [];
msg.success(`已覆盖为导入的 ${validItems.length} 条快速消息`);
},
});
} catch (err) {
msg.error('导入失败: ' + (err.message || '未知错误'));
}
};
reader.onerror = () => {
msg.error('读取文件失败');
};
reader.readAsText(file);
event.target.value = '';
}
function updateColumnCount(width) {
if (width >= 1520) {
columnCount.value = 3;
} else if (width >= 720) {
columnCount.value = 2;
} else {
columnCount.value = 1;
}
}
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
updateColumnCount(entry.contentRect.width);
}
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
updateColumnCount(containerRef.value.clientWidth);
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
<template>
<div
ref="containerRef"
class="quick-send-view"
>
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}"
>
<n-space
style="margin-bottom: 12px"
align="center"
>
<n-button
type="primary"
@click="handleAdd"
>新增快速消息</n-button>
<n-button
size="small"
:disabled="quickList.length === 0"
@click="handleToggleSelectAll"
>{{ isAllSelected ? '取消全选' : '全选' }}</n-button>
<n-button
size="small"
type="error"
ghost
:disabled="!hasSelected"
@click="handleBatchDelete"
>批量删除</n-button>
<n-button
size="small"
@click="handleImportClick"
>导入</n-button>
<n-button
size="small"
@click="handleExport"
>导出</n-button>
</n-space>
<n-empty
v-if="quickList.length === 0"
description="暂无快速发送消息,点击上方按钮添加"
/>
<div
v-else
class="quick-list"
:class="`columns-${columnCount}`"
>
<div
v-for="(item, index) in quickList"
:key="item.id"
class="quick-item"
:class="{ dragging: dragIndex === index }"
draggable="true"
@dragstart="handleDragStart($event, item, index)"
@dragover.prevent="handleDragOver($event, index)"
@drop.prevent="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<div class="quick-header">
<n-checkbox
class="quick-checkbox"
:checked="selectedIds.includes(item.id)"
@update:checked="(checked) => handleSelectItem(item.id, checked)"
@click.stop
/>
<div class="quick-topic-wrap">
<span class="quick-dir pub">PUB</span>
<span
class="quick-topic"
:title="item.topic"
@click="copyTopic(item.topic)"
>{{ item.topic }}</span>
<span class="quick-meta">QoS{{ item.qos }}{{ item.retain ? ' R' : '' }}</span>
</div>
<n-space>
<n-button
size="tiny"
:disabled="!hasActiveServer"
type="primary"
@click="handleSend(item)"
>发送</n-button>
<n-button
size="tiny"
@click="handleEdit(item)"
>编辑</n-button>
<n-button
size="tiny"
type="error"
ghost
@click="handleDelete(item)"
>删除</n-button>
</n-space>
</div>
<pre
v-if="item.payload"
class="quick-payload"
>{{ item.payload }}</pre>
</div>
</div>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleImportFile"
>
</n-card>
<n-modal
v-model:show="showModal"
class="quick-send-modal"
:title="isEdit ? '编辑快速消息' : '新增快速消息'"
preset="card"
style="width: 520px; max-width: 90vw"
:bordered="false"
:segmented="{ content: true }"
>
<n-form
label-placement="top"
:show-feedback="false"
>
<n-form-item label="目标主题">
<n-input
v-model:value="formTopic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item>
<n-grid
cols="2"
x-gap="12"
>
<n-grid-item>
<n-form-item label="QoS">
<n-select
v-model:value="formQos"
:options="qosOptions"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="保留消息">
<n-switch v-model:value="formRetain" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="消息内容">
<div class="payload-field">
<n-input
v-model:value="formPayload"
type="textarea"
:resizable="false"
:rows="6"
placeholder="输入要发送的消息内容"
/>
<n-button
size="small"
@click="handleFormatJson"
>格式化 JSON</n-button>
</div>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showModal = false">取消</n-button>
<n-button
type="primary"
@click="handleSave"
>保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<style scoped>
.quick-send-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.quick-send-view :deep(.n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.quick-send-view :deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.quick-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding-right: 4px;
align-content: start;
}
.quick-list.columns-1 {
grid-template-columns: 1fr;
}
.quick-list.columns-2 {
grid-template-columns: repeat(2, 1fr);
}
.quick-list.columns-3 {
grid-template-columns: repeat(3, 1fr);
}
.payload-field {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.payload-field :deep(.n-input) {
width: 100%;
}
.payload-field .n-button {
align-self: flex-end;
}
.quick-item {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
padding: 12px;
background: #fafafa;
cursor: grab;
transition: box-shadow 0.2s, opacity 0.2s;
}
.quick-item.dragging {
opacity: 0.6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
cursor: grabbing;
}
.quick-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.quick-checkbox {
flex-shrink: 0;
margin-top: 2px;
}
.quick-checkbox :deep(.n-checkbox-box) {
width: 16px;
height: 16px;
}
.quick-topic-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.quick-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
font-size: 12px;
}
.quick-dir.pub {
color: #3b82f6;
}
.quick-topic {
color: #d25a00;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: monospace;
font-size: 13px;
}
.quick-topic:hover {
text-decoration: underline;
}
.quick-meta {
color: #999;
font-size: 11px;
flex-shrink: 0;
font-family: monospace;
}
.quick-payload {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.04);
color: #333;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: monospace;
font-size: 12px;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
.quick-send-modal .n-form-item {
margin-bottom: 20px;
}
.quick-send-modal .n-form-item__label {
margin-bottom: 8px;
}
.quick-send-modal .n-form-item:last-child {
margin-bottom: 0;
}
.quick-send-modal .n-grid {
margin-bottom: 20px;
}
.quick-send-dialog .n-dialog__icon {
color: #0ea5a0;
}
</style>
+183 -30
View File
@@ -1,105 +1,142 @@
<script setup>
import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js';
import {
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NButton,
NEmpty,
NList,
NListItem,
NTag,
NEllipsis,
useMessage,
} from 'naive-ui';
import { NCard, NSpace, NFormItem, NInput, NSelect, NButton, NEmpty, NList, NListItem, NTag, useMessage } from 'naive-ui';
// 全局状态与消息 API
const store = useMqttStore();
const msg = useMessage();
// 主题输入与 QoS 选择
const topicInput = ref('');
const qos = ref(2);
const comment = ref('');
// 编辑状态
const editingTopicId = ref(null);
const editingTopic = ref('');
const editingQos = ref(2);
const editingComment = ref('');
// QoS 下拉选项
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 },
];
// 表单本地存储 key
const formStorageKey = 'mqtt-client.subscribe-form';
let formReady = false;
// 从 localStorage 恢复表单输入
try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.topicInput === 'string') {
topicInput.value = savedForm.topicInput;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
} catch {
localStorage.removeItem(formStorageKey);
}
// 表单变化时持久化到 localStorage
watch([topicInput, qos], () => {
if (!formReady) {
return;
}
localStorage.setItem(
formStorageKey,
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
);
});
formReady = true;
/**
* 添加新主题
*/
async function handleAddTopic() {
const topic = topicInput.value.trim();
let topic = topicInput.value.trim();
if (!topic) {
msg.warning('请输入主题名称');
return;
}
const srv = store.activeServer.value;
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
msg.warning('该主题已存在');
return;
}
const result = await store.addTopic(srv.id, topic, qos.value);
let result = await store.addTopic(srv.id, topic, qos.value, comment.value.trim());
if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
topicInput.value = '';
comment.value = '';
}
}
/**
* 移除主题
* @param {string} topic - 主题名称
*/
async function handleRemoveTopic(topic) {
const srv = store.activeServer.value;
let srv = store.activeServer.value;
if (!srv) {
return;
}
await store.removeTopic(srv.id, topic);
msg.info('主题已移除');
}
/**
* 切换单个主题订阅状态
* @param {string} topic - 主题名称
*/
async function handleToggleSub(topic) {
const srv = store.activeServer.value;
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
const t = srv.topics.find((t) => t.topic === topic);
let t = srv.topics.find((t) => t.topic === topic);
if (!t) {
return;
}
if (t.subscribed) {
await store.unsubscribeTopic(srv.id, topic);
msg.info('已取消订阅: ' + topic);
@@ -107,43 +144,70 @@ async function handleToggleSub(topic) {
await store.subscribeTopic(srv.id, topic);
msg.info('已订阅: ' + topic);
}
}
/**
* 批量订阅当前服务器下所有未订阅主题
*/
async function handleSubscribeAll() {
const srv = store.activeServer.value;
let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
await store.subscribeAllTopics(srv.id);
msg.success('已订阅全部主题');
}
/**
* 进入主题编辑状态
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
function startEdit(t) {
editingTopicId.value = t.id || t.topic;
editingTopic.value = t.topic;
editingQos.value = t.qos ?? 2;
editingComment.value = t.comment ?? '';
}
/**
* 取消主题编辑
*/
function cancelEdit() {
editingTopicId.value = null;
editingTopic.value = '';
editingQos.value = 2;
editingComment.value = '';
}
/**
* 保存主题编辑
* @param {import('@types/jsdoc').MqttTopic} t - 主题对象
*/
async function handleEditSave(t) {
const srv = store.activeServer.value;
let srv = store.activeServer.value;
if (!srv) {
return;
}
const topicName = editingTopic.value.trim();
let topicName = editingTopic.value.trim();
if (!topicName) {
msg.warning('请输入主题名称');
return;
}
if (
srv.topics.find(
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
@@ -152,22 +216,34 @@ async function handleEditSave(t) {
msg.warning('该主题已存在');
return;
}
const topicId = t.id || t.topic;
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value);
let topicId = t.id || t.topic;
let result = await store.updateTopic(
srv.id,
topicId,
topicName,
editingQos.value,
editingComment.value.trim()
);
if (result) {
msg.success('主题已更新');
cancelEdit();
} else {
msg.error('主题更新失败');
}
}
</script>
<template>
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="subscribe-view"
>
<!-- 添加主题 -->
<n-card
title="添加订阅主题"
@@ -193,6 +269,16 @@ async function handleEditSave(t) {
:options="qosOptions"
/>
</n-form-item>
<n-form-item
class="comment-field"
label="备注"
>
<n-input
v-model:value="comment"
placeholder="可选备注"
@keydown.enter="handleAddTopic"
/>
</n-form-item>
<n-button
class="topic-submit"
type="primary"
@@ -264,6 +350,11 @@ async function handleEditSave(t) {
:options="qosOptions"
style="width: 160px; flex-shrink: 0"
/>
<n-input
v-model:value="editingComment"
size="small"
placeholder="备注"
/>
<n-space
:size="4"
:wrap="false"
@@ -287,7 +378,20 @@ async function handleEditSave(t) {
v-else
class="topic-name"
>
<n-ellipsis>{{ t.topic }}</n-ellipsis>
<span
class="topic-label"
:title="t.topic"
>{{ t.topic }}</span>
<div
v-if="t.comment"
class="topic-comment"
>
<span class="topic-comment-prefix">备注</span>
<span
class="topic-comment-text"
:title="t.comment"
>{{ t.comment }}</span>
</div>
</div>
<template #suffix>
<n-space
@@ -323,11 +427,15 @@ async function handleEditSave(t) {
</n-list>
</div>
</n-card>
</div>
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template>
<style scoped>
@@ -374,12 +482,18 @@ async function handleEditSave(t) {
width: 160px;
}
.comment-field {
flex: 1 1 180px;
width: auto;
}
.topic-submit {
flex: 0 0 auto;
}
.topic-field :deep(.n-form-item-blank),
.qos-field :deep(.n-form-item-blank) {
.qos-field :deep(.n-form-item-blank),
.comment-field :deep(.n-form-item-blank) {
width: 100%;
}
@@ -391,12 +505,51 @@ async function handleEditSave(t) {
}
.topic-name {
min-width: 0;
display: flex;
flex: 1;
font-family: monospace;
flex-direction: column;
align-items: stretch;
min-width: 0;
gap: 5px;
font-size: 13px;
}
.topic-label {
min-width: 0;
overflow: hidden;
font-family: monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-comment {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding: 2px 6px;
gap: 5px;
border: 1px solid rgba(14, 165, 160, 0.16);
border-radius: 4px;
background: rgba(20, 184, 166, 0.06);
color: var(--n-text-color-3);
font-size: 12px;
line-height: 18px;
}
.topic-comment-prefix {
flex-shrink: 0;
color: #0f766e;
}
.topic-comment-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-actions {
flex-shrink: 0;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 公共 MQTT 类型定义
* 供 renderer 与 main 进程共享,避免在多个模块中重复定义 JSDoc @typedef
*/
/** MQTT QoS 等级 */
export type MqttQoS = 0 | 1 | 2;
/** MQTT 服务器连接状态 */
export type MqttServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
/** MQTT 主题 */
export interface MqttTopic {
/** 主题唯一标识 */
id: string;
/** 主题名称(支持通配符) */
topic: string;
/** 订阅 QoS 等级 */
qos: MqttQoS;
/** 主题备注,空字符串表示未备注 */
comment: string;
/** 当前是否已订阅 */
subscribed: boolean;
}
/** 主题配置(用于导入/导出或表单) */
export interface TopicConfig {
/** 主题唯一标识,未提供时由后端生成 */
id?: string;
/** 主题名称(支持通配符) */
topic: string;
/** 订阅 QoS 等级,默认 1 */
qos?: MqttQoS;
/** 主题备注,默认空字符串 */
comment?: string;
}
/** MQTT 服务器配置(不含运行时状态) */
export interface ServerConfig {
/** 显示名称 */
name: string;
/** 主机地址 */
host: string;
/** 端口号,默认 1883 */
port?: number;
/** 连接协议,默认 'mqtt' */
protocol?: 'mqtt' | 'mqtts' | 'ws' | 'wss';
/** 客户端 ID,默认随机生成 */
clientId?: string;
/** 用户名,默认空字符串 */
username?: string;
/** 密码,默认空字符串 */
password?: string;
/** 心跳间隔(秒),默认 60 */
keepAlive?: number;
/** 是否清理会话,默认 true */
cleanSession?: boolean;
/** 连接超时(毫秒),默认 10000 */
connectTimeout?: number;
/** 是否自动重连,默认 true */
reconnect?: boolean;
/** 重连间隔(毫秒),默认 5000 */
reconnectInterval?: number;
/** 初始主题列表 */
topics?: TopicConfig[];
}
/** MQTT 服务器运行时对象 */
export interface MqttServer extends ServerConfig {
/** 服务器唯一标识 */
id: string;
/** 当前连接状态 */
status: MqttServerStatus;
/** 该服务器下的主题列表 */
topics: MqttTopic[];
}
/** 发布/订阅选项 */
export interface PublishOptions {
/** QoS 等级,默认 1 */
qos?: MqttQoS;
/** 是否保留消息,默认 false */
retain?: boolean;
}
/** MQTT 消息(订阅接收或本地发布) */
export interface MqttMessage {
/** 消息唯一标识 */
id: string;
/** 接收/发送时间(格式为 HH:mm:ss.SSS */
time: string;
/** 消息主题 */
topic: string;
/** 消息负载内容 */
payload: string;
/** 消息 QoS 等级 */
qos: MqttQoS;
/** 是否为保留消息 */
retain: boolean;
/** 消息方向:pub 表示发布,sub 表示订阅 */
direction: 'pub' | 'sub';
}
/** 发布历史记录 */
export interface PublishRecord {
/** 发布时间(格式为 HH:mm:ss.SSS */
time: string;
/** 目标主题 */
topic: string;
/** 发布内容 */
payload: string;
/** 发布 QoS 等级 */
qos: MqttQoS;
/** 是否保留消息 */
retain: boolean;
/** 固定为 pub */
direction: 'pub';
}
+49
View File
@@ -0,0 +1,49 @@
# 代码风格
## 基础格式
- 使用 UTF-8、LF 换行、2 个空格缩进;文件末尾保留换行,删除行尾空白。
- JavaScript、Vue SFC 使用分号结尾。
- 字符串通常使用单引号;模板属性和值使用双引号。
- 对象、数组尾项保留逗号。
- 复杂函数的函数体内常使用空行分隔初始化、分支和返回逻辑;保持与所在文件一致即可。
## JavaScript
- 使用 ES Module`import` / `export`
- 导入顺序通常为:框架或第三方依赖、项目内部模块、资源文件;不同分组之间空一行。
- 变量优先使用 `const`;需要重新赋值的局部变量使用 `let`;不使用 `var`
- 函数以具名 `function` 声明为主;回调和简单映射使用箭头函数。
- 命名使用 camelCase:变量、函数、方法和参数均使用英文语义名称;布尔值常以 `is``show``has` 等开头。
- 常量或配置集合也多使用 camelCase,例如 `protocolOptions``themeOverrides`
- 错误处理使用 `try...catch`,错误信息通过中文 `console.error` 或界面消息反馈。
## 注释与类型
- 注释、JSDoc 说明和面向用户的提示文案使用简体中文。
- 按职责使用 `// ==================== 模块名 ====================` 分隔较长的文件。
- 公开方法、重要状态和较复杂逻辑使用 JSDoc 描述用途、参数和返回值。
- JavaScript 项目通过 JSDoc 引入或声明类型,例如 `@typedef``@param``@returns`,公共 MQTT 类型集中引用 `types/jsdoc.d.ts`
- 简单实现可配合单行中文注释说明意图,避免重复描述显而易见的代码。
## Vue 组件
- 单文件组件按 `<script setup>``<template>``<style>` 的顺序组织。
- 组件文件使用 PascalCase,例如 `ServerModal.vue`;组件在模板中同样使用 PascalCase。
- `script setup` 中通常依次放置:导入、Props/Emits、响应式状态、静态配置、侦听或生命周期、事件处理函数。
- 事件处理函数以 `handle` 开头,例如 `handleSave`;模板事件名采用 kebab-case,例如 `@edit-server`
- Naive UI 组件使用 `NButton` 等 PascalCase 导入,在模板中写为 `n-button`
- 模板属性一行较长时换行,每个属性单独一行,并保持 2 空格缩进。
- 样式采用普通 CSS;类名使用 kebab-case;布局和组件私有样式就近放在对应组件中。
## Electron 分层
- `src/main` 处理窗口、IPC 与 MQTT 等主进程逻辑。
- `src/preload` 统一封装并暴露 `window.api`,渲染进程不直接使用 Electron IPC。
- `src/renderer` 放置 Vue 页面、组件和状态管理;通过 `window.api` 调用主进程能力。
## ESLint
- 使用 `@electron-toolkit/eslint-config` 与 Vue 推荐规则作为基础。
- Vue 组件允许单词名称;未强制默认 Prop;模板单行或多行内容换行规则已关闭。
- 提交前执行 `pnpm lint`,并修复实际报告的问题。