Compare commits
2 Commits
b23cf4d854
...
515fc2158b
| Author | SHA1 | Date | |
|---|---|---|---|
| 515fc2158b | |||
| a1106d4ce6 |
@@ -9,6 +9,7 @@ import Dashboard from './views/Dashboard.vue';
|
|||||||
import Subscribe from './views/Subscribe.vue';
|
import Subscribe from './views/Subscribe.vue';
|
||||||
import Publish from './views/Publish.vue';
|
import Publish from './views/Publish.vue';
|
||||||
import Messages from './views/Messages.vue';
|
import Messages from './views/Messages.vue';
|
||||||
|
import QuickSend from './views/QuickSend.vue';
|
||||||
|
|
||||||
// 全局状态
|
// 全局状态
|
||||||
const store = useMqttStore();
|
const store = useMqttStore();
|
||||||
@@ -148,6 +149,14 @@ function handleSwitchTab(tab) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane
|
||||||
|
name="quick-send"
|
||||||
|
tab="快速发送"
|
||||||
|
>
|
||||||
|
<div class="tab-content">
|
||||||
|
<QuickSend />
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,17 @@ const loading = ref(false);
|
|||||||
*/
|
*/
|
||||||
let cleanupFns = [];
|
let cleanupFns = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对指定服务器的主题列表按主题名称升序排序
|
||||||
|
* @param {MqttServer} server - 服务器对象
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function sortTopics(server) {
|
||||||
|
if (server && server.topics) {
|
||||||
|
server.topics.sort((a, b) => a.topic.localeCompare(b.topic));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置主进程事件监听器
|
* 设置主进程事件监听器
|
||||||
* 包括状态变化、消息接收、订阅变化等事件
|
* 包括状态变化、消息接收、订阅变化等事件
|
||||||
@@ -172,6 +183,7 @@ function setupListeners() {
|
|||||||
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;
|
||||||
|
sortTopics(srv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +264,7 @@ export function useMqttStore() {
|
|||||||
|
|
||||||
servers.value = data;
|
servers.value = data;
|
||||||
sortServers();
|
sortServers();
|
||||||
|
servers.value.forEach((srv) => sortTopics(srv));
|
||||||
setupListeners();
|
setupListeners();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -355,6 +368,7 @@ export function useMqttStore() {
|
|||||||
let data = await window.api.importServers(serverList);
|
let data = await window.api.importServers(serverList);
|
||||||
servers.value = data;
|
servers.value = data;
|
||||||
sortServers();
|
sortServers();
|
||||||
|
servers.value.forEach((srv) => sortTopics(srv));
|
||||||
if (servers.value.length > 0 && !activeServerId.value) {
|
if (servers.value.length > 0 && !activeServerId.value) {
|
||||||
activeServerId.value = servers.value[0].id;
|
activeServerId.value = servers.value[0].id;
|
||||||
}
|
}
|
||||||
@@ -396,6 +410,7 @@ export function useMqttStore() {
|
|||||||
let srv = servers.value.find((s) => s.id === serverId);
|
let srv = servers.value.find((s) => s.id === serverId);
|
||||||
if (srv) {
|
if (srv) {
|
||||||
srv.topics.push(result);
|
srv.topics.push(result);
|
||||||
|
sortTopics(srv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +432,7 @@ export function useMqttStore() {
|
|||||||
|
|
||||||
if (srv) {
|
if (srv) {
|
||||||
srv.topics = srv.topics.filter((t) => t.topic !== topic);
|
srv.topics = srv.topics.filter((t) => t.topic !== topic);
|
||||||
|
sortTopics(srv);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useMqttStore } from '../stores/mqtt.js';
|
||||||
|
import { NCard, NSpace, NFormItem, NInput, NSelect, NSwitch, NButton, NEmpty, NModal, NForm, NGrid, NGridItem, NCheckbox, useMessage, useDialog } from 'naive-ui';
|
||||||
|
|
||||||
|
// 全局状态与消息 API
|
||||||
|
const store = useMqttStore();
|
||||||
|
const msg = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
// localStorage 持久化 key
|
||||||
|
const storageKey = 'mqtt-client.quick-send-list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} QuickSendItem
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} topic
|
||||||
|
* @property {string} payload
|
||||||
|
* @property {0|1|2} qos
|
||||||
|
* @property {boolean} retain
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 快速发送列表
|
||||||
|
/** @type {import('vue').Ref<QuickSendItem[]>} */
|
||||||
|
const quickList = ref([]);
|
||||||
|
|
||||||
|
// 表单弹窗显隐
|
||||||
|
const showModal = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formId = ref('');
|
||||||
|
const formTopic = ref('');
|
||||||
|
const formPayload = ref('');
|
||||||
|
const formQos = ref(1);
|
||||||
|
const formRetain = ref(false);
|
||||||
|
|
||||||
|
// 从 localStorage 恢复列表
|
||||||
|
try {
|
||||||
|
|
||||||
|
let saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
||||||
|
|
||||||
|
if (Array.isArray(saved)) {
|
||||||
|
quickList.value = saved.filter((item) => {
|
||||||
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表变化时持久化
|
||||||
|
watch(
|
||||||
|
quickList,
|
||||||
|
() => {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(quickList.value));
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// QoS 下拉选项
|
||||||
|
const qosOptions = [
|
||||||
|
{ label: 'QoS 0 - 最多一次', value: 0 },
|
||||||
|
{ label: 'QoS 1 - 至少一次', value: 1 },
|
||||||
|
{ label: 'QoS 2 - 恰好一次', value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 当前是否有选中的服务器
|
||||||
|
const hasActiveServer = computed(() => Boolean(store.activeServer.value));
|
||||||
|
|
||||||
|
// 拖拽排序状态
|
||||||
|
/** @type {import('vue').Ref<number>} */
|
||||||
|
const dragIndex = ref(-1);
|
||||||
|
/** @type {import('vue').Ref<QuickSendItem | null>} */
|
||||||
|
const dragItem = ref(null);
|
||||||
|
|
||||||
|
// 列表容器引用与响应式布局
|
||||||
|
/** @type {import('vue').Ref<HTMLElement | null>} */
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const columnCount = ref(1);
|
||||||
|
/** @type {ResizeObserver | null} */
|
||||||
|
let resizeObserver = null;
|
||||||
|
|
||||||
|
// 批量操作状态
|
||||||
|
/** @type {import('vue').Ref<string[]>} */
|
||||||
|
const selectedIds = ref([]);
|
||||||
|
/** @type {import('vue').Ref<HTMLInputElement | null>} */
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
// 是否已全选
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
return quickList.value.length > 0 && selectedIds.value.length === quickList.value.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 是否有选中项
|
||||||
|
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一 id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateId() {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开新增弹窗
|
||||||
|
*/
|
||||||
|
function handleAdd() {
|
||||||
|
isEdit.value = false;
|
||||||
|
formId.value = '';
|
||||||
|
formTopic.value = '';
|
||||||
|
formPayload.value = '';
|
||||||
|
formQos.value = 1;
|
||||||
|
formRetain.value = false;
|
||||||
|
showModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开编辑弹窗
|
||||||
|
* @param {QuickSendItem} item
|
||||||
|
*/
|
||||||
|
function handleEdit(item) {
|
||||||
|
isEdit.value = true;
|
||||||
|
formId.value = item.id;
|
||||||
|
formTopic.value = item.topic;
|
||||||
|
formPayload.value = item.payload;
|
||||||
|
formQos.value = item.qos;
|
||||||
|
formRetain.value = item.retain;
|
||||||
|
showModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存快速发送项
|
||||||
|
*/
|
||||||
|
function handleSave() {
|
||||||
|
|
||||||
|
let topic = formTopic.value.trim();
|
||||||
|
|
||||||
|
if (!topic) {
|
||||||
|
msg.warning('请输入目标主题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = formPayload.value;
|
||||||
|
let qos = formQos.value;
|
||||||
|
let retain = formRetain.value;
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
|
||||||
|
let idx = quickList.value.findIndex((item) => item.id === formId.value);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
quickList.value[idx] = {
|
||||||
|
...quickList.value[idx],
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
qos,
|
||||||
|
retain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
quickList.value.push({
|
||||||
|
id: generateId(),
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
qos,
|
||||||
|
retain,
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除快速发送项
|
||||||
|
* @param {QuickSendItem} item
|
||||||
|
*/
|
||||||
|
function handleDelete(item) {
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除主题 "${item.topic}" 的快速消息吗?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
quickList.value = quickList.value.filter((i) => i.id !== item.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始拖拽
|
||||||
|
* @param {DragEvent} e
|
||||||
|
* @param {QuickSendItem} item
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
function handleDragStart(e, item, index) {
|
||||||
|
dragIndex.value = index;
|
||||||
|
dragItem.value = item;
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽经过目标项
|
||||||
|
* @param {DragEvent} e
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
function handleDragOver(e, index) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
if (dragIndex.value === -1 || dragIndex.value === index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 放置拖拽项
|
||||||
|
* @param {DragEvent} e
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
function handleDrop(e, index) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex.value === -1 || dragIndex.value === index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = quickList.value[dragIndex.value];
|
||||||
|
|
||||||
|
quickList.value.splice(dragIndex.value, 1);
|
||||||
|
quickList.value.splice(index, 0, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽结束
|
||||||
|
*/
|
||||||
|
function handleDragEnd() {
|
||||||
|
dragIndex.value = -1;
|
||||||
|
dragItem.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息到当前服务器
|
||||||
|
* @param {QuickSendItem} item
|
||||||
|
*/
|
||||||
|
async function handleSend(item) {
|
||||||
|
|
||||||
|
let srv = store.activeServer.value;
|
||||||
|
|
||||||
|
if (!srv) {
|
||||||
|
msg.warning('请先在左侧选择一个 MQTT 服务器');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srv.status !== 'connected') {
|
||||||
|
msg.warning('请先连接服务器');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await store.publishMessage(srv.id, item.topic, item.payload, {
|
||||||
|
qos: item.qos,
|
||||||
|
retain: item.retain,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
msg.success('消息已发布到: ' + item.topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制主题到剪贴板
|
||||||
|
* @param {string} topic
|
||||||
|
*/
|
||||||
|
async function copyTopic(topic) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(topic);
|
||||||
|
msg.success('主题已复制');
|
||||||
|
} catch {
|
||||||
|
msg.error('复制失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化消息内容为 JSON
|
||||||
|
*/
|
||||||
|
function handleFormatJson() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
let obj = JSON.parse(formPayload.value);
|
||||||
|
|
||||||
|
formPayload.value = JSON.stringify(obj, null, 2);
|
||||||
|
msg.info('JSON 已格式化');
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
msg.warning('无效的 JSON 格式');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选中或取消选中单个快速消息
|
||||||
|
* @param {string} id
|
||||||
|
* @param {boolean} checked
|
||||||
|
*/
|
||||||
|
function handleSelectItem(id, checked) {
|
||||||
|
if (checked) {
|
||||||
|
selectedIds.value.push(id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换全选状态
|
||||||
|
*/
|
||||||
|
function handleToggleSelectAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
selectedIds.value = [];
|
||||||
|
} else {
|
||||||
|
selectedIds.value = quickList.value.map((item) => item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除选中的快速消息
|
||||||
|
*/
|
||||||
|
function handleBatchDelete() {
|
||||||
|
|
||||||
|
if (!hasSelected.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认批量删除',
|
||||||
|
content: `确定要删除选中的 ${selectedIds.value.length} 条快速消息吗?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
quickList.value = quickList.value.filter((item) => !selectedIds.value.includes(item.id));
|
||||||
|
selectedIds.value = [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出快速消息列表为 JSON 文件
|
||||||
|
*/
|
||||||
|
function handleExport() {
|
||||||
|
|
||||||
|
let data = quickList.value;
|
||||||
|
let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
let url = URL.createObjectURL(blob);
|
||||||
|
let a = document.createElement('a');
|
||||||
|
|
||||||
|
a.href = url;
|
||||||
|
a.download = `frost-mqtt-quick-send-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
msg.success('快速消息已导出');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发文件导入点击
|
||||||
|
*/
|
||||||
|
function handleImportClick() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理导入的 JSON 文件
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
function handleImportFile(event) {
|
||||||
|
|
||||||
|
let file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
let data = JSON.parse(String(e.target?.result || ''));
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
msg.error('导入失败: 文件内容不是数组');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validItems = data.filter((item) => {
|
||||||
|
return item && typeof item.id === 'string' && typeof item.topic === 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
msg.warning('未找到有效的快速消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.create({
|
||||||
|
title: '选择导入方式',
|
||||||
|
content: `检测到 ${validItems.length} 条快速消息,请选择导入方式:`,
|
||||||
|
positiveText: '追加',
|
||||||
|
negativeText: '覆盖',
|
||||||
|
class: 'quick-send-dialog',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
let existingIds = new Set(quickList.value.map((item) => item.id));
|
||||||
|
let itemsToAdd = validItems.map((item) => {
|
||||||
|
if (existingIds.has(item.id)) {
|
||||||
|
return { ...item, id: generateId() };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
quickList.value.push(...itemsToAdd);
|
||||||
|
selectedIds.value = [];
|
||||||
|
msg.success(`已追加 ${itemsToAdd.length} 条快速消息`);
|
||||||
|
},
|
||||||
|
onNegativeClick: () => {
|
||||||
|
quickList.value = validItems;
|
||||||
|
selectedIds.value = [];
|
||||||
|
msg.success(`已覆盖为导入的 ${validItems.length} 条快速消息`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
msg.error('导入失败: ' + (err.message || '未知错误'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
msg.error('读取文件失败');
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumnCount(width) {
|
||||||
|
if (width >= 1520) {
|
||||||
|
columnCount.value = 3;
|
||||||
|
} else if (width >= 720) {
|
||||||
|
columnCount.value = 2;
|
||||||
|
} else {
|
||||||
|
columnCount.value = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
updateColumnCount(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver.observe(containerRef.value);
|
||||||
|
updateColumnCount(containerRef.value.clientWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="quick-send-view"
|
||||||
|
>
|
||||||
|
<n-card
|
||||||
|
size="small"
|
||||||
|
:content-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<n-space
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleAdd"
|
||||||
|
>新增快速消息</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="quickList.length === 0"
|
||||||
|
@click="handleToggleSelectAll"
|
||||||
|
>{{ isAllSelected ? '取消全选' : '全选' }}</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
ghost
|
||||||
|
:disabled="!hasSelected"
|
||||||
|
@click="handleBatchDelete"
|
||||||
|
>批量删除</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
@click="handleImportClick"
|
||||||
|
>导入</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
@click="handleExport"
|
||||||
|
>导出</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-empty
|
||||||
|
v-if="quickList.length === 0"
|
||||||
|
description="暂无快速发送消息,点击上方按钮添加"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="quick-list"
|
||||||
|
:class="`columns-${columnCount}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in quickList"
|
||||||
|
:key="item.id"
|
||||||
|
class="quick-item"
|
||||||
|
:class="{ dragging: dragIndex === index }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart($event, item, index)"
|
||||||
|
@dragover.prevent="handleDragOver($event, index)"
|
||||||
|
@drop.prevent="handleDrop($event, index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
>
|
||||||
|
<div class="quick-header">
|
||||||
|
<n-checkbox
|
||||||
|
class="quick-checkbox"
|
||||||
|
:checked="selectedIds.includes(item.id)"
|
||||||
|
@update:checked="(checked) => handleSelectItem(item.id, checked)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="quick-topic-wrap">
|
||||||
|
<span class="quick-dir pub">PUB</span>
|
||||||
|
<span
|
||||||
|
class="quick-topic"
|
||||||
|
:title="item.topic"
|
||||||
|
@click="copyTopic(item.topic)"
|
||||||
|
>{{ item.topic }}</span>
|
||||||
|
<span class="quick-meta">QoS{{ item.qos }}{{ item.retain ? ' R' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
:disabled="!hasActiveServer"
|
||||||
|
type="primary"
|
||||||
|
@click="handleSend(item)"
|
||||||
|
>发送</n-button>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
@click="handleEdit(item)"
|
||||||
|
>编辑</n-button>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
type="error"
|
||||||
|
ghost
|
||||||
|
@click="handleDelete(item)"
|
||||||
|
>删除</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-if="item.payload"
|
||||||
|
class="quick-payload"
|
||||||
|
>{{ item.payload }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleImportFile"
|
||||||
|
>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showModal"
|
||||||
|
class="quick-send-modal"
|
||||||
|
:title="isEdit ? '编辑快速消息' : '新增快速消息'"
|
||||||
|
preset="card"
|
||||||
|
style="width: 520px; max-width: 90vw"
|
||||||
|
:bordered="false"
|
||||||
|
:segmented="{ content: true }"
|
||||||
|
>
|
||||||
|
<n-form
|
||||||
|
label-placement="top"
|
||||||
|
:show-feedback="false"
|
||||||
|
>
|
||||||
|
<n-form-item label="目标主题">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formTopic"
|
||||||
|
placeholder="例如: sensor/device01/temperature"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-grid
|
||||||
|
cols="2"
|
||||||
|
x-gap="12"
|
||||||
|
>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item label="QoS">
|
||||||
|
<n-select
|
||||||
|
v-model:value="formQos"
|
||||||
|
:options="qosOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item label="保留消息">
|
||||||
|
<n-switch v-model:value="formRetain" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
<n-form-item label="消息内容">
|
||||||
|
<div class="payload-field">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formPayload"
|
||||||
|
type="textarea"
|
||||||
|
:resizable="false"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="输入要发送的消息内容"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
@click="handleFormatJson"
|
||||||
|
>格式化 JSON</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showModal = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleSave"
|
||||||
|
>保存</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-send-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-view :deep(.n-card) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-view :deep(.n-card__content) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 4px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-list.columns-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-list.columns-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-list.columns-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-field :deep(.n-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-field .n-button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-item {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-item.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-checkbox :deep(.n-checkbox-box) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-topic-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-dir {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-dir.pub {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-topic {
|
||||||
|
color: #d25a00;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-topic:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-meta {
|
||||||
|
color: #999;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-payload {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: #333;
|
||||||
|
font: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-modal .n-form-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-modal .n-form-item__label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-modal .n-form-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-modal .n-grid {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-send-dialog .n-dialog__icon {
|
||||||
|
color: #0ea5a0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user