470 lines
9.3 KiB
Vue
470 lines
9.3 KiB
Vue
<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';
|
|
|
|
// 全局状态与消息 API
|
|
const store = useMqttStore();
|
|
const msg = useMessage();
|
|
|
|
// 发布表单状态
|
|
const topic = ref('');
|
|
const payload = ref('');
|
|
const qos = ref(1);
|
|
const retain = ref(false);
|
|
|
|
// 当前激活的标签页
|
|
const activePublishTab = ref('publish');
|
|
|
|
// localStorage 持久化 key
|
|
const formStorageKey = 'mqtt-client.publish-form';
|
|
const publishTabStorageKey = 'mqtt-client.publish-active-tab';
|
|
|
|
let formReady = false;
|
|
|
|
// 从 localStorage 恢复发布表单
|
|
try {
|
|
|
|
let savedForm = JSON.parse(localStorage.getItem(formStorageKey) || '{}');
|
|
|
|
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);
|
|
}
|
|
|
|
// 从 localStorage 恢复当前标签页
|
|
try {
|
|
|
|
let 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;
|
|
|
|
// QoS 下拉选项
|
|
const qosOptions = [
|
|
{ label: 'QoS 0 - 最多一次', value: 0 },
|
|
{ label: 'QoS 1 - 至少一次', value: 1 },
|
|
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
|
];
|
|
|
|
/**
|
|
* 发布消息到当前服务器
|
|
*/
|
|
async function handlePublish() {
|
|
|
|
let srv = store.activeServer.value;
|
|
|
|
if (!srv) {
|
|
return;
|
|
}
|
|
|
|
if (srv.status !== 'connected') {
|
|
msg.warning('请先连接服务器');
|
|
return;
|
|
}
|
|
|
|
if (!topic.value.trim()) {
|
|
msg.warning('请输入目标主题');
|
|
return;
|
|
}
|
|
|
|
let result = await store.publishMessage(srv.id, topic.value.trim(), payload.value, {
|
|
qos: qos.value,
|
|
retain: retain.value,
|
|
});
|
|
|
|
if (result) {
|
|
msg.success('消息已发布到: ' + topic.value);
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* 格式化 Payload 为 JSON
|
|
*/
|
|
function handleFormatJson() {
|
|
try {
|
|
|
|
let 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();
|
|
}
|
|
|
|
/**
|
|
* 复制主题到剪贴板
|
|
* @param {string} topic - 主题名称
|
|
*/
|
|
async function copyTopic(topic) {
|
|
try {
|
|
await navigator.clipboard.writeText(topic);
|
|
msg.success('主题已复制');
|
|
} catch {
|
|
msg.error('复制失败');
|
|
}
|
|
}
|
|
</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"
|
|
/>
|
|
</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"
|
|
@click="copyTopic(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 lang="less">
|
|
.publish-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
|
|
:deep(.n-tabs) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.n-tabs-nav) {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
:deep(.n-tabs-pane-wrapper) {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
:deep(.n-tabs-tab-panels),
|
|
:deep(.n-tabs-pane-wrapper) {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.n-tab-pane) {
|
|
box-sizing: border-box;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.n-tab-pane > .n-card) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.n-card__content) {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
|
|
> div {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
.topic-field {
|
|
width: 100%;
|
|
|
|
:deep(.n-form-item-blank) {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.publish-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
|
|
.payload-field {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
:deep(.n-form-item-blank) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
:deep(.n-input) {
|
|
height: 100%;
|
|
}
|
|
|
|
:deep(.n-input__textarea-el),
|
|
:deep(textarea) {
|
|
resize: none !important;
|
|
height: 100% !important;
|
|
min-height: 0 !important;
|
|
font-family: monospace;
|
|
}
|
|
}
|
|
}
|
|
|
|
.msg-log {
|
|
background: var(--frost-color-bg-light);
|
|
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: #333;
|
|
}
|
|
|
|
.msg-entry {
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--frost-shadow-black-6);
|
|
}
|
|
|
|
.msg-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.msg-time {
|
|
color: var(--frost-color-text-tertiary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.msg-dir {
|
|
flex-shrink: 0;
|
|
font-weight: 700;
|
|
width: 28px;
|
|
|
|
&.pub {
|
|
color: var(--frost-info-color);
|
|
}
|
|
}
|
|
|
|
.msg-topic {
|
|
color: var(--frost-color-topic);
|
|
min-width: 0;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
.msg-payload {
|
|
margin: 6px 0 0;
|
|
padding: 8px 10px;
|
|
border-radius: 4px;
|
|
background: var(--frost-shadow-black-4);
|
|
color: #333;
|
|
font: inherit;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.msg-meta {
|
|
color: var(--frost-color-text-tertiary);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|