style: 整理代码格式

This commit is contained in:
2026-07-19 17:15:28 +08:00
parent 6bd13851d9
commit 78797dee86
15 changed files with 1354 additions and 874 deletions

View File

@@ -6,4 +6,4 @@ indent_style = space
indent_size = 2 indent_size = 2
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true

View File

@@ -1,10 +1,12 @@
import { app, shell, BrowserWindow } from 'electron' import { app, shell, BrowserWindow } from 'electron';
import { join } from 'path' import { join } from 'path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import icon from '../../resources/icon.png?asset' import { registerIpcHandlers } from './ipc-handlers.js';
import { registerIpcHandlers } from './ipc-handlers.js'
import icon from '../../resources/icon.png?asset';
function createWindow() { function createWindow() {
// Create the browser window. // Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
@@ -16,62 +18,67 @@ function createWindow() {
...(process.platform === 'linux' ? { icon } : {}), ...(process.platform === 'linux' ? { icon } : {}),
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false sandbox: false,
} },
}) });
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
mainWindow.show() mainWindow.show();
}) });
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) shell.openExternal(details.url);
return { action: 'deny' } return { action: 'deny' };
}) });
// HMR for renderer base on electron-vite cli. // HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production. // Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
} }
} }
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.whenReady().then(() => { app.whenReady().then(() => {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron');
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window);
}) });
// 注册 IPC 处理器 // 注册 IPC 处理器
registerIpcHandlers() registerIpcHandlers();
createWindow() createWindow();
app.on('activate', function () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // 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. // dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow() if (BrowserWindow.getAllWindows().length === 0) {
}) createWindow();
}) }
});
});
// Quit when all windows are closed, except on macOS. There, it's common // 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 // for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q. // explicitly with Cmd + Q.
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit();
} }
}) });
// In this file you can include the rest of your app's specific main process // 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. // code. You can also put them in separate files and require them here.

View File

@@ -1,18 +1,20 @@
import { ipcMain, BrowserWindow } from 'electron' import { ipcMain, BrowserWindow } from 'electron';
import mqttManager from './mqtt-manager.js'
import mqttManager from './mqtt-manager.js';
export function registerIpcHandlers() { export function registerIpcHandlers() {
// 获取主窗口(用于推送事件) // 获取主窗口(用于推送事件)
function getMainWindow() { function getMainWindow() {
const windows = BrowserWindow.getAllWindows() const windows = BrowserWindow.getAllWindows();
return windows.length > 0 ? windows[0] : null return windows.length > 0 ? windows[0] : null;
} }
// 向渲染进程发送事件 // 向渲染进程发送事件
function sendToRenderer(channel, data) { function sendToRenderer(channel, data) {
const win = getMainWindow() const win = getMainWindow();
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.webContents.send(channel, data) win.webContents.send(channel, data);
} }
} }
@@ -20,100 +22,103 @@ export function registerIpcHandlers() {
mqttManager.addListener((event, data) => { mqttManager.addListener((event, data) => {
switch (event) { switch (event) {
case 'status-change': case 'status-change':
sendToRenderer('mqtt:status-change', data) sendToRenderer('mqtt:status-change', data);
break break;
case 'message': case 'message':
sendToRenderer('mqtt:message', data) sendToRenderer('mqtt:message', data);
break break;
case 'subscription-change': case 'subscription-change':
sendToRenderer('mqtt:subscription-change', data) sendToRenderer('mqtt:subscription-change', data);
break break;
case 'subscription-error': case 'subscription-error':
sendToRenderer('mqtt:subscription-error', data) sendToRenderer('mqtt:subscription-error', data);
break break;
case 'publish-error': case 'publish-error':
sendToRenderer('mqtt:publish-error', data) sendToRenderer('mqtt:publish-error', data);
break break;
} }
}) });
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
ipcMain.handle('mqtt:get-servers', () => { ipcMain.handle('mqtt:get-servers', () => {
return mqttManager.getServers() return mqttManager.getServers();
}) });
ipcMain.handle('mqtt:add-server', (_, config) => { ipcMain.handle('mqtt:add-server', (_, config) => {
return mqttManager.addServer(config) return mqttManager.addServer(config);
}) });
ipcMain.handle('mqtt:update-server', (_, id, config) => { ipcMain.handle('mqtt:update-server', (_, id, config) => {
return mqttManager.updateServer(id, config) return mqttManager.updateServer(id, config);
}) });
ipcMain.handle('mqtt:delete-server', (_, id) => { ipcMain.handle('mqtt:delete-server', (_, id) => {
mqttManager.deleteServer(id) mqttManager.deleteServer(id);
return true return true;
}) });
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
ipcMain.handle('mqtt:connect', (_, id) => { ipcMain.handle('mqtt:connect', (_, id) => {
return mqttManager.connect(id) return mqttManager.connect(id);
}) });
ipcMain.handle('mqtt:disconnect', (_, id) => { ipcMain.handle('mqtt:disconnect', (_, id) => {
mqttManager.disconnect(id) mqttManager.disconnect(id);
return true return true;
}) });
// ==================== 主题管理 ==================== // ==================== 主题管理 ====================
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => { ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
return mqttManager.addTopic(serverId, topic, qos) return mqttManager.addTopic(serverId, topic, qos);
}) });
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => { ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
return mqttManager.removeTopic(serverId, topic) return mqttManager.removeTopic(serverId, topic);
}) });
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => { ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
return mqttManager.subscribe(serverId, topic) return mqttManager.subscribe(serverId, topic);
}) });
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => { ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
return mqttManager.unsubscribe(serverId, topic) return mqttManager.unsubscribe(serverId, topic);
}) });
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => { ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
return mqttManager.subscribeAll(serverId) return mqttManager.subscribeAll(serverId);
}) });
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => { ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos) return mqttManager.updateTopic(serverId, topicId, topic, qos);
}) });
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => { ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => {
return mqttManager.publish(serverId, topic, payload, opts) return mqttManager.publish(serverId, topic, payload, opts);
}) });
// ==================== 消息管理 ==================== // ==================== 消息管理 ====================
ipcMain.handle('mqtt:get-messages', (_, serverId) => { ipcMain.handle('mqtt:get-messages', (_, serverId) => {
return mqttManager.getMessages(serverId) return mqttManager.getMessages(serverId);
}) });
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => { ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
return mqttManager.getPublishHistory(serverId) return mqttManager.getPublishHistory(serverId);
}) });
ipcMain.handle('mqtt:clear-messages', (_, serverId) => { ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
mqttManager.clearMessages(serverId) mqttManager.clearMessages(serverId);
return true return true;
}) });
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => { ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
mqttManager.clearPublishHistory(serverId) mqttManager.clearPublishHistory(serverId);
return true return true;
}) });
// 兼容旧的 ping 测试
ipcMain.on('ping', () => console.log('pong'))
} }

View File

