feat: 初始版本,支持配置和连接多个 MQTT 服务器,支持配置订阅的主题列表,支持消息历史记录

This commit is contained in:
2026-07-18 19:23:50 +08:00
parent d0bbd32d08
commit 32643c2518
17 changed files with 2931 additions and 34 deletions
+8 -5
View File
@@ -1,13 +1,16 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { app, shell, BrowserWindow } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc-handlers.js'
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
@@ -49,8 +52,8 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
// 注册 IPC 处理器
registerIpcHandlers()
createWindow()
+119
View File
@@ -0,0 +1,119 @@
import { ipcMain, BrowserWindow } from 'electron'
import mqttManager from './mqtt-manager.js'
export function registerIpcHandlers() {
// 获取主窗口(用于推送事件)
function getMainWindow() {
const windows = BrowserWindow.getAllWindows()
return windows.length > 0 ? windows[0] : null
}
// 向渲染进程发送事件
function sendToRenderer(channel, data) {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data)
}
}
// 注册 MQTT 管理器的事件监听
mqttManager.addListener((event, data) => {
switch (event) {
case 'status-change':
sendToRenderer('mqtt:status-change', data)
break
case 'message':
sendToRenderer('mqtt:message', data)
break
case 'subscription-change':
sendToRenderer('mqtt:subscription-change', data)
break
case 'subscription-error':
sendToRenderer('mqtt:subscription-error', data)
break
case 'publish-error':
sendToRenderer('mqtt:publish-error', data)
break
}
})
// ==================== 服务器管理 ====================
ipcMain.handle('mqtt:get-servers', () => {
return mqttManager.getServers()
})
ipcMain.handle('mqtt:add-server', (_, config) => {
return mqttManager.addServer(config)
})
ipcMain.handle('mqtt:update-server', (_, id, config) => {
return mqttManager.updateServer(id, config)
})
ipcMain.handle('mqtt:delete-server', (_, id) => {
mqttManager.deleteServer(id)
return true
})
// ==================== 连接管理 ====================
ipcMain.handle('mqtt:connect', (_, id) => {
return mqttManager.connect(id)
})
ipcMain.handle('mqtt:disconnect', (_, id) => {
mqttManager.disconnect(id)
return true
})
// ==================== 主题管理 ====================
ipcMain.handle('mqtt:add-topic', (_, serverId, topic, qos) => {
return mqttManager.addTopic(serverId, topic, qos)
})
ipcMain.handle('mqtt:remove-topic', (_, serverId, topic) => {
return mqttManager.removeTopic(serverId, topic)
})
ipcMain.handle('mqtt:subscribe', (_, serverId, topic) => {
return mqttManager.subscribe(serverId, topic)
})
ipcMain.handle('mqtt:unsubscribe', (_, serverId, topic) => {
return mqttManager.unsubscribe(serverId, topic)
})
ipcMain.handle('mqtt:subscribe-all', (_, serverId) => {
return mqttManager.subscribeAll(serverId)
})
ipcMain.handle('mqtt:update-topic', (_, serverId, topicId, topic, qos) => {
return mqttManager.updateTopic(serverId, topicId, topic, qos)
})
// ==================== 消息发布 ====================
ipcMain.handle('mqtt:publish', (_, serverId, topic, payload, opts) => {
return mqttManager.publish(serverId, topic, payload, opts)
})
// ==================== 消息管理 ====================
ipcMain.handle('mqtt:get-messages', (_, serverId) => {
return mqttManager.getMessages(serverId)
})
ipcMain.handle('mqtt:get-publish-history', (_, serverId) => {
return mqttManager.getPublishHistory(serverId)
})
ipcMain.handle('mqtt:clear-messages', (_, serverId) => {
mqttManager.clearMessages(serverId)
return true
})
ipcMain.handle('mqtt:clear-publish-history', (_, serverId) => {
mqttManager.clearPublishHistory(serverId)
return true
})
// 兼容旧的 ping 测试
ipcMain.on('ping', () => console.log('pong'))
}
+524
View File
@@ -0,0 +1,524 @@
import mqtt from 'mqtt'
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
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.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json')
this.loadData()
}
// ==================== 数据持久化 ====================
getDataDir() {
const dir = app.getPath('userData')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
return dir
}
loadData() {
try {
if (existsSync(this.dataPath)) {
const raw = readFileSync(this.dataPath, 'utf-8')
const data = JSON.parse(raw)
if (data.servers) {
data.servers.forEach((s) => {
s.status = 'disconnected'
s.topics = (s.topics || []).map((t) => ({
...t,
id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6),
subscribed: false
}))
this.servers.set(s.id, s)
})
}
if (data.messages) {
Object.entries(data.messages).forEach(([id, msgs]) => {
this.messages.set(id, msgs)
})
}
if (data.publishHistory) {
Object.entries(data.publishHistory).forEach(([id, history]) => {
this.publishHistory.set(id, history)
})
}
} else {
this.initDefaultServers()
}
} catch (e) {
console.error('加载数据失败:', e.message)
this.initDefaultServers()
}
}
saveData() {
try {
const data = {
servers: Array.from(this.servers.values()).map((s) => ({
...s,
status: 'disconnected',
topics: s.topics.map((t) => ({ ...t, subscribed: false }))
})),
messages: Object.fromEntries(this.messages),
publishHistory: Object.fromEntries(this.publishHistory)
}
writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8')
} catch (e) {
console.error('保存数据失败:', e.message)
}
}
initDefaultServers() {
const defaults = [
{
id: 'srv_001',
name: 'EMQX 公共测试',
host: 'broker.emqx.io',
port: 1883,
protocol: 'mqtt',
clientId: 'frost_client_' + Math.random().toString(36).slice(2, 8),
username: '',
password: '',
keepAlive: 60,
cleanSession: true,
status: 'disconnected',
topics: [
{ id: 'topic_default_001', topic: 'sensor/+/temperature', qos: 1, subscribed: false },
{ id: 'topic_default_002', topic: 'device/status', qos: 2, subscribed: false }
]
},
{
id: 'srv_002',
name: '本地 Mosquitto',
host: 'localhost',
port: 1883,
protocol: 'mqtt',
clientId: 'frost_local_' + Math.random().toString(36).slice(2, 8),
username: '',
password: '',
keepAlive: 60,
cleanSession: true,
status: 'disconnected',
topics: [
{ id: 'topic_default_003', topic: 'home/+/light', qos: 1, subscribed: false },
{ id: 'topic_default_004', topic: 'home/alarm', qos: 2, subscribed: false }
]
}
]
defaults.forEach((s) => {
this.servers.set(s.id, s)
this.messages.set(s.id, [])
this.publishHistory.set(s.id, [])
})
this.saveData()
}
// ==================== 监听器 ====================
addListener(callback) {
this.listeners.add(callback)
}
removeListener(callback) {
this.listeners.delete(callback)
}
notify(event, data) {
this.listeners.forEach((cb) => {
try {
cb(event, data)
} catch (e) {
console.error('通知失败:', e)
}
})
}
// ==================== 服务器管理 ====================
getServers() {
return Array.from(this.servers.values()).map((s) => ({
...s,
password: s.password ? '******' : ''
}))
}
getServer(id) {
return this.servers.get(id) || null
}
addServer(config) {
const id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
const server = {
id,
name: config.name,
host: config.host,
port: config.port || 1883,
protocol: config.protocol || 'mqtt',
clientId: config.clientId || 'frost_' + Math.random().toString(36).slice(2, 10),
username: config.username || '',
password: config.password || '',
keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false,
status: 'disconnected',
topics: []
}
this.servers.set(id, server)
this.messages.set(id, [])
this.publishHistory.set(id, [])
this.saveData()
return server
}
updateServer(id, config) {
const server = this.servers.get(id)
if (!server) return null
Object.assign(server, {
name: config.name,
host: config.host,
port: config.port || 1883,
protocol: config.protocol || 'mqtt',
clientId: config.clientId || server.clientId,
username: config.username || '',
keepAlive: config.keepAlive || 60,
cleanSession: config.cleanSession !== false
})
if (config.password) {
server.password = config.password
}
this.saveData()
return server
}
deleteServer(id) {
// 先断开连接
this.disconnect(id)
this.servers.delete(id)
this.messages.delete(id)
this.publishHistory.delete(id)
this.saveData()
}
// ==================== 连接管理 ====================
connect(id) {
const server = this.servers.get(id)
if (!server) return false
// 如果已连接,先断开
if (this.clients.has(id)) {
this.disconnect(id)
}
server.status = 'connecting'
this.notify('status-change', { id, status: 'connecting' })
const protocol = server.protocol || 'mqtt'
const url = `${protocol}://${server.host}:${server.port}`
const options = {
clientId: server.clientId,
username: server.username || undefined,
password: server.password || undefined,
keepalive: server.keepAlive,
clean: server.cleanSession,
reconnectPeriod: 5000,
connectTimeout: 10000
}
try {
const client = mqtt.connect(url, options)
client.on('connect', () => {
server.status = 'connected'
this.notify('status-change', { id, status: 'connected' })
// 自动订阅已有主题
server.topics.forEach((t) => {
this._doSubscribe(id, t.topic, { qos: t.qos })
})
})
client.on('error', (err) => {
console.error(`[${server.name}] 连接错误:`, err.message)
server.status = 'error'
this.notify('status-change', { id, status: 'error', error: err.message })
})
client.on('close', () => {
if (server.status === 'connected') {
server.status = 'disconnected'
server.topics.forEach((t) => {
t.subscribed = false
})
this.notify('status-change', { id, status: 'disconnected' })
}
})
client.on('message', (topic, payload, packet) => {
const msg = {
time:
new Date().toLocaleTimeString('zh-CN', { hour12: false }) +
'.' +
String(Date.now() % 1000).padStart(3, '0'),
topic,
payload: payload.toString(),
qos: packet.qos,
retain: packet.retain,
direction: 'sub',
id: 'msg_' + Date.now()
}
const msgs = this.messages.get(id) || []
msgs.unshift(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
} catch (err) {
server.status = 'error'
this.notify('status-change', { id, status: 'error', error: err.message })
return false
}
}
disconnect(id) {
const client = this.clients.get(id)
if (client) {
client.end(true)
this.clients.delete(id)
}
const server = this.servers.get(id)
if (server) {
server.status = 'disconnected'
server.topics.forEach((t) => {
t.subscribed = false
})
this.notify('status-change', { id, status: 'disconnected' })
}
}
// ==================== 订阅管理 ====================
_doSubscribe(serverId, topic, opts) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId)
if (!client || !server || server.status !== 'connected') return false
client.subscribe(topic, opts, (err) => {
if (err) {
console.error(`订阅失败 [${topic}]:`, err.message)
this.notify('subscription-error', { serverId, topic, error: err.message })
} else {
const t = server.topics.find((t) => t.topic === topic)
if (t) t.subscribed = true
this.notify('subscription-change', { serverId, topic, subscribed: true })
}
})
return true
}
addTopic(serverId, topic, qos = 1) {
const server = this.servers.get(serverId)
if (!server) return false
if (server.topics.find((t) => t.topic === topic)) return false
const topicId = 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
server.topics.push({ id: topicId, topic, qos, subscribed: false })
this.saveData()
// 如果已连接,自动订阅
if (server.status === 'connected') {
this._doSubscribe(serverId, topic, { qos })
}
return true
}
removeTopic(serverId, topic) {
const server = this.servers.get(serverId)
if (!server) return false
// 先取消订阅
this.unsubscribe(serverId, topic)
server.topics = server.topics.filter((t) => t.topic !== topic)
this.saveData()
return true
}
subscribe(serverId, topic) {
const server = this.servers.get(serverId)
if (!server) return false
const t = server.topics.find((t) => t.topic === topic)
if (!t) return false
return this._doSubscribe(serverId, topic, { qos: t.qos })
}
unsubscribe(serverId, topic) {
const client = this.clients.get(serverId)
const server = this.servers.get(serverId)
if (!client || !server || server.status !== 'connected') return false
client.unsubscribe(topic, {}, (err) => {
if (!err) {
const t = server.topics.find((t) => t.topic === topic)
if (t) t.subscribed = false
this.notify('subscription-change', { serverId, topic, subscribed: false })
}
})
return true
}
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
if (
topicChanged &&
server.topics.find((item) => item.topic === newTopic && item.id !== topicId)
) {
return false
}
const oldTopic = t.topic
const wasSubscribed = t.subscribed
// 如果主题名变更且已订阅,先取消旧主题订阅
if (topicChanged && wasSubscribed) {
this.unsubscribe(serverId, oldTopic)
}
t.topic = newTopic
t.qos = newQos
// 如果已连接且此前已订阅,重新订阅新主题
if (server.status === 'connected' && wasSubscribed) {
this._doSubscribe(serverId, newTopic, { qos: newQos })
}
this.saveData()
this.notify('subscription-change', {
serverId,
topic: newTopic,
subscribed: t.subscribed,
topicId
})
return true
}
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)
if (topicsToSubscribe.length === 0) return true
const subMap = Object.fromEntries(topicsToSubscribe.map((t) => [t.topic, { qos: t.qos }]))
client.subscribe(subMap, (err) => {
if (err) {
console.error('批量订阅失败:', err.message)
topicsToSubscribe.forEach((t) => {
this.notify('subscription-error', { serverId, topic: t.topic, error: err.message })
})
} else {
topicsToSubscribe.forEach((t) => {
t.subscribed = true
this.notify('subscription-change', {
serverId,
topic: t.topic,
subscribed: true,
topicId: t.id
})
})
}
})
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 = {
qos: opts.qos ?? 1,
retain: opts.retain ?? false
}
client.publish(topic, payload, pubOpts, (err) => {
if (err) {
console.error(`发布失败 [${topic}]:`, err.message)
this.notify('publish-error', { serverId, topic, error: err.message })
}
})
// 记录发布历史
const pubEntry = {
time:
new Date().toLocaleTimeString('zh-CN', { hour12: false }) +
'.' +
String(Date.now() % 1000).padStart(3, '0'),
topic,
payload,
qos: pubOpts.qos,
retain: pubOpts.retain,
direction: 'pub'
}
const history = this.publishHistory.get(serverId) || []
history.unshift(pubEntry)
if (history.length > 50) history.pop()
this.publishHistory.set(serverId, history)
// 同时添加到消息日志
const msgs = this.messages.get(serverId) || []
msgs.unshift({
...pubEntry,
id: 'msg_' + Date.now()
})
if (msgs.length > 500) msgs.pop()
this.messages.set(serverId, msgs)
this.notify('message', {
serverId,
message: {
...pubEntry,
id: 'msg_' + Date.now()
}
})
this.saveData()
return true
}
// ==================== 消息管理 ====================
getMessages(serverId) {
return this.messages.get(serverId) || []
}
getPublishHistory(serverId) {
return this.publishHistory.get(serverId) || []
}
clearMessages(serverId) {
this.messages.set(serverId, [])
this.saveData()
}
clearPublishHistory(serverId) {
this.publishHistory.set(serverId, [])
this.saveData()
}
}
// 单例
const mqttManager = new MqttManager()
export default mqttManager
+58 -2
View File
@@ -1,8 +1,64 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
const api = {
// 服务器管理
getServers: () => ipcRenderer.invoke('mqtt:get-servers'),
addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config),
updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config),
deleteServer: (id) => ipcRenderer.invoke('mqtt:delete-server', id),
// 连接管理
connect: (id) => ipcRenderer.invoke('mqtt:connect', id),
disconnect: (id) => ipcRenderer.invoke('mqtt:disconnect', id),
// 主题管理
addTopic: (serverId, topic, qos) => ipcRenderer.invoke('mqtt:add-topic', serverId, topic, qos),
removeTopic: (serverId, topic) => ipcRenderer.invoke('mqtt:remove-topic', serverId, topic),
subscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:subscribe', serverId, topic),
unsubscribe: (serverId, topic) => ipcRenderer.invoke('mqtt:unsubscribe', serverId, topic),
subscribeAll: (serverId) => ipcRenderer.invoke('mqtt:subscribe-all', serverId),
updateTopic: (serverId, topicId, topic, qos) =>
ipcRenderer.invoke('mqtt:update-topic', serverId, topicId, topic, qos),
// 消息发布
publish: (serverId, topic, payload, opts) =>
ipcRenderer.invoke('mqtt:publish', serverId, topic, payload, opts),
// 消息管理
getMessages: (serverId) => ipcRenderer.invoke('mqtt:get-messages', serverId),
getPublishHistory: (serverId) => ipcRenderer.invoke('mqtt:get-publish-history', serverId),
clearMessages: (serverId) => ipcRenderer.invoke('mqtt:clear-messages', serverId),
clearPublishHistory: (serverId) => ipcRenderer.invoke('mqtt:clear-publish-history', serverId),
// 事件监听
onStatusChange: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:status-change', handler)
return () => ipcRenderer.removeListener('mqtt:status-change', handler)
},
onMessage: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:message', handler)
return () => ipcRenderer.removeListener('mqtt:message', handler)
},
onSubscriptionChange: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:subscription-change', handler)
return () => ipcRenderer.removeListener('mqtt:subscription-change', handler)
},
onSubscriptionError: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:subscription-error', handler)
return () => ipcRenderer.removeListener('mqtt:subscription-error', handler)
},
onPublishError: (callback) => {
const handler = (_, data) => callback(data)
ipcRenderer.on('mqtt:publish-error', handler)
return () => ipcRenderer.removeListener('mqtt:publish-error', handler)
}
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
+3 -4
View File
@@ -2,11 +2,10 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<title>Frost MQTT Client</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: mqtt: mqtts:"
/>
</head>
@@ -14,4 +13,4 @@
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>
+150 -18
View File
@@ -1,24 +1,156 @@
<script setup>
import Versions from './components/Versions.vue'
import { ref, onMounted } from 'vue'
import {
NLayout,
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 ipcHandle = () => window.electron.ipcRenderer.send('ping')
const store = useMqttStore()
const activeTab = ref('dashboard')
const showServerModal = ref(false)
const editServerData = ref(null)
onMounted(async () => {
await store.init()
})
function handleAddServer() {
editServerData.value = null
showServerModal.value = true
}
function handleEditServer(server) {
editServerData.value = server
showServerModal.value = true
}
function handleServerSaved() {
// 刷新数据
}
function handleSwitchTab(tab) {
activeTab.value = tab
}
</script>
<template>
<img alt="logo" class="logo" src="./assets/electron.svg" />
<div class="creator">Powered by electron-vite</div>
<div class="text">
Build an Electron app with
<span class="vue">Vue</span>
</div>
<p class="tip">Please try pressing <code>F12</code> to open the devTool</p>
<div class="actions">
<div class="action">
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">Documentation</a>
</div>
<div class="action">
<a target="_blank" rel="noreferrer" @click="ipcHandle">Send IPC</a>
</div>
</div>
<Versions />
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme="null">
<n-message-provider placement="bottom">
<n-dialog-provider>
<n-layout has-sider position="absolute">
<!-- 侧边栏 -->
<Sidebar @add-server="handleAddServer" @edit-server="handleEditServer" />
<!-- 主内容区 -->
<n-layout content-class="main-layout-content">
<n-tabs v-model:value="activeTab" type="line" size="medium" style="padding: 0 16px">
<n-tab-pane name="dashboard" tab="仪表盘">
<div class="tab-content dashboard-content">
<Dashboard @switch-tab="handleSwitchTab" @edit-server="handleEditServer" />
</div>
</n-tab-pane>
<n-tab-pane name="subscribe" tab="主题订阅">
<div class="tab-content">
<Subscribe />
</div>
</n-tab-pane>
<n-tab-pane name="messages" tab="消息中心">
<div class="tab-content">
<div class="message-center-layout">
<section class="publish-panel">
<Publish />
</section>
<section class="messages-panel">
<Messages />
</section>
</div>
</div>
</n-tab-pane>
</n-tabs>
</n-layout>
<ServerModal
v-model:show="showServerModal"
:edit-server="editServerData"
@saved="handleServerSaved"
/>
</n-layout>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style>
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.main-layout-content {
overflow: hidden;
}
.tab-content {
box-sizing: border-box;
padding: 12px 0;
height: calc(100vh - 50px);
overflow: hidden;
}
.dashboard-content {
overflow-y: auto;
overscroll-behavior: contain;
}
.message-center-layout {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.6fr);
gap: 12px;
height: 100%;
min-width: 0;
}
.publish-panel,
.messages-panel {
min-width: 0;
min-height: 0;
}
.publish-panel {
box-sizing: border-box;
height: 100%;
min-height: 0;
padding-right: 4px;
overflow: hidden;
}
.messages-panel {
height: 100%;
min-height: 0;
overflow: hidden;
}
@media (max-width: 900px) {
.message-center-layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,166 @@
<script setup>
import { ref, watch } from 'vue'
import {
NModal,
NForm,
NFormItem,
NGrid,
NFormItemGi,
NInput,
NInputNumber,
NSelect,
NSwitch,
NButton,
useMessage
} from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
const props = defineProps({
show: Boolean,
editServer: Object
})
const emit = defineEmits(['update:show', 'saved'])
const store = useMqttStore()
const msg = useMessage()
const formRef = ref(null)
const form = ref({
name: '',
host: '',
port: 1883,
protocol: 'mqtt',
clientId: '',
username: '',
password: '',
keepAlive: 60,
cleanSession: true
})
const rules = {
name: { required: true, message: '请输入服务器名称', trigger: 'blur' },
host: { required: true, message: '请输入主机地址', trigger: 'blur' },
port: { required: true, type: 'number', message: '请输入端口号', trigger: 'blur' }
}
const protocolOptions = [
{ label: 'mqtt:// (默认)', value: 'mqtt' },
{ label: 'mqtts:// (TLS)', value: 'mqtts' },
{ label: 'ws:// (WebSocket)', value: 'ws' },
{ label: 'wss:// (WebSocket TLS)', value: 'wss' }
]
const isEdit = ref(false)
watch(
() => props.show,
(val) => {
if (val) {
if (props.editServer) {
isEdit.value = true
form.value = {
name: props.editServer.name,
host: props.editServer.host,
port: props.editServer.port,
protocol: props.editServer.protocol || 'mqtt',
clientId: props.editServer.clientId || '',
username: props.editServer.username || '',
password: '',
keepAlive: props.editServer.keepAlive || 60,
cleanSession: props.editServer.cleanSession !== false
}
} else {
isEdit.value = false
form.value = {
name: '',
host: '',
port: 1883,
protocol: 'mqtt',
clientId: 'frost_' + Math.random().toString(36).slice(2, 10),
username: '',
password: '',
keepAlive: 60,
cleanSession: true
}
}
}
}
)
async function handleSave() {
try {
await formRef.value?.validate()
} catch {
return
}
try {
if (isEdit.value && props.editServer) {
await store.updateServer(props.editServer.id, { ...form.value })
msg.success('服务器配置已更新')
} else {
await store.addServer({ ...form.value })
msg.success('服务器已添加')
}
emit('saved')
emit('update:show', false)
} catch (e) {
msg.error('操作失败: ' + (e.message || '未知错误'))
}
}
</script>
<template>
<n-modal
:show="show"
:mask-closable="false"
preset="card"
:title="isEdit ? '编辑 MQTT 服务器' : '添加 MQTT 服务器'"
style="width: 520px; max-width: 90vw"
@update:show="$emit('update:show', $event)"
>
<n-form ref="formRef" :model="form" :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 label="主机地址" path="host">
<n-input v-model:value="form.host" placeholder="例如: broker.emqx.io" />
</n-form-item-gi>
<n-form-item-gi 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 label="协议">
<n-select v-model:value="form.protocol" :options="protocolOptions" />
</n-form-item-gi>
<n-form-item-gi label="客户端 ID">
<n-input v-model:value="form.clientId" placeholder="自动生成" />
</n-form-item-gi>
<n-form-item-gi label="Keep Alive (秒)">
<n-input-number
v-model:value="form.keepAlive"
:min="1"
:max="65535"
style="width: 100%"
/>
</n-form-item-gi>
<n-form-item-gi label="用户名">
<n-input v-model:value="form.username" placeholder="可选" />
</n-form-item-gi>
<n-form-item-gi label="密码">
<n-input v-model:value="form.password" type="password" placeholder="可选" />
</n-form-item-gi>
</n-grid>
<n-form-item label="Clean Session">
<n-switch v-model:value="form.cleanSession" />
</n-form-item>
</n-form>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px">
<n-button @click="$emit('update:show', false)">取消</n-button>
<n-button type="primary" @click="handleSave">{{ isEdit ? '更新' : '保存' }}</n-button>
</div>
</template>
</n-modal>
</template>
+288
View File
@@ -0,0 +1,288 @@
<script setup>
import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui'
import { useMqttStore } from '../stores/mqtt.js'
const emit = defineEmits(['add-server', 'edit-server'])
const store = useMqttStore()
const message = useMessage()
const dialog = useDialog()
async function handleConnect(server) {
if (server.status === 'connected') {
await store.disconnectServer(server.id)
message.info('已断开连接: ' + server.name)
} else {
await store.connectServer(server.id)
}
}
function handleEdit(server) {
emit('edit-server', server)
}
function handleDelete(server) {
dialog.warning({
title: '确认删除',
content: `确定要删除服务器 "${server.name}" 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await store.deleteServer(server.id)
message.info('服务器已删除')
}
})
}
</script>
<template>
<n-layout-sider
bordered
collapse-mode="transform"
:collapsed-width="0"
:width="280"
show-trigger="bar"
:native-scrollbar="true"
content-class="sidebar-scroll-container"
:content-style="{ height: '100%', overflow: 'hidden' }"
>
<div class="sidebar-container">
<div class="sidebar-header">
<div class="sidebar-logo">F</div>
<div>
<div class="sidebar-title">Frost MQTT Client</div>
<div class="sidebar-subtitle">多服务器 MQTT 客户端</div>
</div>
</div>
<div class="server-list">
<div
v-for="srv in store.servers.value"
:key="srv.id"
class="server-item"
:class="{ active: srv.id === store.activeServerId.value }"
@click="store.setActiveServer(srv.id)"
>
<div class="server-item-top">
<span class="server-name">{{ srv.name }}</span>
<div class="server-actions">
<n-button text size="tiny" @click.stop="handleEdit(srv)">
<template #icon>
<span style="font-size: 14px">&#9998;</span>
</template>
</n-button>
<n-button text size="tiny" type="error" @click.stop="handleDelete(srv)">
<template #icon>
<span style="font-size: 14px">&#10005;</span>
</template>
</n-button>
</div>
</div>
<div class="server-url">
<n-tag
:bordered="false"
size="small"
:type="
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
>
<template #icon>
<span class="status-dot" :class="srv.status" />
</template>
{{ srv.protocol }}://{{ srv.host }}:{{ srv.port }}
</n-tag>
</div>
<div class="server-bottom">
<span class="server-status-text">
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: srv.status === 'error'
? '连接失败'
: '未连接'
}}
</span>
<n-button
size="tiny"
:type="
srv.status === 'connected'
? 'error'
: srv.status === 'connecting'
? 'warning'
: 'success'
"
:disabled="srv.status === 'connecting'"
@click.stop="handleConnect(srv)"
>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</div>
</div>
</div>
<div class="sidebar-footer">
<n-button type="primary" block @click="$emit('add-server')"> + 添加服务器 </n-button>
</div>
</div>
</n-layout-sider>
</template>
<style scoped>
:deep(.sidebar-scroll-container) {
height: 100%;
overflow: hidden !important;
}
.sidebar-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--n-color);
}
.sidebar-header {
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--n-border-color);
flex-shrink: 0;
}
.sidebar-logo {
width: 32px;
height: 32px;
background: #18a058;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.sidebar-title {
font-size: 15px;
font-weight: 700;
}
.sidebar-subtitle {
font-size: 11px;
opacity: 0.55;
}
.server-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 8px;
}
.server-item {
padding: 8px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
border: 1px solid transparent;
transition: all 0.15s;
}
.server-item:hover {
background: var(--n-color-hover);
}
.server-item.active {
background: var(--n-color-pressed);
border-color: #18a058;
}
.server-item-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.server-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.server-item:hover .server-actions {
opacity: 1;
}
.server-url {
margin-top: 4px;
}
.server-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.server-status-text {
font-size: 11px;
opacity: 0.55;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 2px;
}
.status-dot.connected {
background: #18a058;
}
.status-dot.connecting {
background: #f0a020;
animation: pulse 1s infinite;
}
.status-dot.disconnected {
background: #999;
}
.status-dot.error {
background: #d03050;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--n-border-color);
flex-shrink: 0;
}
</style>
+2 -3
View File
@@ -1,6 +1,5 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
const app = createApp(App)
app.mount('#app')
+238
View File
@@ -0,0 +1,238 @@
import { ref, computed } from 'vue'
// 全局状态(单例)
const servers = ref([])
const activeServerId = ref(null)
const messages = ref([])
const publishHistory = ref([])
const loading = ref(false)
// 清理函数集合
let cleanupFns = []
function setupListeners() {
// 清理旧监听器
cleanupFns.forEach((fn) => fn())
cleanupFns = []
cleanupFns.push(
window.api.onStatusChange((data) => {
const srv = servers.value.find((s) => s.id === data.id)
if (srv) {
srv.status = data.status
if (data.status === 'disconnected') {
srv.topics.forEach((t) => {
t.subscribed = false
})
}
}
})
)
cleanupFns.push(
window.api.onMessage((data) => {
if (data.serverId === activeServerId.value) {
messages.value.unshift(data.message)
if (messages.value.length > 500) messages.value.pop()
}
})
)
cleanupFns.push(
window.api.onSubscriptionChange((data) => {
if (data.serverId === activeServerId.value) {
const srv = servers.value.find((s) => s.id === data.serverId)
if (srv) {
const t = srv.topics.find((t) => t.topic === data.topic || t.id === data.topicId)
if (t) {
t.subscribed = data.subscribed
if (data.topic && data.topic !== t.topic) {
t.topic = data.topic
}
}
}
}
})
)
}
export function useMqttStore() {
const activeServer = computed(
() => servers.value.find((s) => s.id === activeServerId.value) || null
)
const connectedServers = computed(() => servers.value.filter((s) => s.status === 'connected'))
const totalTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.length || 0), 0)
)
const subscribedTopics = computed(() =>
servers.value.reduce((sum, s) => sum + (s.topics?.filter((t) => t.subscribed)?.length || 0), 0)
)
const totalMessages = computed(() => messages.value.length)
// ==================== 初始化 ====================
async function init() {
loading.value = true
try {
const data = await window.api.getServers()
servers.value = data
if (data.length > 0 && !activeServerId.value) {
activeServerId.value = data[0].id
}
setupListeners()
} catch (e) {
console.error('初始化失败:', e)
} finally {
loading.value = false
}
}
// ==================== 服务器管理 ====================
function setActiveServer(id) {
activeServerId.value = id
// 切换时刷新消息
refreshMessages()
refreshPublishHistory()
}
async function addServer(config) {
const server = await window.api.addServer(config)
servers.value.push(server)
if (!activeServerId.value) {
activeServerId.value = server.id
}
return server
}
async function updateServer(id, config) {
const result = await window.api.updateServer(id, config)
if (result) {
const idx = servers.value.findIndex((s) => s.id === id)
if (idx !== -1) {
servers.value[idx] = { ...servers.value[idx], ...result }
}
}
return result
}
async function deleteServer(id) {
await window.api.deleteServer(id)
servers.value = servers.value.filter((s) => s.id !== id)
if (activeServerId.value === id) {
activeServerId.value = servers.value.length > 0 ? servers.value[0].id : null
}
}
async function connectServer(id) {
return await window.api.connect(id)
}
async function disconnectServer(id) {
await window.api.disconnect(id)
}
// ==================== 主题管理 ====================
async function addTopic(serverId, topic, qos) {
const result = await window.api.addTopic(serverId, topic, qos)
if (result) {
const srv = servers.value.find((s) => s.id === serverId)
if (srv) {
srv.topics.push({ topic, qos, subscribed: false })
}
}
return result
}
async function removeTopic(serverId, topic) {
await window.api.removeTopic(serverId, topic)
const srv = servers.value.find((s) => s.id === serverId)
if (srv) {
srv.topics = srv.topics.filter((t) => t.topic !== topic)
}
}
async function subscribeTopic(serverId, topic) {
return await window.api.subscribe(serverId, topic)
}
async function unsubscribeTopic(serverId, topic) {
return await window.api.unsubscribe(serverId, topic)
}
async function subscribeAllTopics(serverId) {
return await window.api.subscribeAll(serverId)
}
async function updateTopic(serverId, topicId, topic, qos) {
return await window.api.updateTopic(serverId, topicId, topic, qos)
}
// ==================== 消息发布 ====================
async function publishMessage(serverId, topic, payload, opts) {
const result = await window.api.publish(serverId, topic, payload, opts)
if (result && serverId === activeServerId.value) {
refreshPublishHistory()
}
return result
}
// ==================== 消息管理 ====================
async function refreshMessages() {
if (!activeServerId.value) return
messages.value = await window.api.getMessages(activeServerId.value)
}
async function refreshPublishHistory() {
if (!activeServerId.value) return
publishHistory.value = await window.api.getPublishHistory(activeServerId.value)
}
async function clearMessages() {
if (!activeServerId.value) return
await window.api.clearMessages(activeServerId.value)
messages.value = []
}
async function clearPublishHistory() {
if (!activeServerId.value) return
await window.api.clearPublishHistory(activeServerId.value)
publishHistory.value = []
}
return {
// 状态
servers,
activeServerId,
activeServer,
messages,
publishHistory,
loading,
// 计算属性
connectedServers,
totalTopics,
subscribedTopics,
totalMessages,
// 方法
init,
setActiveServer,
addServer,
updateServer,
deleteServer,
connectServer,
disconnectServer,
addTopic,
removeTopic,
subscribeTopic,
unsubscribeTopic,
subscribeAllTopics,
updateTopic,
publishMessage,
refreshMessages,
refreshPublishHistory,
clearMessages,
clearPublishHistory
}
}
+219
View File
@@ -0,0 +1,219 @@
<script setup>
import { useMqttStore } from '../stores/mqtt.js'
import {
NGrid,
NGridItem,
NCard,
NStatistic,
NTag,
NDescriptions,
NDescriptionsItem,
NSpace,
NButton,
NList,
NListItem
} from 'naive-ui'
import { useMessage } from 'naive-ui'
const store = useMqttStore()
const msg = useMessage()
function handleAction(srv, action) {
if (srv.id === store.activeServerId.value) {
emit('switch-tab', action)
} else {
store.setActiveServer(srv.id)
emit('switch-tab', action)
}
}
async function handleToggleConnection(srv) {
if (srv.status === 'connected') {
await store.disconnectServer(srv.id)
msg.info('已断开连接: ' + srv.name)
} else if (srv.status === 'disconnected' || srv.status === 'error') {
await store.connectServer(srv.id)
}
}
const emit = defineEmits(['switch-tab', 'edit-server'])
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<n-grid :cols="4" :x-gap="12" responsive="screen">
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="服务器总数" :value="store.servers.value.length" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="已连接" :value="store.connectedServers.value.length" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic
label="已订阅主题"
:value="`${store.subscribedTopics.value}/${store.totalTopics.value}`"
/>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" :bordered="true">
<n-statistic label="消息总数" :value="store.messages.value.length" />
</n-card>
</n-grid-item>
</n-grid>
<!-- 当前服务器 -->
<n-card
v-if="store.activeServer.value"
title="当前服务器"
size="small"
style="margin-top: 12px"
>
<template #header-extra>
<n-tag
:type="
store.activeServer.value.status === 'connected'
? 'success'
: store.activeServer.value.status === 'connecting'
? 'warning'
: 'error'
"
size="small"
>
{{
store.activeServer.value.status === 'connected'
? '已连接'
: store.activeServer.value.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
</template>
<n-descriptions :column="2" size="small" bordered>
<n-descriptions-item label="地址">
{{ store.activeServer.value.protocol }}://{{ store.activeServer.value.host }}:{{
store.activeServer.value.port
}}
</n-descriptions-item>
<n-descriptions-item label="客户端 ID">
{{ store.activeServer.value.clientId }}
</n-descriptions-item>
<n-descriptions-item label="Keep Alive">
{{ store.activeServer.value.keepAlive }}s
</n-descriptions-item>
<n-descriptions-item label="Clean Session">
{{ store.activeServer.value.cleanSession ? '是' : '否' }}
</n-descriptions-item>
</n-descriptions>
<n-space style="margin-top: 12px">
<n-button
size="small"
:type="store.activeServer.value.status === 'connected' ? 'error' : 'success'"
@click="handleToggleConnection(store.activeServer.value)"
>
{{ store.activeServer.value.status === 'connected' ? '断开连接' : '连接服务器' }}
</n-button>
<n-button size="small" @click="$emit('edit-server', store.activeServer.value)"
>编辑配置</n-button
>
<n-button size="small" @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, 'messages')"
>查看消息</n-button
>
</n-space>
</n-card>
<!-- 所有服务器状态 -->
<n-card title="所有服务器状态" size="small" style="margin-top: 12px">
<n-list>
<n-list-item v-for="srv in store.servers.value" :key="srv.id">
<template #prefix>
<span class="status-dot" :class="srv.status" />
</template>
<n-space align="center">
<span style="font-weight: 500">{{ srv.name }}</span>
<n-tag
:bordered="false"
size="small"
:type="
srv.status === 'connected'
? 'success'
: srv.status === 'connecting'
? 'warning'
: 'default'
"
>
{{
srv.status === 'connected'
? '已连接'
: srv.status === 'connecting'
? '连接中...'
: '未连接'
}}
</n-tag>
</n-space>
<template #suffix>
<n-button
size="tiny"
:type="srv.status === 'connected' ? 'error' : 'success'"
:disabled="srv.status === 'connecting'"
@click="handleToggleConnection(srv)"
>
{{
srv.status === 'connected' ? '断开' : srv.status === 'connecting' ? '...' : '连接'
}}
</n-button>
</template>
</n-list-item>
</n-list>
</n-card>
</div>
</template>
<style scoped>
.dashboard {
max-width: 900px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #18a058;
}
.status-dot.connecting {
background: #f0a020;
animation: pulse 1s infinite;
}
.status-dot.disconnected {
background: #999;
}
.status-dot.error {
background: #d03050;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup>
import { ref, computed } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import { NCard, NSpace, NInput, NButton, NEmpty } from 'naive-ui'
const store = useMqttStore()
const filter = ref('')
const filteredMessages = computed(() => {
if (!filter.value.trim()) return store.messages.value
const f = filter.value.trim().toLowerCase()
return store.messages.value.filter((m) => m.topic.toLowerCase().includes(f))
})
async function handleClear() {
await store.clearMessages()
}
function formatPayload(payload) {
try {
return JSON.stringify(JSON.parse(payload), null, 2)
} catch {
return payload
}
}
</script>
<template>
<div v-if="store.activeServer.value" class="messages-view">
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
}"
>
<template #header>
<n-space align="center" justify="space-between" style="width: 100%">
<div>
<span style="font-weight: 700">消息监控</span>
<span style="font-size: 12px; opacity: 0.55; margin-left: 8px"
>实时查看接收和发送的 MQTT 消息</span
>
</div>
<n-space>
<n-input
v-model:value="filter"
size="small"
placeholder="过滤主题..."
style="width: 180px"
clearable
/>
<n-button size="small" @click="handleClear">清空</n-button>
</n-space>
</n-space>
</template>
<n-empty v-if="filteredMessages.length === 0" description="等待消息...">
<template #icon>
<span style="font-size: 32px; opacity: 0.4">&#9776;</span>
</template>
</n-empty>
<div v-else ref="logRef" class="msg-log">
<div v-for="m in filteredMessages" :key="m.id" class="msg-entry">
<div class="msg-header">
<span class="msg-time">{{ m.time }}</span>
<span class="msg-dir" :class="m.direction">{{
m.direction === 'pub' ? 'PUB' : 'SUB'
}}</span>
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div>
<pre class="msg-payload">{{ formatPayload(m.payload) }}</pre>
</div>
</div>
</n-card>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
</template>
<style scoped>
.messages-view {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.messages-view :deep(.n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.messages-view :deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.msg-log {
background: #1e1e2e;
border-radius: 6px;
padding: 12px;
height: 100%;
max-height: 100%;
box-sizing: border-box;
min-height: 0;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
line-height: 1.7;
color: #cdd6f4;
}
.msg-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.msg-header {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.msg-time {
color: #6c7086;
flex-shrink: 0;
}
.msg-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
}
.msg-dir.pub {
color: #89b4fa;
}
.msg-dir.sub {
color: #a6e3a1;
}
.msg-topic {
color: #f9e2af;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-payload {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
color: #cdd6f4;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg-meta {
color: #6c7086;
font-size: 11px;
flex-shrink: 0;
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<script setup>
import { ref, watch } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import {
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NSwitch,
NButton,
NEmpty,
NTabs,
NTabPane,
useMessage
} from 'naive-ui'
const store = useMqttStore()
const msg = useMessage()
const topic = ref('')
const payload = ref('')
const qos = ref(1)
const retain = ref(false)
const activePublishTab = ref('publish')
const formStorageKey = 'mqtt-client.publish-form'
const publishTabStorageKey = 'mqtt-client.publish-active-tab'
let formReady = false
try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topic === 'string') 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 {
localStorage.removeItem(formStorageKey)
}
try {
const savedTab = localStorage.getItem(publishTabStorageKey)
if (savedTab === 'publish' || savedTab === 'history') activePublishTab.value = savedTab
} catch {
localStorage.removeItem(publishTabStorageKey)
}
watch([topic, payload, qos, retain], () => {
if (!formReady) return
localStorage.setItem(
formStorageKey,
JSON.stringify({
topic: topic.value,
payload: payload.value,
qos: qos.value,
retain: retain.value
})
)
})
watch(activePublishTab, (tab) => {
localStorage.setItem(publishTabStorageKey, tab)
})
formReady = true
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 }
]
async function handlePublish() {
const srv = store.activeServer.value
if (!srv) return
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
}
if (!topic.value.trim()) {
msg.warning('请输入目标主题')
return
}
const result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
qos: qos.value,
retain: retain.value
})
if (result) {
msg.success('消息已发布到: ' + topic.value)
}
}
function handleFormatJson() {
try {
const obj = JSON.parse(payload.value)
payload.value = JSON.stringify(obj, null, 2)
msg.info('JSON 已格式化')
} catch {
msg.warning('无效的 JSON 格式')
}
}
async function handleClearHistory() {
await store.clearPublishHistory()
}
</script>
<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="发布消息">
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
}"
>
<div class="publish-form">
<n-form-item class="topic-field" label="目标主题 (Topic)">
<n-input v-model:value="topic" placeholder="例如: sensor/device01/temperature" />
</n-form-item>
<n-space align="center">
<n-form-item label="QoS">
<n-select v-model:value="qos" :options="qosOptions" style="width: 160px" />
</n-form-item>
<n-form-item label="保留消息">
<n-switch v-model:value="retain" />
</n-form-item>
</n-space>
<n-form-item class="payload-field" label="消息内容 (Payload)">
<n-input
v-model:value="payload"
type="textarea"
:resizable="false"
:rows="1"
placeholder='{"temperature": 25.6, "humidity": 68.3}'
/>
</n-form-item>
<n-space>
<n-button type="primary" @click="handlePublish">发布消息</n-button>
<n-button @click="handleFormatJson">格式化 JSON</n-button>
</n-space>
</div>
</n-card>
</n-tab-pane>
<n-tab-pane name="history" tab="发布历史">
<n-card
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
}"
>
<n-space style="margin-bottom: 12px">
<n-button size="small" @click="handleClearHistory">清空</n-button>
</n-space>
<n-empty v-if="store.publishHistory.value.length === 0" 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">
<span class="msg-time">{{ m.time }}</span>
<span class="msg-dir pub">PUB</span>
<span class="msg-topic" :title="m.topic">{{ m.topic }}</span>
<span class="msg-meta">QoS{{ m.qos }}{{ m.retain ? ' R' : '' }}</span>
</div>
<pre class="msg-payload">{{ m.payload }}</pre>
</div>
</div>
</n-card>
</n-tab-pane>
</n-tabs>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
</template>
<style scoped>
.publish-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.publish-view :deep(.n-tabs) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.publish-view :deep(.n-tabs-nav) {
flex-shrink: 0;
}
.publish-view :deep(.n-tabs-pane-wrapper) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.publish-view :deep(.n-tabs-tab-panels),
.publish-view :deep(.n-tabs-pane-wrapper) {
height: 100%;
min-height: 0;
}
.publish-view :deep(.n-tab-pane) {
box-sizing: border-box;
height: 100%;
min-height: 0;
}
.publish-view :deep(.n-tab-pane > .n-card) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.publish-view :deep(.n-card__content) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.publish-view :deep(.n-card__content > div) {
height: 100%;
min-height: 0;
}
.topic-field {
width: 100%;
}
.topic-field :deep(.n-form-item-blank) {
width: 100%;
}
.publish-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.publish-form .payload-field {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.publish-form .payload-field :deep(.n-form-item-blank) {
flex: 1;
min-height: 0;
}
.publish-form .payload-field :deep(.n-input) {
height: 100%;
}
.publish-form .payload-field :deep(.n-input__textarea-el),
.publish-form .payload-field :deep(textarea) {
resize: none !important;
height: 100% !important;
min-height: 0 !important;
font-family: monospace;
}
.msg-log {
background: #1e1e2e;
border-radius: 6px;
padding: 12px;
height: 100%;
max-height: 100%;
box-sizing: border-box;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
line-height: 1.7;
color: #cdd6f4;
}
.msg-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.msg-header {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.msg-time {
color: #6c7086;
flex-shrink: 0;
}
.msg-dir {
flex-shrink: 0;
font-weight: 700;
width: 28px;
}
.msg-dir.pub {
color: #89b4fa;
}
.msg-topic {
color: #f9e2af;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-payload {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
color: #cdd6f4;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg-meta {
color: #6c7086;
font-size: 11px;
flex-shrink: 0;
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script setup>
import { ref, watch } from 'vue'
import { useMqttStore } from '../stores/mqtt.js'
import {
NCard,
NSpace,
NFormItem,
NInput,
NSelect,
NButton,
NEmpty,
NList,
NListItem,
NTag,
NEllipsis,
useMessage
} from 'naive-ui'
const store = useMqttStore()
const msg = useMessage()
const topicInput = ref('')
const qos = ref(2)
const editingTopicId = ref(null)
const editingTopic = ref('')
const editingQos = ref(2)
const qosOptions = [
{ label: 'QoS 0 - 最多一次', value: 0 },
{ label: 'QoS 1 - 至少一次', value: 1 },
{ label: 'QoS 2 - 恰好一次', value: 2 }
]
const formStorageKey = 'mqtt-client.subscribe-form'
let formReady = false
try {
const savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}')
if (typeof savedForm.topicInput === 'string') topicInput.value = savedForm.topicInput
if ([0, 1, 2].includes(savedForm.qos)) qos.value = savedForm.qos
} catch {
localStorage.removeItem(formStorageKey)
}
watch([topicInput, qos], () => {
if (!formReady) return
localStorage.setItem(
formStorageKey,
JSON.stringify({ topicInput: topicInput.value, qos: qos.value })
)
})
formReady = true
async function handleAddTopic() {
const topic = topicInput.value.trim()
if (!topic) {
msg.warning('请输入主题名称')
return
}
const srv = store.activeServer.value
if (!srv) return
if (srv.topics && srv.topics.find((t) => t.topic === topic)) {
msg.warning('该主题已存在')
return
}
const result = await store.addTopic(srv.id, topic, qos.value)
if (result) {
msg.success('主题已添加' + (srv.status === 'connected' ? '并已订阅' : ''))
topicInput.value = ''
}
}
async function handleRemoveTopic(topic) {
const srv = store.activeServer.value
if (!srv) return
await store.removeTopic(srv.id, topic)
msg.info('主题已移除')
}
async function handleToggleSub(topic) {
const srv = store.activeServer.value
if (!srv) return
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
}
const t = srv.topics.find((t) => t.topic === topic)
if (!t) return
if (t.subscribed) {
await store.unsubscribeTopic(srv.id, topic)
msg.info('已取消订阅: ' + topic)
} else {
await store.subscribeTopic(srv.id, topic)
msg.info('已订阅: ' + topic)
}
}
async function handleSubscribeAll() {
const srv = store.activeServer.value
if (!srv) return
if (srv.status !== 'connected') {
msg.warning('请先连接服务器')
return
}
await store.subscribeAllTopics(srv.id)
msg.success('已订阅全部主题')
}
function startEdit(t) {
editingTopicId.value = t.id || t.topic
editingTopic.value = t.topic
editingQos.value = t.qos ?? 2
}
function cancelEdit() {
editingTopicId.value = null
editingTopic.value = ''
editingQos.value = 2
}
async function handleEditSave(t) {
const srv = store.activeServer.value
if (!srv) return
const topicName = editingTopic.value.trim()
if (!topicName) {
msg.warning('请输入主题名称')
return
}
if (
srv.topics.find(
(item) => item.topic === topicName && (item.id || item.topic) !== editingTopicId.value
)
) {
msg.warning('该主题已存在')
return
}
const topicId = t.id || t.topic
const result = await store.updateTopic(srv.id, topicId, topicName, editingQos.value)
if (result) {
msg.success('主题已更新')
cancelEdit()
} else {
msg.error('主题更新失败')
}
}
</script>
<template>
<div v-if="store.activeServer.value" class="subscribe-view">
<!-- 添加主题 -->
<n-card title="添加订阅主题" size="small">
<div class="topic-form">
<n-form-item class="topic-field" label="主题 (Topic)">
<n-input
v-model:value="topicInput"
placeholder="例如: sensor/+/temperature"
@keydown.enter="handleAddTopic"
/>
</n-form-item>
<n-form-item class="qos-field" label="QoS">
<n-select v-model:value="qos" :options="qosOptions" />
</n-form-item>
<n-button class="topic-submit" type="primary" @click="handleAddTopic">
+ 添加订阅
</n-button>
</div>
</n-card>
<!-- 主题列表 -->
<n-card
class="topic-list-card"
title="已订阅主题列表"
size="small"
:content-style="{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden'
}"
>
<template #header-extra>
<n-button size="small" @click="handleSubscribeAll">全部订阅</n-button>
</template>
<n-empty
v-if="!store.activeServer.value.topics || store.activeServer.value.topics.length === 0"
description="暂无订阅主题,请在上方添加"
/>
<div v-else class="topic-list-scroll">
<n-list>
<n-list-item
v-for="t in store.activeServer.value.topics"
:key="t.id || t.topic"
class="topic-item"
>
<template v-if="editingTopicId !== (t.id || t.topic)" #prefix>
<n-tag :type="t.subscribed ? 'success' : 'default'" size="small">
QoS {{ t.qos }}
</n-tag>
</template>
<div v-if="editingTopicId === (t.id || t.topic)" class="topic-edit-row">
<n-input
v-model:value="editingTopic"
size="small"
placeholder="主题名称"
@keydown.enter="handleEditSave(t)"
/>
<n-select
v-model:value="editingQos"
size="small"
:options="qosOptions"
style="width: 160px; flex-shrink: 0"
/>
<n-space :size="4" :wrap="false">
<n-button size="tiny" type="primary" @click="handleEditSave(t)">保存</n-button>
<n-button size="tiny" @click="cancelEdit">取消</n-button>
</n-space>
</div>
<div v-else class="topic-name">
<n-ellipsis>{{ t.topic }}</n-ellipsis>
</div>
<template #suffix>
<n-space
v-if="editingTopicId !== (t.id || t.topic)"
class="topic-actions"
:size="4"
:wrap="false"
>
<n-button size="tiny" @click="startEdit(t)">编辑</n-button>
<n-button
size="tiny"
:type="t.subscribed ? 'error' : 'success'"
@click="handleToggleSub(t.topic)"
>
{{ t.subscribed ? '取消订阅' : '订阅' }}
</n-button>
<n-button size="tiny" type="error" text @click="handleRemoveTopic(t.topic)">
&#10005;
</n-button>
</n-space>
</template>
</n-list-item>
</n-list>
</div>
</n-card>
</div>
<n-empty v-else description="请先在左侧选择一个 MQTT 服务器" />
</template>
<style scoped>
.subscribe-view {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.topic-list-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
margin-top: 12px;
}
.topic-list-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.topic-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
width: 100%;
}
.topic-field {
flex: 1 1 auto;
width: auto;
min-width: 220px;
}
.qos-field {
flex: 0 0 160px;
width: 160px;
}
.topic-submit {
flex: 0 0 auto;
}
.topic-field :deep(.n-form-item-blank),
.qos-field :deep(.n-form-item-blank) {
width: 100%;
}
.topic-item :deep(.n-list-item__main) {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
}
.topic-name {
min-width: 0;
flex: 1;
font-family: monospace;
font-size: 13px;
}
.topic-actions {
flex-shrink: 0;
}
.topic-edit-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
flex: 1;
min-width: 0;
}
.topic-edit-row .n-input {
flex: 1;
min-width: 180px;
}
@media (max-width: 720px) {
.topic-field {
width: 100%;
min-width: 100%;
}
.qos-field {
flex-basis: 160px;
}
}
</style>