@@ -1,89 +1,104 @@
import mqtt from 'mqtt' import { app } from 'electron';
import { app } from 'electron' import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path' import { join } from 'path';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import mqtt from 'mqtt';
class MqttManager { class MqttManager {
constructor() {
this.clients = new Map() // serverId -> mqtt client
this.servers = new Map() // serverId -> server config
this.messages = new Map() // serverId -> message[]
this.publishHistory = new Map() // serverId -> publishHistory[]
this.listeners = new Set() // IPC callback functions
this.msgIdCounter = 0 // 用于生成唯一消息 id
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json') constructor() {
this.loadData() this.clients = new Map(); // serverId -> mqtt client
this.servers = new Map(); // serverId -> server config
this.messages = new Map(); // serverId -> message[]
this.publishHistory = new Map(); // serverId -> publishHistory[]
this.listeners = new Set(); // IPC callback functions
this.msgIdCounter = 0; // 用于生成唯一消息 id
this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json');
this.loadData();
} }
nextMsgId() { nextMsgId() {
this.msgIdCounter = (this.msgIdCounter + 1) % Number.MAX_SAFE_INTEGER this.msgIdCounter = (this.msgIdCounter + 1) % Number.MAX_SAFE_INTEGER;
return 'msg_' + Date.now() + '_' + this.msgIdCounter return 'msg_' + Date.now() + '_' + this.msgIdCounter;
} }
// ==================== 数据持久化 ==================== // ==================== 数据持久化 ====================
getDataDir() { getDataDir() {
const dir = app.getPath('userData')
let dir = app.getPath('userData');
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true });
} }
return dir
return dir;
} }
loadData() { loadData() {
try { try {
if (existsSync(this.dataPath)) { if (existsSync(this.dataPath)) {
const raw = readFileSync(this.dataPath, 'utf-8')
const data = JSON.parse(raw) let raw = readFileSync(this.dataPath, 'utf-8');
let data = JSON.parse(raw);
if (data.servers) { if (data.servers) {
data.servers.forEach((s) => { data.servers.forEach((s) => {
s.status = 'disconnected' s.status = 'disconnected';
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),
subscribed: false subscribed: false,
})) }));
this.servers.set(s.id, s) this.servers.set(s.id, s);
}) });
} }
if (data.messages) { if (data.messages) {
Object.entries(data.messages).forEach(([id, msgs]) => { Object.entries(data.messages).forEach(([id, msgs]) => {
this.messages.set(id, msgs) this.messages.set(id, msgs);
}) });
} }
if (data.publishHistory) { if (data.publishHistory) {
Object.entries(data.publishHistory).forEach(([id, history]) => { Object.entries(data.publishHistory).forEach(([id, history]) => {
this.publishHistory.set(id, history) this.publishHistory.set(id, history);
}) });
} }
} else { } else {
this.initDefaultServers() this.initDefaultServers();
} }
} catch (e) { } catch (e) {
console.error('加载数据失败:', e.message) console.error('加载数据失败:', e.message);
this.initDefaultServers() this.initDefaultServers();
} }
} }
saveData() { saveData() {
try { try {
const data = {
let data = {
servers: Array.from(this.servers.values()).map((s) => ({ servers: Array.from(this.servers.values()).map((s) => ({
...s, ...s,
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),
} };
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8')
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8');
} catch (e) { } catch (e) {
console.error('保存数据失败:', e.message) console.error('保存数据失败:', e.message);
} }
} }
initDefaultServers() { initDefaultServers() {
const defaults = [
let defaults = [
{ {
id: 'example_server', id: 'example_server',
name: 'EMQX 公共测试', name: 'EMQX 公共测试',
@@ -98,52 +113,58 @@ class MqttManager {
status: 'disconnected', status: 'disconnected',
topics: [ topics: [
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false }, { 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_002', topic: 'device/status', qos: 2, subscribed: false },
] ],
} },
] ];
defaults.forEach((s) => { defaults.forEach((s) => {
this.servers.set(s.id, s) this.servers.set(s.id, s);
this.messages.set(s.id, []) this.messages.set(s.id, []);
this.publishHistory.set(s.id, []) this.publishHistory.set(s.id, []);
}) });
this.saveData()
this.saveData();
} }
// ==================== 监听器 ==================== // ==================== 监听器 ====================
addListener(callback) { addListener(callback) {
this.listeners.add(callback) this.listeners.add(callback);
} }
removeListener(callback) { removeListener(callback) {
this.listeners.delete(callback) this.listeners.delete(callback);
} }
notify(event, data) { notify(event, data) {
this.listeners.forEach((cb) => { this.listeners.forEach((cb) => {
try { try {
cb(event, data) cb(event, data);
} catch (e) { } catch (e) {
console.error('通知失败:', e) console.error('通知失败:', e);
} }
}) });
} }
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
getServers() { getServers() {
return Array.from(this.servers.values()).map((s) => ({ return Array.from(this.servers.values()).map((s) => ({
...s, ...s,
password: s.password ? '******' : '' password: s.password ? '******' : '',
})) }));
} }
getServer(id) { getServer(id) {
return this.servers.get(id) || null return this.servers.get(id) || null;
} }
addServer(config) { addServer(config) {
const id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
const server = { let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
let server = {
id, id,
name: config.name, name: config.name,
host: config.host, host: config.host,
@@ -155,18 +176,26 @@ class MqttManager {
keepAlive: config.keepAlive || 60, keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false, cleanSession: config.cleanSession !== false,
status: 'disconnected', status: 'disconnected',
topics: [] topics: [],
} };
this.servers.set(id, server)
this.messages.set(id, []) this.servers.set(id, server);
this.publishHistory.set(id, []) this.messages.set(id, []);
this.saveData() this.publishHistory.set(id, []);
return server this.saveData();
return server;
} }
updateServer(id, config) { updateServer(id, config) {
const server = this.servers.get(id)
if (!server) return null let server = this.servers.get(id);
if (!server) {
return null;
}
Object.assign(server, { Object.assign(server, {
name: config.name, name: config.name,
host: config.host, host: config.host,
@@ -175,283 +204,373 @@ class MqttManager {
clientId: config.clientId || server.clientId, clientId: config.clientId || server.clientId,
username: config.username || '', username: config.username || '',
keepAlive: config.keepAlive || 60, keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false cleanSession: config.cleanSession !== false,
}) });
if (config.password) { if (config.password) {
server.password = config.password server.password = config.password;
} }
this.saveData()
return server this.saveData();
return server;
} }
deleteServer(id) { deleteServer(id) {
// 先断开连接 // 先断开连接
this.disconnect(id) this.disconnect(id);
this.servers.delete(id) this.servers.delete(id);
this.messages.delete(id) this.messages.delete(id);
this.publishHistory.delete(id) this.publishHistory.delete(id);
this.saveData() this.saveData();
} }
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
connect(id) { connect(id) {
const server = this.servers.get(id)
if (!server) return false let server = this.servers.get(id);
if (!server) {
return false;
}
// 如果已连接,先断开 // 如果已连接,先断开
if (this.clients.has(id)) { if (this.clients.has(id)) {
this.disconnect(id) this.disconnect(id);
} }
server.status = 'connecting' server.status = 'connecting';
this.notify('status-change', { id, status: 'connecting' })
const protocol = server.protocol || 'mqtt' this.notify('status-change', { id, status: 'connecting' });
const url = `${protocol}://${server.host}:${server.port}`
const options = { let protocol = server.protocol || 'mqtt';
let url = `${protocol}://${server.host}:${server.port}`;
let options = {
clientId: server.clientId, clientId: server.clientId,
username: server.username || undefined, username: server.username || undefined,
password: server.password || undefined, password: server.password || undefined,
keepalive: server.keepAlive, keepalive: server.keepAlive,
clean: server.cleanSession, clean: server.cleanSession,
reconnectPeriod: 5000, reconnectPeriod: 5000,
connectTimeout: 10000 connectTimeout: 10000,
} };
try { try {
const client = mqtt.connect(url, options)
let client = mqtt.connect(url, options);
client.on('connect', () => { client.on('connect', () => {
server.status = 'connected' server.status = 'connected';
this.notify('status-change', { id, status: 'connected' }) this.notify('status-change', { id, status: 'connected' });
// 自动订阅已有主题 // 自动订阅已有主题
server.topics.forEach((t) => { server.topics.forEach((t) => {
this._doSubscribe(id, t.topic, { qos: t.qos }) this._doSubscribe(id, t.topic, { qos: t.qos });
}) });
}) });
client.on('error', (err) => { client.on('error', (err) => {
console.error(`[${server.name}] 连接错误:`, err.message) console.error(`[${server.name}] 连接错误:`, err.message);
server.status = 'error' server.status = 'error';
this.notify('status-change', { id, status: 'error', error: err.message }) this.notify('status-change', { id, status: 'error', error: err.message });
}) });
client.on('close', () => { client.on('close', () => {
if (server.status === 'connected') { if (server.status === 'connected') {
server.status = 'disconnected' server.status = 'disconnected';
server.topics.forEach((t) => { server.topics.forEach((t) => {
t.subscribed = false t.subscribed = false;
}) });
this.notify('status-change', { id, status: 'disconnected' }) this.notify('status-change', { id, status: 'disconnected' });
} }
}) });
client.on('message', (topic, payload, packet) => { client.on('message', (topic, payload, packet) => {
const msg = {
time: let msg = {
new Date().toLocaleTimeString('zh-CN', { hour12: false }) + time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(Date.now() % 1000).padStart(3, '0'),
'.' + topic: topic,
String(Date.now() % 1000).padStart(3, '0'),
topic,
payload: payload.toString(), payload: payload.toString(),
qos: packet.qos, qos: packet.qos,
retain: packet.retain, retain: packet.retain,
direction: 'sub', direction: 'sub',
id: this.nextMsgId() id: this.nextMsgId(),
} };
const msgs = this.messages.get(id) || []
msgs.unshift(msg) let msgs = this.messages.get(id) || [];
if (msgs.length > 500) msgs.pop()
this.messages.set(id, msgs) msgs.unshift(msg);
this.notify('message', { serverId: id, message: msg })
}) if (msgs.length > 500) {
msgs.pop();
}
this.messages.set(id, msgs);
this.notify('message', { serverId: id, message: msg });
});
this.clients.set(id, client);
return true;
this.clients.set(id, client)
return true
} catch (err) { } catch (err) {
server.status = 'error' server.status = 'error';
this.notify('status-change', { id, status: 'error', error: err.message }) this.notify('status-change', { id, status: 'error', error: err.message });
return false return false;
} }
} }
disconnect(id) { disconnect(id) {
const client = this.clients.get(id)
let client = this.clients.get(id);
if (client) { if (client) {
client.end(true) client.end(true);
this.clients.delete(id) this.clients.delete(id);
} }
const server = this.servers.get(id)
let server = this.servers.get(id);
if (server) { if (server) {
server.status = 'disconnected' server.status = 'disconnected';
server.topics.forEach((t) => { server.topics.forEach((t) => {
t.subscribed = false t.subscribed = false;
}) });
this.notify('status-change', { id, status: 'disconnected' }) this.notify('status-change', { id, status: 'disconnected' });
} }
} }
// ==================== 订阅管理 ==================== // ==================== 订阅管理 ====================
_doSubscribe(serverId, topic, opts) { _doSubscribe(serverId, topic, opts) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId) let client = this.clients.get(serverId);
if (!client || !server || server.status !== 'connected') return false let server = this.servers.get(serverId);
if (!client || !server || server.status !== 'connected') {
return false;
}
client.subscribe(topic, opts, (err) => { client.subscribe(topic, opts, (err) => {
if (err) { if (err) {
console.error(`订阅失败 [${topic}]:`, err.message) console.error(`订阅失败 [${topic}]:`, err.message);
this.notify('subscription-error', { serverId, topic, error: err.message }) this.notify('subscription-error', { serverId, topic, error: err.message });
} else { } else {
const t = server.topics.find((t) => t.topic === topic) let t = server.topics.find((t) => t.topic === topic);
if (t) t.subscribed = true if (t) {
this.notify('subscription-change', { serverId, topic, subscribed: true }) t.subscribed = true;
}
this.notify('subscription-change', { serverId, topic, subscribed: true });
} }
}) });
return true
return true;
} }
addTopic(serverId, topic, qos = 1) { addTopic(serverId, topic, qos = 1) {
const server = this.servers.get(serverId) let server = this.servers.get(serverId);
if (!server) return false if (!server) {
if (server.topics.find((t) => t.topic === topic)) return false return false;
}
if (server.topics.find((t) => t.topic === topic)) {
return false;
}
const topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) let topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
server.topics.push({ id: topicId, topic, qos, subscribed: false })
this.saveData() server.topics.push({ id: topicId, topic, qos, subscribed: false });
this.saveData();
// 如果已连接,自动订阅 // 如果已连接,自动订阅
if (server.status === 'connected') { if (server.status === 'connected') {
this._doSubscribe(serverId, topic, { qos }) this._doSubscribe(serverId, topic, { qos });
} }
return true
return true;
} }
removeTopic(serverId, topic) { removeTopic(serverId, topic) {
const server = this.servers.get(serverId)
if (!server) return false let server = this.servers.get(serverId);
if (!server) {
return false;
}
// 先取消订阅 // 先取消订阅
this.unsubscribe(serverId, topic) this.unsubscribe(serverId, topic);
server.topics = server.topics.filter((t) => t.topic !== topic)
this.saveData() server.topics = server.topics.filter((t) => t.topic !== topic);
return true
this.saveData();
return true;
} }
subscribe(serverId, topic) { subscribe(serverId, topic) {
const server = this.servers.get(serverId)
if (!server) return false let server = this.servers.get(serverId);
const t = server.topics.find((t) => t.topic === topic)
if (!t) return false if (!server) {
return this._doSubscribe(serverId, topic, { qos: t.qos }) return false;
}
let t = server.topics.find((t) => t.topic === topic);
if (!t) {
return false;
}
return this._doSubscribe(serverId, topic, { qos: t.qos });
} }
unsubscribe(serverId, topic) { unsubscribe(serverId, topic) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId) let client = this.clients.get(serverId);
if (!client || !server || server.status !== 'connected') return false let server = this.servers.get(serverId);
if (!client || !server || server.status !== 'connected') {
return false;
}
client.unsubscribe(topic, {}, (err) => { client.unsubscribe(topic, {}, (err) => {
if (!err) { if (!err) {
const t = server.topics.find((t) => t.topic === topic) let t = server.topics.find((t) => t.topic === topic);
if (t) t.subscribed = false if (t) {
this.notify('subscription-change', { serverId, topic, subscribed: false }) t.subscribed = false;
}
this.notify('subscription-change', { serverId, topic, subscribed: false });
} }
}) });
return true
return true;
} }
updateTopic(serverId, topicId, newTopic, newQos) { updateTopic(serverId, topicId, newTopic, newQos) {
const server = this.servers.get(serverId)
if (!server) return false
const t = server.topics.find((item) => item.id === topicId)
if (!t) return false
const topicChanged = t.topic !== newTopic let server = this.servers.get(serverId);
if (!server) {
return false;
}
let t = server.topics.find((item) => item.id === topicId);
if (!t) {
return false;
}
let topicChanged = t.topic !== newTopic;
if ( if (
topicChanged && topicChanged &&
server.topics.find((item) => item.topic === newTopic && item.id !== topicId) server.topics.find((item) => item.topic === newTopic && item.id !== topicId)
) { ) {
return false return false;
} }
const oldTopic = t.topic let oldTopic = t.topic;
const wasSubscribed = t.subscribed let wasSubscribed = t.subscribed;
// 如果主题名变更且已订阅,先取消旧主题订阅 // 如果主题名变更且已订阅,先取消旧主题订阅
if (topicChanged && wasSubscribed) { if (topicChanged && wasSubscribed) {
this.unsubscribe(serverId, oldTopic) this.unsubscribe(serverId, oldTopic);
} }
t.topic = newTopic t.topic = newTopic;
t.qos = newQos t.qos = newQos;
// 如果已连接且此前已订阅,重新订阅新主题 // 如果已连接且此前已订阅,重新订阅新主题
if (server.status === 'connected' && wasSubscribed) { if (server.status === 'connected' && wasSubscribed) {
this._doSubscribe(serverId, newTopic, { qos: newQos }) this._doSubscribe(serverId, newTopic, { qos: newQos });
} }
this.saveData() this.saveData();
this.notify('subscription-change', { this.notify('subscription-change', {
serverId, serverId,
topic: newTopic, topic: newTopic,
subscribed: t.subscribed, subscribed: t.subscribed,
topicId topicId,
}) });
return true
return true;
} }
subscribeAll(serverId) { subscribeAll(serverId) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId)
if (!client || !server || server.status !== 'connected') return false
const topicsToSubscribe = server.topics.filter((t) => !t.subscribed) let client = this.clients.get(serverId);
if (topicsToSubscribe.length === 0) return true let server = this.servers.get(serverId);
const subMap = Object.fromEntries(topicsToSubscribe.map((t) => [t.topic, { qos: t.qos }])) if (!client || !server || server.status !== 'connected') {
return false;
}
let topicsToSubscribe = server.topics.filter((t) => !t.subscribed);
if (topicsToSubscribe.length === 0) {
return true;
}
let subMap = Object.fromEntries(topicsToSubscribe.map((t) => [t.topic, { qos: t.qos }]));
client.subscribe(subMap, (err) => { client.subscribe(subMap, (err) => {
if (err) { if (err) {
console.error('批量订阅失败:', err.message) console.error('批量订阅失败:', err.message);
topicsToSubscribe.forEach((t) => { topicsToSubscribe.forEach((t) => {
this.notify('subscription-error', { serverId, topic: t.topic, error: err.message }) this.notify('subscription-error', { serverId, topic: t.topic, error: err.message });
}) });
} else { } else {
topicsToSubscribe.forEach((t) => { topicsToSubscribe.forEach((t) => {
t.subscribed = true t.subscribed = true;
this.notify('subscription-change', { this.notify('subscription-change', {
serverId, serverId,
topic: t.topic, topic: t.topic,
subscribed: true, subscribed: true,
topicId: t.id topicId: t.id,
}) });
}) });
} }
}) });
return true
return true;
} }
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
publish(serverId, topic, payload, opts = {}) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId)
if (!client || !server || server.status !== 'connected') return false
const pubOpts = { publish(serverId, topic, payload, opts = {}) {
qos: opts.qos ?? 1,
retain: opts.retain ?? false let client = this.clients.get(serverId);
let server = this.servers.get(serverId);
if (!client || !server || server.status !== 'connected') {
return false;
} }
let pubOpts = {
qos: opts.qos ?? 1,
retain: opts.retain ?? false,
};
client.publish(topic, payload, pubOpts, (err) => { client.publish(topic, payload, pubOpts, (err) => {
if (err) { if (err) {
console.error(`发布失败 [${topic}]:`, err.message) console.error(`发布失败 [${topic}]:`, err.message);
this.notify('publish-error', { serverId, topic, error: err.message }) this.notify('publish-error', { serverId, topic, error: err.message });
} }
}) });
// 记录发布历史 // 记录发布历史
const pubEntry = { let pubEntry = {
time: time:
new Date().toLocaleTimeString('zh-CN', { hour12: false }) + new Date().toLocaleTimeString('zh-CN', { hour12: false }) +
'.' + '.' +
@@ -460,55 +579,70 @@ class MqttManager {
payload, payload,
qos: pubOpts.qos, qos: pubOpts.qos,
retain: pubOpts.retain, retain: pubOpts.retain,
direction: 'pub' direction: 'pub',
};
let history = this.publishHistory.get(serverId) || [];
history.unshift(pubEntry);
if (history.length > 50) {
history.pop();
} }
const history = this.publishHistory.get(serverId) || []
history.unshift(pubEntry) this.publishHistory.set(serverId, history);
if (history.length > 50) history.pop()
this.publishHistory.set(serverId, history)
// 同时添加到消息日志 // 同时添加到消息日志
const pubMsgId = this.nextMsgId() let pubMsgId = this.nextMsgId();
const msgs = this.messages.get(serverId) || [] let msgs = this.messages.get(serverId) || [];
msgs.unshift({ msgs.unshift({
...pubEntry, ...pubEntry,
id: pubMsgId id: pubMsgId,
}) });
if (msgs.length > 500) msgs.pop()
this.messages.set(serverId, msgs) if (msgs.length > 500) {
msgs.pop();
}
this.messages.set(serverId, msgs);
this.notify('message', { this.notify('message', {
serverId, serverId,
message: { message: {
...pubEntry, ...pubEntry,
id: pubMsgId id: pubMsgId,
} },
}) });
this.saveData();
return true;
this.saveData()
return true
} }
// ==================== 消息管理 ==================== // ==================== 消息管理 ====================
getMessages(serverId) { getMessages(serverId) {
return this.messages.get(serverId) || [] return this.messages.get(serverId) || [];
} }
getPublishHistory(serverId) { getPublishHistory(serverId) {
return this.publishHistory.get(serverId) || [] return this.publishHistory.get(serverId) || [];
} }
clearMessages(serverId) { clearMessages(serverId) {
this.messages.set(serverId, []) this.messages.set(serverId, []);
this.saveData() this.saveData();
} }
clearPublishHistory(serverId) { clearPublishHistory(serverId) {
this.publishHistory.set(serverId, []) this.publishHistory.set(serverId, []);
this.saveData() this.saveData();
} }
} }
// 单例 // 单例
const mqttManager = new MqttManager() const mqttManager = new MqttManager();
export default mqttManager export default mqttManager;

View File

@@ -1,8 +1,9 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron';
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload';
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
// 服务器管理 // 服务器管理
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),
@@ -34,43 +35,44 @@ const api = {
// 事件监听 // 事件监听
onStatusChange: (callback) => { onStatusChange: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:status-change', handler) ipcRenderer.on('mqtt:status-change', handler);
return () => ipcRenderer.removeListener('mqtt:status-change', handler) return () => ipcRenderer.removeListener('mqtt:status-change', handler);
}, },
onMessage: (callback) => { onMessage: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:message', handler) ipcRenderer.on('mqtt:message', handler);
return () => ipcRenderer.removeListener('mqtt:message', handler) return () => ipcRenderer.removeListener('mqtt:message', handler);
}, },
onSubscriptionChange: (callback) => { onSubscriptionChange: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-change', handler) ipcRenderer.on('mqtt:subscription-change', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler) return () => ipcRenderer.removeListener('mqtt:subscription-change', handler);
}, },
onSubscriptionError: (callback) => { onSubscriptionError: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:subscription-error', handler) ipcRenderer.on('mqtt:subscription-error', handler);
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler) return () => ipcRenderer.removeListener('mqtt:subscription-error', handler);
}, },
onPublishError: (callback) => { onPublishError: (callback) => {
const handler = (_, data) => callback(data) const handler = (_, data) => callback(data);
ipcRenderer.on('mqtt:publish-error', handler) ipcRenderer.on('mqtt:publish-error', handler);
return () => ipcRenderer.removeListener('mqtt:publish-error', handler) return () => ipcRenderer.removeListener('mqtt:publish-error', handler);
} },
}
};
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise // renderer only if context isolation is enabled, otherwise
// just add to the DOM global. // just add to the DOM global.
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api);
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} }
} else { } else {
window.electron = electronAPI window.electron = electronAPI;
window.api = api window.api = api;
} }

View File

@@ -1,16 +1,16 @@
<!doctype html> <!DOCTYPE html>
<html> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frost MQTT Client</title> <title>Frost MQTT Client</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
/> />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,41 +1,33 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue';
import { import { NLayout, NMessageProvider, NDialogProvider, NConfigProvider, NTabs, NTabPane, zhCN, dateZhCN } from 'naive-ui';
NLayout, import { useMqttStore } from './stores/mqtt.js';
NMessageProvider,
NDialogProvider,
NConfigProvider,
NTabs,
NTabPane,
zhCN,
dateZhCN
} from 'naive-ui'
import Sidebar from './components/Sidebar.vue'
import ServerModal from './components/ServerModal.vue'
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 { useMqttStore } from './stores/mqtt.js'
const store = useMqttStore() import Sidebar from './components/Sidebar.vue';
import ServerModal from './components/ServerModal.vue';
import Dashboard from './views/Dashboard.vue';
import Subscribe from './views/Subscribe.vue';
import Publish from './views/Publish.vue';
import Messages from './views/Messages.vue';
const activeTab = ref('dashboard') const store = useMqttStore();
const showServerModal = ref(false)
const editServerData = ref(null) const activeTab = ref('dashboard');
const showServerModal = ref(false);
const editServerData = ref(null);
onMounted(async () => { onMounted(async () => {
await store.init() await store.init();
}) });
function handleAddServer() { function handleAddServer() {
editServerData.value = null editServerData.value = null;
showServerModal.value = true showServerModal.value = true;
} }
function handleEditServer(server) { function handleEditServer(server) {
editServerData.value = server editServerData.value = server;
showServerModal.value = true showServerModal.value = true;
} }
function handleServerSaved() { function handleServerSaved() {
@@ -43,32 +35,60 @@ function handleServerSaved() {
} }
function handleSwitchTab(tab) { function handleSwitchTab(tab) {
activeTab.value = tab activeTab.value = tab;
} }
</script> </script>
<template> <template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme="null"> <n-config-provider
:locale="zhCN"
:date-locale="dateZhCN"
:theme="null"
>
<n-message-provider placement="bottom"> <n-message-provider placement="bottom">
<n-dialog-provider> <n-dialog-provider>
<n-layout has-sider position="absolute"> <n-layout
has-sider
position="absolute"
>
<!-- 侧边栏 --> <!-- 侧边栏 -->
<Sidebar @add-server="handleAddServer" @edit-server="handleEditServer" /> <Sidebar
@add-server="handleAddServer"
@edit-server="handleEditServer"
/>
<!-- 主内容区 --> <!-- 主内容区 -->
<n-layout content-class="main-layout-content"> <n-layout content-class="main-layout-content">
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px"> <n-tabs
<n-tab-pane name="dashboard" tab="仪表盘"> v-model:value="activeTab"
type="line"
size="medium"
style="padding: 0 16px"
>
<n-tab-pane
name="dashboard"
tab="仪表盘"
>
<div class="tab-content dashboard-content"> <div class="tab-content dashboard-content">
<Dashboard @switch-tab="handleSwitchTab" @edit-server="handleEditServer" /> <Dashboard
@switch-tab="handleSwitchTab"
@edit-server="handleEditServer"
/>
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="subscribe" tab="主题订阅"> <n-tab-pane
name="subscribe"
tab="主题订阅"
>
<div class="tab-content"> <div class="tab-content">
<Subscribe /> <Subscribe />
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="messages" tab="消息中心"> <n-tab-pane
name="messages"
tab="消息中心"
>
<div class="tab-content"> <div class="tab-content">
<div class="message-center-layout"> <div class="message-center-layout">
<section class="publish-panel"> <section class="publish-panel">
@@ -88,6 +108,7 @@ function handleSwitchTab(tab) {
:edit-server="editServerData" :edit-server="editServerData"
@saved="handleServerSaved" @saved="handleServerSaved"
/> />
</n-layout> </n-layout>
</n-dialog-provider> </n-dialog-provider>
</n-message-provider> </n-message-provider>
@@ -95,9 +116,7 @@ function handleSwitchTab(tab) {
</template> </template>
<style> <style>
html, html, body, #app {
body,
#app {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;

View File

@@ -1,31 +1,19 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { import { NModal, NForm, NFormItem, NGrid, NFormItemGi, NInput, NInputNumber, NSelect, NSwitch, NButton, useMessage } from 'naive-ui';
NModal, import { useMqttStore } from '../stores/mqtt.js';
NForm,
NFormItem,
NGrid,
NFormItemGi,
NInput,
NInputNumber,
NSelect,
NSwitch,
NButton,
useMessage
} from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
editServer: Object editServer: Object,
}) });
const emit = defineEmits(['update:show', 'saved']) const emit = defineEmits(['update:show', 'saved']);
const store = useMqttStore() const store = useMqttStore();
const msg = useMessage() const msg = useMessage();
const formRef = ref(null) const formRef = ref(null);
const form = ref({ const form = ref({
name: '', name: '',
host: '', host: '',
@@ -35,30 +23,30 @@ const form = ref({
username: '', username: '',
password: '', password: '',
keepAlive: 60, keepAlive: 60,
cleanSession: true cleanSession: true,
}) });
const rules = { const rules = {
name: { required: true, message: '请输入服务器名称', trigger: 'blur' }, name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
host: { required: true, message: '请输入主机地址', trigger: 'blur' }, host: { required: true, message: '请输入主机地址', trigger: 'blur' },
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' } port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' },
} };
const protocolOptions = [ const protocolOptions = [
{ label: 'mqtt:// (默认)', value: 'mqtt' }, { label: 'mqtt:// (默认)', value: 'mqtt' },
{ label: 'mqtts:// (TLS)', value: 'mqtts' }, { label: 'mqtts:// (TLS)', value: 'mqtts' },
{ label: 'ws:// (WebSocket)', value: 'ws' }, { label: 'ws:// (WebSocket)', value: 'ws' },
{ label: 'wss:// (WebSocket TLS)', value: 'wss' } { label: 'wss:// (WebSocket TLS)', value: 'wss' },
] ];
const isEdit = ref(false) const isEdit = ref(false);
watch( watch(
() => props.show, () => props.show,
(val) => { (val) => {
if (val) { if (val) {
if (props.editServer) { if (props.editServer) {
isEdit.value = true isEdit.value = true;
form.value = { form.value = {
name: props.editServer.name, name: props.editServer.name,
host: props.editServer.host, host: props.editServer.host,
@@ -68,10 +56,10 @@ watch(
username: props.editServer.username || '', username: props.editServer.username || '',
password: '', password: '',
keepAlive: props.editServer.keepAlive || 60, keepAlive: props.editServer.keepAlive || 60,
cleanSession: props.editServer.cleanSession !== false cleanSession: props.editServer.cleanSession !== false,
} };
} else { } else {
isEdit.value = false isEdit.value = false;
form.value = { form.value = {
name: '', name: '',
host: '', host: '',
@@ -81,61 +69,101 @@ watch(
username: '', username: '',
password: '', password: '',
keepAlive: 60, keepAlive: 60,
cleanSession: true cleanSession: true,
} };
} }
} }
} }
) );
async function handleSave() { async function handleSave() {
try { try {
await formRef.value?.validate() await formRef.value?.validate();
} catch { } catch {
return return;
} }
try { try {
if (isEdit.value && props.editServer) { if (isEdit.value && props.editServer) {
await store.updateServer(props.editServer.id, { ...form.value }) await store.updateServer(props.editServer.id, { ...form.value });
msg.success('服务器配置已更新') msg.success('服务器配置已更新');
} else { } else {
await store.addServer({ ...form.value }) await store.addServer({ ...form.value });
msg.success('服务器已添加') msg.success('服务器已添加');
} }
emit('saved')
emit('update:show', false) emit('saved');
emit('update:show', false);
} catch (e) { } catch (e) {
msg.error('操作失败: ' + (e.message || '未知错误')) msg.error('操作失败: ' + (e.message || '未知错误'));
} }
} }
</script> </script>
<template> <template>
<n-modal <n-modal
:show="show"
:mask-closable="false" :mask-closable="false"
preset="card" :show="show"
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'" :title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
preset="card"
style="width: 520px; max-width: 90vw" style="width: 520px; max-width: 90vw"
@update:show="$emit('update:show', $event)" @update:show="$emit('update:show', $event)"
> >
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" size="medium"> <n-form
<n-grid :cols="2" :x-gap="12"> ref="formRef"
<n-form-item-gi label="服务器名称" path="name"> :model="form"
<n-input v-model:value="form.name" placeholder="例如: 本地开发服务器" /> :rules="rules"
label-placement="top"
size="medium"
>
<n-grid
:cols="2"
:x-gap="12"
>
<n-form-item-gi
label="服务器名称"
path="name"
>
<n-input
v-model:value="form.name"
placeholder="例如: 本地开发服务器"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="主机地址" path="host"> <n-form-item-gi
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" /> label="主机地址"
path="host"
>
<n-input
v-model:value="form.host"
placeholder="例如: broker.emqx.io"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="端口" path="port"> <n-form-item-gi
<n-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" /> label="端口"
path="port"
>
<n-input-number
v-model:value="form.port"
:min="1"
:max="65535"
style="width: 100%"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="协议"> <n-form-item-gi label="协议">
<n-select v-model:value="form.protocol" :options="protocolOptions" /> <n-select
v-model:value="form.protocol"
:options="protocolOptions"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="客户端 ID"> <n-form-item-gi label="客户端 ID">
<n-input v-model:value="form.clientId" placeholder="自动生成" /> <n-input
v-model:value="form.clientId"
placeholder="自动生成"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="Keep Alive (秒)"> <n-form-item-gi label="Keep Alive (秒)">
<n-input-number <n-input-number
@@ -146,10 +174,17 @@ async function handleSave() {
/> />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="用户名"> <n-form-item-gi label="用户名">
<n-input v-model:value="form.username" placeholder="可选" /> <n-input
v-model:value="form.username"
placeholder="可选"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi label="密码"> <n-form-item-gi label="密码">
<n-input v-model:value="form.password" type="password" placeholder="可选" /> <n-input
v-model:value="form.password"
type="password"
placeholder="可选"
/>
</n-form-item-gi> </n-form-item-gi>
</n-grid> </n-grid>
<n-form-item label="Clean Session"> <n-form-item label="Clean Session">
@@ -158,8 +193,13 @@ async function handleSave() {
</n-form> </n-form>
<template #footer> <template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px"> <div style="display: flex; justify-content: flex-end; gap: 8px">
<n-button @click="$emit('update:show', false)">取消</n-button> <n-button
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button> @click="$emit('update:show', false)"
>取消</n-button>
<n-button
type="primary"
@click="handleSave"
>{{ isEdit ? '更新' : '保存' }}</n-button>
</div> </div>
</template> </template>
</n-modal> </n-modal>

View File

@@ -1,23 +1,23 @@
<script setup> <script setup>
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui' 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']);
const store = useMqttStore() const store = useMqttStore();
const message = useMessage() const message = useMessage();
const dialog = useDialog() const dialog = useDialog();
async function handleConnect(server) { async function handleConnect(server) {
if (server.status === 'connected') { if (server.status === 'connected') {
await store.disconnectServer(server.id) await store.disconnectServer(server.id);
message.info('已断开连接: ' + server.name) message.info('已断开连接: ' + server.name);
} else { } else {
await store.connectServer(server.id) await store.connectServer(server.id);
} }
} }
function handleEdit(server) { function handleEdit(server) {
emit('edit-server', server) emit('edit-server', server);
} }
function handleDelete(server) { function handleDelete(server) {
@@ -27,10 +27,10 @@ function handleDelete(server) {
positiveText: '删除', positiveText: '删除',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
await store.deleteServer(server.id) await store.deleteServer(server.id);
message.info('服务器已删除') message.info('服务器已删除');
} },
}) });
} }
</script> </script>
@@ -43,9 +43,13 @@ function handleDelete(server) {
show-trigger="bar" show-trigger="bar"
:native-scrollbar="true" :native-scrollbar="true"
content-class="sidebar-scroll-container" content-class="sidebar-scroll-container"
:content-style="{ height: '100%', overflow: 'hidden' }" :content-style="{
height: '100%',
overflow: 'hidden',
}"
> >
<div class="sidebar-container"> <div class="sidebar-container">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-logo">F</div> <div class="sidebar-logo">F</div>
<div> <div>
@@ -65,12 +69,21 @@ function handleDelete(server) {
<div class="server-item-top"> <div class="server-item-top">
<span class="server-name">{{ srv.name }}</span> <span class="server-name">{{ srv.name }}</span>
<div class="server-actions"> <div class="server-actions">
<n-button text size="tiny" @click.stop="handleEdit(srv)"> <n-button
text
size="tiny"
@click.stop="handleEdit(srv)"
>
<template #icon> <template #icon>
<span style="font-size: 14px">&#9998;</span> <span style="font-size: 14px">&#9998;</span>
</template> </template>
</n-button> </n-button>
<n-button text size="tiny" type="error" @click.stop="handleDelete(srv)"> <n-button
text
size="tiny"
type="error"
@click.stop="handleDelete(srv)"
>
<template #icon> <template #icon>
<span style="font-size: 14px">&#10005;</span> <span style="font-size: 14px">&#10005;</span>
</template> </template>
@@ -81,55 +94,39 @@ function handleDelete(server) {
<n-tag <n-tag
:bordered="false" :bordered="false"
size="small" size="small"
:type=" :type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
> >
<template #icon> <template #icon>
<span class="status-dot" :class="srv.status" /> <span
class="status-dot"
:class="srv.status"
/>
</template> </template>
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }} {{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
</n-tag> </n-tag>
</div> </div>
<div class="server-bottom"> <div class="server-bottom">
<span class="server-status-text"> <span
{{ class="server-status-text"
srv.status === 'connected' >{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : srv.status === 'error' ? '连接失败' : '未连接' }}</span>
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: srv.status === 'error'
? '连接失败'
: '未连接'
}}
</span>
<n-button <n-button
size="tiny" size="tiny"
:type=" :type="srv.status === 'connected' ? 'error' : srv.status === 'connecting' ? 'warning' : 'success'"
srv.status === 'connected'
? 'error'
: srv.status === 'connecting'
? 'warning'
: 'success'
"
:disabled="srv.status === 'connecting'" :disabled="srv.status === 'connecting'"
@click.stop="handleConnect(srv)" @click.stop="handleConnect(srv)"
> >{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</div> </div>
</div> </div>
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<n-button type="primary" block @click="$emit('add-server')"> + 添加服务器 </n-button> <n-button
type="primary"
block
@click="$emit('add-server')"
> + 添加服务器</n-button>
</div> </div>
</div> </div>
</n-layout-sider> </n-layout-sider>
</template> </template>
@@ -259,13 +256,16 @@ function handleDelete(server) {
.status-dot.connected { .status-dot.connected {
background: #18a058; background: #18a058;
} }
.status-dot.connecting { .status-dot.connecting {
background: #f0a020; background: #f0a020;
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected { .status-dot.disconnected {
background: #999; background: #999;
} }
.status-dot.error { .status-dot.error {
background: #d03050; background: #d03050;
} }
@@ -275,6 +275,7 @@ function handleDelete(server) {
100% { 100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.3;
} }

View File

@@ -1,5 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import App from './App.vue'
const app = createApp(App) import App from './App.vue';
app.mount('#app')
const app = createApp(App);
app.mount('#app');

View File

@@ -1,202 +1,249 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue';
// 全局状态(单例) // 全局状态(单例)
const servers = ref([]) const servers = ref([]);
const activeServerId = ref(null) const activeServerId = ref(null);
const messages = ref([]) const messages = ref([]);
const publishHistory = ref([]) const publishHistory = ref([]);
const loading = ref(false) const loading = ref(false);
// 清理函数集合 // 清理函数集合
let cleanupFns = [] let cleanupFns = [];
function setupListeners() { function setupListeners() {
// 清理旧监听器 // 清理旧监听器
cleanupFns.forEach((fn) => fn()) cleanupFns.forEach((fn) => fn());
cleanupFns = [] cleanupFns = [];
cleanupFns.push( cleanupFns.push(
window.api.onStatusChange((data) => { window.api.onStatusChange((data) => {
const srv = servers.value.find((s) => s.id === data.id) const srv = servers.value.find((s) => s.id === data.id);
if (srv) { if (srv) {
srv.status = data.status srv.status = data.status;
if (data.status === 'disconnected') { if (data.status === 'disconnected') {
srv.topics.forEach((t) => { srv.topics.forEach((t) => {
t.subscribed = false t.subscribed = false;
}) });
} }
} }
}) })
) );
cleanupFns.push( cleanupFns.push(
window.api.onMessage((data) => { window.api.onMessage((data) => {
if (data.serverId === activeServerId.value) { if (data.serverId === activeServerId.value) {
messages.value.unshift(data.message) messages.value.unshift(data.message);
if (messages.value.length > 500) messages.value.pop() if (messages.value.length > 500) {
messages.value.pop();
}
} }
}) })
) );
cleanupFns.push( cleanupFns.push(
window.api.onSubscriptionChange((data) => { window.api.onSubscriptionChange((data) => {
if (data.serverId === activeServerId.value) { if (data.serverId === activeServerId.value) {
const srv = servers.value.find((s) => s.id === data.serverId) const srv = servers.value.find((s) => s.id === data.serverId);
if (srv) { if (srv) {
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId) const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId);
if (t) { if (t) {
t.subscribed = data.subscribed t.subscribed = data.subscribed;
if (data.topic && data.topic !== t.topic) { if (data.topic && data.topic !== t.topic) {
t.topic = data.topic t.topic = data.topic;
} }
} }
} }
} }
}) })
) );
} }
export function useMqttStore() { export function useMqttStore() {
const activeServer = computed(
let activeServer = computed(
() => servers.value.find((s) => s.id === activeServerId.value) || null () => servers.value.find((s) => s.id === activeServerId.value) || null
) );
const connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected')) let connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'));
const totalTopics = computed(() => let totalTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0) servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
) );
const subscribedTopics = computed(() => let subscribedTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0), 0) servers.value.reduce(
) (sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0),
0
)
);
const totalMessages = computed(() => messages.value.length) let totalMessages = computed(() => messages.value.length);
// ==================== 初始化 ==================== // ==================== 初始化 ====================
async function init() { async function init() {
loading.value = true
loading.value = true;
try { try {
const data = await window.api.getServers()
servers.value = data let data = await window.api.getServers();
setupListeners()
servers.value = data;
setupListeners();
} catch (e) { } catch (e) {
console.error('初始化失败:', e) console.error('初始化失败:', e);
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
// ==================== 服务器管理 ==================== // ==================== 服务器管理 ====================
function setActiveServer(id) { function setActiveServer(id) {
activeServerId.value = id
activeServerId.value = id;
// 切换时刷新消息 // 切换时刷新消息
refreshMessages() refreshMessages();
refreshPublishHistory() refreshPublishHistory();
} }
async function addServer(config) { async function addServer(config) {
const server = await window.api.addServer(config)
servers.value.push(server) let server = await window.api.addServer(config);
servers.value.push(server);
if (!activeServerId.value) { if (!activeServerId.value) {
activeServerId.value = server.id activeServerId.value = server.id;
} }
return server
return server;
} }
async function updateServer(id, config) { async function updateServer(id, config) {
const result = await window.api.updateServer(id, config)
let result = await window.api.updateServer(id, config);
if (result) { if (result) {
const idx = servers.value.findIndex((s) => s.id === id) let idx = servers.value.findIndex((s) => s.id === id);
if (idx !== -1) { if (idx !== -1) {
servers.value[idx] = { ...servers.value[idx], ...result } servers.value[idx] = { ...servers.value[idx], ...result };
} }
} }
return result
return result;
} }
async function deleteServer(id) { async function deleteServer(id) {
await window.api.deleteServer(id)
servers.value = servers.value.filter((s) => s.id !== id) await window.api.deleteServer(id);
servers.value = servers.value.filter((s) => s.id !== id);
if (activeServerId.value === id) { if (activeServerId.value === id) {
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null;
} }
} }
async function connectServer(id) { async function connectServer(id) {
return await window.api.connect(id) return await window.api.connect(id);
} }
async function disconnectServer(id) { async function disconnectServer(id) {
await window.api.disconnect(id) await window.api.disconnect(id);
} }
// ==================== 主题管理 ==================== // ==================== 主题管理 ====================
async function addTopic(serverId, topic, qos) { async function addTopic(serverId, topic, qos) {
const result = await window.api.addTopic(serverId, topic, qos)
let result = await window.api.addTopic(serverId, topic, qos);
if (result) { if (result) {
const srv = servers.value.find((s) => s.id === serverId) let srv = servers.value.find((s) => s.id === serverId);
if (srv) { if (srv) {
srv.topics.push({ topic, qos, subscribed: false }) srv.topics.push({ topic, qos, subscribed: false });
} }
} }
return result
return result;
} }
async function removeTopic(serverId, topic) { async function removeTopic(serverId, topic) {
await window.api.removeTopic(serverId, topic)
const srv = servers.value.find((s) => s.id === serverId) await window.api.removeTopic(serverId, topic);
let srv = servers.value.find((s) => s.id === serverId);
if (srv) { if (srv) {
srv.topics = srv.topics.filter((t) => t.topic !== topic) srv.topics = srv.topics.filter((t) => t.topic !== topic);
} }
} }
async function subscribeTopic(serverId, topic) { async function subscribeTopic(serverId, topic) {
return await window.api.subscribe(serverId, topic) return await window.api.subscribe(serverId, topic);
} }
async function unsubscribeTopic(serverId, topic) { async function unsubscribeTopic(serverId, topic) {
return await window.api.unsubscribe(serverId, topic) return await window.api.unsubscribe(serverId, topic);
} }
async function subscribeAllTopics(serverId) { async function subscribeAllTopics(serverId) {
return await window.api.subscribeAll(serverId) return await window.api.subscribeAll(serverId);
} }
async function updateTopic(serverId, topicId, topic, qos) { async function updateTopic(serverId, topicId, topic, qos) {
return await window.api.updateTopic(serverId, topicId, topic, qos) return await window.api.updateTopic(serverId, topicId, topic, qos);
} }
// ==================== 消息发布 ==================== // ==================== 消息发布 ====================
async function publishMessage(serverId, topic, payload, opts) { async function publishMessage(serverId, topic, payload, opts) {
const result = await window.api.publish(serverId, topic, payload, opts)
let result = await window.api.publish(serverId, topic, payload, opts);
if (result && serverId === activeServerId.value) { if (result && serverId === activeServerId.value) {
refreshPublishHistory() refreshPublishHistory();
} }
return result
return result;
} }
// ==================== 消息管理 ==================== // ==================== 消息管理 ====================
async function refreshMessages() { async function refreshMessages() {
if (!activeServerId.value) return if (activeServerId.value) {
messages.value = await window.api.getMessages(activeServerId.value) messages.value = await window.api.getMessages(activeServerId.value);
}
} }
async function refreshPublishHistory() { async function refreshPublishHistory() {
if (!activeServerId.value) return if (activeServerId.value) {
publishHistory.value = await window.api.getPublishHistory(activeServerId.value) publishHistory.value = await window.api.getPublishHistory(activeServerId.value);
}
} }
async function clearMessages() { async function clearMessages() {
if (!activeServerId.value) return if (activeServerId.value) {
await window.api.clearMessages(activeServerId.value) await window.api.clearMessages(activeServerId.value);
messages.value = [] messages.value = [];
}
} }
async function clearPublishHistory() { async function clearPublishHistory() {
if (!activeServerId.value) return if (activeServerId.value) {
await window.api.clearPublishHistory(activeServerId.value) await window.api.clearPublishHistory(activeServerId.value);
publishHistory.value = [] publishHistory.value = [];
}
} }
return { return {
@@ -230,6 +277,7 @@ export function useMqttStore() {
refreshMessages, refreshMessages,
refreshPublishHistory, refreshPublishHistory,
clearMessages, clearMessages,
clearPublishHistory clearPublishHistory,
} };
} }

View File

@@ -1,60 +1,67 @@
<script setup> <script setup>
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import { NGrid, NGridItem, NCard, NStatistic, NTag, NDescriptions, NDescriptionsItem, NSpace, NButton, NList, NListItem, useMessage } from 'naive-ui';
NGrid,
NGridItem,
NCard,
NStatistic,
NTag,
NDescriptions,
NDescriptionsItem,
NSpace,
NButton,
NList,
NListItem
} from 'naive-ui'
import { useMessage } from 'naive-ui'
const store = useMqttStore() const store = useMqttStore();
const msg = useMessage() const msg = useMessage();
function handleAction(srv, action) { function handleAction(srv, action) {
if (srv.id === store.activeServerId.value) { if (srv.id === store.activeServerId.value) {
emit('switch-tab', action) emit('switch-tab', action);
} else { } else {
store.setActiveServer(srv.id) store.setActiveServer(srv.id);
emit('switch-tab', action) emit('switch-tab', action);
} }
} }
async function handleToggleConnection(srv) { async function handleToggleConnection(srv) {
if (srv.status === 'connected') { if (srv.status === 'connected') {
await store.disconnectServer(srv.id) await store.disconnectServer(srv.id);
msg.info('已断开连接: ' + srv.name) msg.info('已断开连接: ' + srv.name);
} else if (srv.status === 'disconnected' || srv.status === 'error') { } else if (srv.status === 'disconnected' || srv.status === 'error') {
await store.connectServer(srv.id) await store.connectServer(srv.id);
} }
} }
const emit = defineEmits(['switch-tab', 'edit-server']) const emit = defineEmits(['switch-tab', 'edit-server']);
</script> </script>
<template> <template>
<div class="dashboard"> <div class="dashboard">
<!-- 统计卡片 --> <!-- 统计卡片 -->
<n-grid :cols="4" :x-gap="12" responsive="screen"> <n-grid
:cols="4"
:x-gap="12"
responsive="screen"
>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="服务器总数" :value="store.servers.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="服务器总数"
:value="store.servers.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="已连接" :value="store.connectedServers.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="已连接"
:value="store.connectedServers.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
size="small"
:bordered="true"
>
<n-statistic <n-statistic
label="已订阅主题" label="已订阅主题"
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`" :value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
@@ -62,8 +69,14 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
</n-card> </n-card>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<n-card size="small" :bordered="true"> <n-card
<n-statistic label="消息总数" :value="store.messages.value.length" /> size="small"
:bordered="true"
>
<n-statistic
label="消息总数"
:value="store.messages.value.length"
/>
</n-card> </n-card>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -77,29 +90,17 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
> >
<template #header-extra> <template #header-extra>
<n-tag <n-tag
:type=" :type="store.activeServer.value.status === 'connected' ? 'success' : store.activeServer.value.status === 'connecting' ? 'warning' : 'error'"
store.activeServer.value.status === 'connected'
? 'success'
: store.activeServer.value.status === 'connecting'
? 'warning'
: 'error'
"
size="small" size="small"
> >{{ store.activeServer.value.status === 'connected' ? '已连接' : store.activeServer.value.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
{{
store.activeServer.value.status === 'connected'
? '已连接'
: store.activeServer.value.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
</template> </template>
<n-descriptions :column="2" size="small" bordered> <n-descriptions
:column="2"
size="small"
bordered
>
<n-descriptions-item label="地址"> <n-descriptions-item label="地址">
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ {{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{ store.activeServer.value.port }}
store.activeServer.value.port
}}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="客户端 ID"> <n-descriptions-item label="客户端 ID">
{{ store.activeServer.value.clientId }} {{ store.activeServer.value.clientId }}
@@ -116,52 +117,50 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
size="small" size="small"
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'" :type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
@click="handleToggleConnection(store.activeServer.value)" @click="handleToggleConnection(store.activeServer.value)"
> >{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}</n-button>
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }} <n-button
</n-button> size="small"
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)" @click="$emit('edit-server', store.activeServer.value)"
>编辑配置</n-button >编辑配置</n-button>
> <n-button
<n-button size="small" @click="handleAction(store.activeServer.value, 'subscribe')" size="small"
>管理订阅</n-button @click="handleAction(store.activeServer.value, 'subscribe')"
> >管理订阅</n-button>
<n-button size="small" @click="handleAction(store.activeServer.value, 'publish')" <n-button
>发布消息</n-button size="small"
> @click="handleAction(store.activeServer.value, 'publish')"
<n-button size="small" @click="handleAction(store.activeServer.value, 'messages')" >发布消息</n-button>
>查看消息</n-button <n-button
> size="small"
@click="handleAction(store.activeServer.value, 'messages')"
>查看消息</n-button>
</n-space> </n-space>
</n-card> </n-card>
<!-- 所有服务器状态 --> <!-- 所有服务器状态 -->
<n-card title="所有服务器状态" size="small" style="margin-top: 12px"> <n-card
title="所有服务器状态"
size="small"
style="margin-top: 12px"
>
<n-list> <n-list>
<n-list-item v-for="srv in store.servers.value" :key="srv.id"> <n-list-item
v-for="srv in store.servers.value"
:key="srv.id"
>
<template #prefix> <template #prefix>
<span class="status-dot" :class="srv.status" /> <span
class="status-dot"
:class="srv.status"
/>
</template> </template>
<n-space align="center"> <n-space align="center">
<span style="font-weight: 500">{{ srv.name }}</span> <span style="font-weight: 500">{{ srv.name }}</span>
<n-tag <n-tag
:bordered="false" :bordered="false"
size="small" size="small"
:type=" :type="srv.status === 'connected' ? 'success' : srv.status === 'connecting' ? 'warning' : 'default'"
srv.status === 'connected' >{{ srv.status === 'connected' ? '已连接' : srv.status === 'connecting' ? '连接中...' : '未连接' }}</n-tag>
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
>
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
</n-space> </n-space>
<template #suffix> <template #suffix>
<n-button <n-button
@@ -169,15 +168,12 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
:type="srv.status === 'connected' ? 'error' : 'success'" :type="srv.status === 'connected' ? 'error' : 'success'"
:disabled="srv.status === 'connecting'" :disabled="srv.status === 'connecting'"
@click="handleToggleConnection(srv)" @click="handleToggleConnection(srv)"
> >{{ srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接' }}</n-button>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</template> </template>
</n-list-item> </n-list-item>
</n-list> </n-list>
</n-card> </n-card>
</div> </div>
</template> </template>
@@ -196,13 +192,16 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
.status-dot.connected { .status-dot.connected {
background: #18a058; background: #18a058;
} }
.status-dot.connecting { .status-dot.connecting {
background: #f0a020; background: #f0a020;
animation: pulse 1s infinite; animation: pulse 1s infinite;
} }
.status-dot.disconnected { .status-dot.disconnected {
background: #999; background: #999;
} }
.status-dot.error { .status-dot.error {
background: #d03050; background: #d03050;
} }
@@ -212,6 +211,7 @@ const emit = defineEmits(['switch-tab', 'edit-server'])
100% { 100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.3;
} }

View File

@@ -1,59 +1,76 @@
<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, NSpace, NInput, NButton, NEmpty, useMessage } from 'naive-ui';
const store = useMqttStore() const store = useMqttStore();
const message = useMessage() const message = useMessage();
const filter = ref('') const filter = ref('');
async function copyTopic(topic) { async function copyTopic(topic) {
try { try {
await navigator.clipboard.writeText(topic) await navigator.clipboard.writeText(topic);
message.success('主题已复制') message.success('主题已复制');
} catch { } catch {
message.error('复制失败') message.error('复制失败');
} }
} }
const filteredMessages = computed(() => { const filteredMessages = computed(() => {
if (!filter.value.trim()) return store.messages.value
const f = filter.value.trim().toLowerCase() if (!filter.value.trim()) {
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f)) return store.messages.value;
}
let f = filter.value.trim().toLowerCase();
return store.messages.value.filter((m) => {
return m.topic.toLowerCase().includes(f);
});
}) })
async function handleClear() { async function handleClear() {
await store.clearMessages() await store.clearMessages();
} }
function formatPayload(payload) { function formatPayload(payload) {
try { try {
return JSON.stringify(JSON.parse(payload), null, 2) return JSON.stringify(JSON.parse(payload), null, 2);
} catch { } catch {
return payload return payload;
} }
} }
</script> </script>
<template> <template>
<div v-if="store.activeServer.value" class="messages-view">
<!-- 内容区域 -->
<div
v-if="store.activeServer.value"
class="messages-view"
>
<n-card <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<template #header> <template #header>
<n-space align="center" justify="space-between" style="width: 100%"> <n-space
align="center"
justify="space-between"
style="width: 100%"
>
<div> <div>
<span>消息监控</span> <span>消息监控</span>
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px" <span
>实时查看接收和发送的 MQTT 消息</span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
> >实时查看接收和发送的 MQTT 消息</span>
</div> </div>
<n-space> <n-space>
<n-input <n-input
@@ -63,31 +80,60 @@ function formatPayload(payload) {
style="width: 180px" style="width: 180px"
clearable clearable
/> />
<n-button size="small" @click="handleClear">清空</n-button> <n-button
size="small"
@click="handleClear"
>清空消息</n-button>
</n-space> </n-space>
</n-space> </n-space>
</template> </template>
<n-empty v-if="filteredMessages.length === 0" description="等待消息..."> <n-empty
v-if="filteredMessages.length === 0"
description="等待消息..."
>
<template #icon> <template #icon>
<span style="font-size: 32px; opacity: 0.4">&#9776;</span> <span style="font-size: 32px; opacity: 0.4">&#9776;</span>
</template> </template>
</n-empty> </n-empty>
<div v-else ref="logRef" class="msg-log"> <div
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry"> v-else
ref="logRef"
class="msg-log"
>
<div
v-for="m in filteredMessages"
:key="m.id"
class="msg-entry"
>
<div class="msg-header"> <div class="msg-header">
<span class="msg-time">{{ m.time }}</span> <span class="msg-time">{{ m.time }}</span>
<span class="msg-dir" :class="m.direction">{{ <span
m.direction === 'pub' ? 'PUB' : 'SUB' class="msg-dir"
}}</span> :class="m.direction"
<span class="msg-topic" :title="m.topic" @click="copyTopic(m.topic)">{{ m.topic }}</span> >{{ m.direction === 'pub' ? 'PUB' : 'SUB' }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span> <span
class="msg-topic"
:title="m.topic"
@click="copyTopic(m.topic)"
>{{ m.topic }}</span>
<span
class="msg-meta"
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div> </div>
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre> <pre
class="msg-payload"
>{{ formatPayload(m.payload) }}</pre>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,191 +1,278 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NTabs, NTabPane, useMessage } from 'naive-ui';
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NSwitch,
NButton,
NEmpty,
NTabs,
NTabPane,
useMessage
} from 'naive-ui'
const store = useMqttStore() const store = useMqttStore();
const msg = useMessage() const msg = useMessage();
const topic = ref('') const topic = ref('');
const payload = ref('') const payload = ref('');
const qos = ref(1) const qos = ref(1);
const retain = ref(false) const retain = ref(false);
const activePublishTab = ref('publish') const activePublishTab = ref('publish');
const formStorageKey = 'mqtt-client.publish-form' const formStorageKey = 'mqtt-client.publish-form';
const publishTabStorageKey = 'mqtt-client.publish-active-tab' const publishTabStorageKey = 'mqtt-client.publish-active-tab';
let formReady = false let formReady = false;
try { try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topic === 'string') topic.value = savedForm.topic let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.payload === 'string') payload.value = savedForm.payload
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos if (typeof savedForm.topic === 'string') {
if (typeof savedForm.retain === 'boolean') retain.value = savedForm.retain topic.value = savedForm.topic;
}
if (typeof savedForm.payload === 'string') {
payload.value = savedForm.payload;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
if (typeof savedForm.retain === 'boolean') {
retain.value = savedForm.retain;
}
} catch { } catch {
localStorage.removeItem(formStorageKey) localStorage.removeItem(formStorageKey);
} }
try { try {
const savedTab = localStorage.getItem(publishTabStorageKey)
if (savedTab === 'publish' || savedTab === 'history') activePublishTab.value = savedTab let savedTab = localStorage.getItem(publishTabStorageKey);
if (savedTab === 'publish' || savedTab === 'history') {
activePublishTab.value = savedTab;
}
} catch { } catch {
localStorage.removeItem(publishTabStorageKey) localStorage.removeItem(publishTabStorageKey);
} }
watch([topic, payload, qos, retain], () => { watch([topic, payload, qos, retain], () => {
if (!formReady) return
if (!formReady) {
return;
}
localStorage.setItem( localStorage.setItem(
formStorageKey, formStorageKey,
JSON.stringify({ JSON.stringify({
topic: topic.value, topic: topic.value,
payload: payload.value, payload: payload.value,
qos: qos.value, qos: qos.value,
retain: retain.value retain: retain.value,
}) })
) );
})
});
watch(activePublishTab, (tab) => { watch(activePublishTab, (tab) => {
localStorage.setItem(publishTabStorageKey, tab) localStorage.setItem(publishTabStorageKey, tab);
}) });
formReady = true formReady = true;
const qosOptions = [ const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 }, { label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 }, { label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 } { label: 'QoS 2 - 恰好一次', value: 2 },
] ];
async function handlePublish() { async function handlePublish() {
const srv = store.activeServer.value
if (!srv) return let srv = store.activeServer.value;
if (!srv) {
return;
}
if (srv.status !== 'connected') { if (srv.status !== 'connected') {
msg.warning('请先连接服务器') msg.warning('请先连接服务器');
return return;
} }
if (!topic.value.trim()) { if (!topic.value.trim()) {
msg.warning('请输入目标主题') msg.warning('请输入目标主题');
return return;
} }
const result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
let result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
qos: qos.value, qos: qos.value,
retain: retain.value retain: retain.value,
}) });
if (result) { if (result) {
msg.success('消息已发布到: ' + topic.value) msg.success('消息已发布到: ' + topic.value);
} }
} }
function handleFormatJson() { function handleFormatJson() {
try { try {
const obj = JSON.parse(payload.value)
payload.value = JSON.stringify(obj, null, 2) let obj = JSON.parse(payload.value);
msg.info('JSON 已格式化')
payload.value = JSON.stringify(obj, null, 2);
msg.info('JSON 已格式化');
} catch { } catch {
msg.warning('无效的 JSON 格式') msg.warning('无效的 JSON 格式');
} }
} }
async function handleClearHistory() { async function handleClearHistory() {
await store.clearPublishHistory() await store.clearPublishHistory();
} }
async function copyTopic(topic) { async function copyTopic(topic) {
try { try {
await navigator.clipboard.writeText(topic) await navigator.clipboard.writeText(topic);
msg.success('主题已复制') msg.success('主题已复制');
} catch { } catch {
msg.error('复制失败') msg.error('复制失败');
} }
} }
</script> </script>
<template> <template>
<div v-if="store.activeServer.value" class="publish-view">
<n-tabs v-model:value="activePublishTab" type="line" size="small"> <!-- 内容区域 -->
<n-tab-pane name="publish" tab="发布消息"> <div
v-if="store.activeServer.value"
class="publish-view"
>
<n-tabs
v-model:value="activePublishTab"
type="line"
size="small"
>
<n-tab-pane
name="publish"
tab="发布消息"
>
<n-card <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<div class="publish-form"> <div class="publish-form">
<n-form-item class="topic-field" label="目标主题 (Topic)"> <n-form-item
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" /> class="topic-field"
label="目标主题 (Topic)"
>
<n-input
v-model:value="topic"
placeholder="例如: sensor/device01/temperature"
/>
</n-form-item> </n-form-item>
<n-space align="center"> <n-space align="center">
<n-form-item label="QoS"> <n-form-item label="QoS">
<n-select v-model:value="qos" :options="qosOptions" style="width: 160px" /> <n-select
v-model:value="qos"
:options="qosOptions"
style="width: 160px"
/>
</n-form-item> </n-form-item>
<n-form-item label="保留消息"> <n-form-item label="保留消息">
<n-switch v-model:value="retain" /> <n-switch v-model:value="retain" />
</n-form-item> </n-form-item>
</n-space> </n-space>
<n-form-item class="payload-field" label="消息内容 (Payload)"> <n-form-item
class="payload-field"
label="消息内容 (Payload)"
>
<n-input <n-input
v-model:value="payload" v-model:value="payload"
type="textarea" type="textarea"
:resizable="false" :resizable="false"
:rows="1" :rows="1"
placeholder='{"temperature": 25.6, "humidity": 68.3}'
/> />
</n-form-item> </n-form-item>
<n-space> <n-space>
<n-button type="primary" @click="handlePublish">发布消息</n-button> <n-button
<n-button @click="handleFormatJson">格式化 JSON</n-button> type="primary"
@click="handlePublish"
>发布消息</n-button>
<n-button
@click="handleFormatJson"
>格式化 JSON</n-button>
</n-space> </n-space>
</div> </div>
</n-card> </n-card>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="history" tab="发布历史"> <n-tab-pane
name="history"
tab="发布历史"
>
<n-card <n-card
size="small" size="small"
:content-style="{ :content-style="{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<n-space style="margin-bottom: 12px"> <n-space style="margin-bottom: 12px">
<n-button size="small" @click="handleClearHistory">清空</n-button> <n-button
size="small"
@click="handleClearHistory"
>清空历史</n-button>
</n-space> </n-space>
<n-empty v-if="store.publishHistory.value.length === 0" description="暂无发布记录" /> <n-empty
<div v-else class="msg-log"> v-if="store.publishHistory.value.length === 0"
<div v-for="m in store.publishHistory.value" :key="m.id || m.time" class="msg-entry"> description="暂无发布记录"
/>
<div
v-else
class="msg-log"
>
<div
v-for="m in store.publishHistory.value"
:key="m.id || m.time"
class="msg-entry"
>
<div class="msg-header"> <div class="msg-header">
<span class="msg-time">{{ m.time }}</span> <span
<span class="msg-dir pub">PUB</span> class="msg-time"
<span class="msg-topic" :title="m.topic" @click="copyTopic(m.topic)">{{ m.topic }}</span> >{{ m.time }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span> <span
class="msg-dir pub"
>PUB</span>
<span
class="msg-topic"
:title="m.topic"
@click="copyTopic(m.topic)"
>{{ m.topic }}</span>
<span
class="msg-meta"
>QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div> </div>
<pre class="msg-payload">{{ m.payload }}</pre> <pre
class="msg-payload"
>{{ m.payload }}</pre>
</div> </div>
</div> </div>
</n-card> </n-card>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
<!-- 空状态 -->
<n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped>
@@ -314,14 +401,17 @@ async function copyTopic(topic) {
color: #999; color: #999;
flex-shrink: 0; flex-shrink: 0;
} }
.msg-dir { .msg-dir {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
width: 28px; width: 28px;
} }
.msg-dir.pub { .msg-dir.pub {
color: #2080f0; color: #2080f0;
} }
.msg-topic { .msg-topic {
color: #d25a00; color: #d25a00;
min-width: 0; min-width: 0;
@@ -335,6 +425,7 @@ async function copyTopic(topic) {
.msg-topic:hover { .msg-topic:hover {
text-decoration: underline; text-decoration: underline;
} }
.msg-payload { .msg-payload {
margin: 6px 0 0; margin: 6px 0 0;
padding: 8px 10px; padding: 8px 10px;
@@ -345,6 +436,7 @@ async function copyTopic(topic) {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.msg-meta { .msg-meta {
color: #999; color: #999;
font-size: 11px; font-size: 11px;

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { useMqttStore } from '../stores/mqtt.js' import { useMqttStore } from '../stores/mqtt.js';
import { import {
NCard, NCard,
NSpace, NSpace,
@@ -13,154 +13,191 @@ import {
NListItem, NListItem,
NTag, NTag,
NEllipsis, NEllipsis,
useMessage useMessage,
} from 'naive-ui' } from 'naive-ui';
const store = useMqttStore() const store = useMqttStore();
const msg = useMessage() const msg = useMessage();
const topicInput = ref('') const topicInput = ref('');
const qos = ref(2) const qos = ref(2);
const editingTopicId = ref(null) const editingTopicId = ref(null);
const editingTopic = ref('') const editingTopic = ref('');
const editingQos = ref(2) const editingQos = ref(2);
const qosOptions = [ const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 }, { label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 }, { label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 } { label: 'QoS 2 - 恰好一次', value: 2 },
] ];
const formStorageKey = 'mqtt-client.subscribe-form' const formStorageKey = 'mqtt-client.subscribe-form';
let formReady = false let formReady = false;
try { try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}') const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
if (typeof savedForm.topicInput === 'string') topicInput.value = savedForm.topicInput if (typeof savedForm.topicInput === 'string') {
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos topicInput.value = savedForm.topicInput;
}
if ([0, 1, 2].includes(savedForm.qos)) {
qos.value = savedForm.qos;
}
} catch { } catch {
localStorage.removeItem(formStorageKey) localStorage.removeItem(formStorageKey);
} }
watch([topicInput, qos], () => { watch([topicInput, qos], () => {
if (!formReady) return if (!formReady) {
return;
}
localStorage.setItem( localStorage.setItem(
formStorageKey, formStorageKey,
JSON.stringify({ topicInput: topicInput.value, qos: qos.value }) JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
) );
}) });
formReady = true formReady = true;
async function handleAddTopic() { async function handleAddTopic() {
const topic = topicInput.value.trim() const topic = topicInput.value.trim();
if (!topic) { if (!topic) {
msg.warning('请输入主题名称') msg.warning('请输入主题名称');
return return;
}
const srv = store.activeServer.value;
if (!srv) {
return;
} }
const srv = store.activeServer.value
if (!srv) return
if (srv.topics && srv.topics.find((t) => t.topic === topic)) { if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
msg.warning('该主题已存在') msg.warning('该主题已存在');
return return;
} }
const result = await store.addTopic(srv.id, topic, qos.value) const result = await store.addTopic(srv.id, topic, qos.value);
if (result) { if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : '')) msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''));
topicInput.value = '' topicInput.value = '';
} }
} }
async function handleRemoveTopic(topic) { async function handleRemoveTopic(topic) {
const srv = store.activeServer.value const srv = store.activeServer.value;
if (!srv) return if (!srv) {
await store.removeTopic(srv.id, topic) return;
msg.info('主题已移除') }
await store.removeTopic(srv.id, topic);
msg.info('主题已移除');
} }
async function handleToggleSub(topic) { async function handleToggleSub(topic) {
const srv = store.activeServer.value const srv = store.activeServer.value;
if (!srv) return if (!srv) {
if (srv.status !== 'connected') { return;
msg.warning('请先连接服务器') }
return if (srv.status !== 'connected') {
msg.warning('请先连接服务器');
return;
}
const t = srv.topics.find((t) => t.topic === topic);
if (!t) {
return;
} }
const t = srv.topics.find((t) => t.topic === topic)
if (!t) return
if (t.subscribed) { if (t.subscribed) {
await store.unsubscribeTopic(srv.id, topic) await store.unsubscribeTopic(srv.id, topic);
msg.info('已取消订阅: ' + topic) msg.info('已取消订阅: ' + topic);
} else { } else {
await store.subscribeTopic(srv.id, topic) await store.subscribeTopic(srv.id, topic);
msg.info('已订阅: ' + topic) msg.info('已订阅: ' + topic);
} }
} }
async function handleSubscribeAll() { async function handleSubscribeAll() {
const srv = store.activeServer.value const srv = store.activeServer.value;
if (!srv) return if (!srv) {
if (srv.status !== 'connected') { return;
msg.warning('请先连接服务器')
return
} }
await store.subscribeAllTopics(srv.id) if (srv.status !== 'connected') {
msg.success('已订阅全部主题') msg.warning('请先连接服务器');
return;
}
await store.subscribeAllTopics(srv.id);
msg.success('已订阅全部主题');
} }
function startEdit(t) { function startEdit(t) {
editingTopicId.value = t.id || t.topic editingTopicId.value = t.id || t.topic;
editingTopic.value = t.topic editingTopic.value = t.topic;
editingQos.value = t.qos ?? 2 editingQos.value = t.qos ?? 2;
} }
function cancelEdit() { function cancelEdit() {
editingTopicId.value = null editingTopicId.value = null;
editingTopic.value = '' editingTopic.value = '';
editingQos.value = 2 editingQos.value = 2;
} }
async function handleEditSave(t) { async function handleEditSave(t) {
const srv = store.activeServer.value const srv = store.activeServer.value;
if (!srv) return if (!srv) {
const topicName = editingTopic.value.trim() return;
}
const topicName = editingTopic.value.trim();
if (!topicName) { if (!topicName) {
msg.warning('请输入主题名称') msg.warning('请输入主题名称');
return return;
} }
if ( if (
srv.topics.find( srv.topics.find(
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value (item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
) )
) { ) {
msg.warning('该主题已存在') msg.warning('该主题已存在');
return return;
} }
const topicId = t.id || t.topic const topicId = t.id || t.topic;
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value) const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value);
if (result) { if (result) {
msg.success('主题已更新') msg.success('主题已更新');
cancelEdit() cancelEdit();
} else { } else {
msg.error('主题更新失败') msg.error('主题更新失败');
} }
} }
</script> </script>
<template> <template>
<div v-if="store.activeServer.value" class="subscribe-view"> <div
v-if="store.activeServer.value"
class="subscribe-view"
>
<!-- 添加主题 --> <!-- 添加主题 -->
<n-card title="添加订阅主题" size="small"> <n-card
title="添加订阅主题"
size="small"
>
<div class="topic-form"> <div class="topic-form">
<n-form-item class="topic-field" label="主题 (Topic)"> <n-form-item
class="topic-field"
label="主题 (Topic)"
>
<n-input <n-input
v-model:value="topicInput" v-model:value="topicInput"
placeholder="例如: sensor/+/temperature" placeholder="例如: sensor/+/temperature"
@keydown.enter="handleAddTopic" @keydown.enter="handleAddTopic"
/> />
</n-form-item> </n-form-item>
<n-form-item class="qos-field" label="QoS"> <n-form-item
<n-select v-model:value="qos" :options="qosOptions" /> class="qos-field"
label="QoS"
>
<n-select
v-model:value="qos"
:options="qosOptions"
/>
</n-form-item> </n-form-item>
<n-button class="topic-submit" type="primary" @click="handleAddTopic"> <n-button
class="topic-submit"
type="primary"
@click="handleAddTopic"
>
+ 添加订阅 + 添加订阅
</n-button> </n-button>
</div> </div>
@@ -175,29 +212,46 @@ async function handleEditSave(t) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,
overflow: 'hidden' overflow: 'hidden',
}" }"
> >
<template #header-extra> <template #header-extra>
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button> <n-button
size="small"
@click="handleSubscribeAll"
>
全部订阅
</n-button>
</template> </template>
<n-empty <n-empty
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0" v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
description="暂无订阅主题,请在上方添加" description="暂无订阅主题,请在上方添加"
/> />
<div v-else class="topic-list-scroll"> <div
v-else
class="topic-list-scroll"
>
<n-list> <n-list>
<n-list-item <n-list-item
v-for="t in store.activeServer.value.topics" v-for="t in store.activeServer.value.topics"
:key="t.id || t.topic" :key="t.id || t.topic"
class="topic-item" class="topic-item"
> >
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix> <template
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small"> v-if="editingTopicId !== (t.id || t.topic)"
#prefix
>
<n-tag
:type="t.subscribed ? 'success' : 'default'"
size="small"
>
QoS {{ t.qos }} QoS {{ t.qos }}
</n-tag> </n-tag>
</template> </template>
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row"> <div
v-if="editingTopicId === (t.id || t.topic)"
class="topic-edit-row"
>
<n-input <n-input
v-model:value="editingTopic" v-model:value="editingTopic"
size="small" size="small"
@@ -210,12 +264,29 @@ async function handleEditSave(t) {
:options="qosOptions" :options="qosOptions"
style="width: 160px; flex-shrink: 0" style="width: 160px; flex-shrink: 0"
/> />
<n-space :size="4" :wrap="false"> <n-space
<n-button size="tiny" type="primary" @click="handleEditSave(t)">保存</n-button> :size="4"
<n-button size="tiny" @click="cancelEdit">取消</n-button> :wrap="false"
>
<n-button
size="tiny"
type="primary"
@click="handleEditSave(t)"
>
保存
</n-button>
<n-button
size="tiny"
@click="cancelEdit"
>
取消
</n-button>
</n-space> </n-space>
</div> </div>
<div v-else class="topic-name"> <div
v-else
class="topic-name"
>
<n-ellipsis>{{ t.topic }}</n-ellipsis> <n-ellipsis>{{ t.topic }}</n-ellipsis>
</div> </div>
<template #suffix> <template #suffix>
@@ -225,7 +296,12 @@ async function handleEditSave(t) {
:size="4" :size="4"
:wrap="false" :wrap="false"
> >
<n-button size="tiny" @click="startEdit(t)">编辑</n-button> <n-button
size="tiny"
@click="startEdit(t)"
>
编辑
</n-button>
<n-button <n-button
size="tiny" size="tiny"
:type="t.subscribed ? 'error' : 'success'" :type="t.subscribed ? 'error' : 'success'"
@@ -233,7 +309,12 @@ async function handleEditSave(t) {
> >
{{ t.subscribed ? '取消订阅' : '订阅' }} {{ t.subscribed ? '取消订阅' : '订阅' }}
</n-button> </n-button>
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)"> <n-button
size="tiny"
type="error"
text
@click.stop="handleRemoveTopic(t.topic)"
>
&#10005; &#10005;
</n-button> </n-button>
</n-space> </n-space>
@@ -243,7 +324,10 @@ async function handleEditSave(t) {
</div> </div>
</n-card> </n-card>
</div> </div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" /> <n-empty
v-else
description="请先在左侧选择一个 MQTT 服务器"
/>
</template> </template>
<style scoped> <style scoped>