feat: 阿里云 MQTT 连接预设、连接分组与 MQTT 3.1.1 支持
- 后端引入 rumqttc v4 客户端,支持 MQTT 3.1.1 连接/发布/订阅,协议版本随连接下发 - 连接表单:阿里云预设管理(分组、导入导出、去抖自动保存),退出云模式需确认,mqtts 时端口跟随 8883 - 连接列表:分组列就地编辑(输入即创建),空分组提示优化 - 导入按内容去重并提示跳过数量,导出提示含明文密钥 - 调试日志密码脱敏;修复多处取消弹窗的 unhandled rejection
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { MqttConnection } from '../../types/mqtt'
|
||||
import { defaultConnection } from '../../types/mqtt'
|
||||
import { mqttTestConnection } from '../../stores/useMqttBridge'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { MqttConnection, AliyunPreset } from '../../types/mqtt'
|
||||
import { defaultConnection, defaultAliyunPreset, generateAliyunConnection } from '../../types/mqtt'
|
||||
import { mqttTestConnection, mqttReadDebugLog } from '../../stores/useMqttBridge'
|
||||
import { useConnections } from '../../stores/useConnections'
|
||||
import { useAliyunPresets } from '../../stores/useAliyunPresets'
|
||||
import { toConnectionDto } from '../../types/mqtt'
|
||||
|
||||
const { getGroups: getConnectionGroups } = useConnections()
|
||||
const { presets, add: addPreset, update: updatePreset, remove: removePreset, getGroups: getPresetGroups, renameGroup: renamePresetGroup, deleteGroup: deletePresetGroup, exportPresets, importPresets } = useAliyunPresets()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
connection: MqttConnection | null
|
||||
@@ -18,24 +23,299 @@ const emit = defineEmits<{
|
||||
|
||||
const form = ref<MqttConnection>(defaultConnection())
|
||||
const testing = ref(false)
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// ─── 连接分组 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const connectionGroupOptions = computed(() => {
|
||||
const existing = getConnectionGroups()
|
||||
const opts = [{ label: '未分组', value: '' }]
|
||||
for (const g of existing) opts.push({ label: g, value: g })
|
||||
return opts
|
||||
})
|
||||
|
||||
// ─── 阿里云预设 ─────────────────────────────────────────────────────────────
|
||||
|
||||
type CloudPreset = '' | 'aliyun'
|
||||
const cloudPreset = ref<CloudPreset>('')
|
||||
const selectedPresetId = ref<string>('')
|
||||
const aliyunPreset = ref<AliyunPreset | null>(null)
|
||||
|
||||
// 预设分组
|
||||
const presetGroupFilter = ref<string | null>(null)
|
||||
const presetGroups = computed(() => getPresetGroups())
|
||||
|
||||
const filteredPresets = computed(() => {
|
||||
if (presetGroupFilter.value === null) return presets.value
|
||||
if (presetGroupFilter.value === '__ungrouped__') {
|
||||
return presets.value.filter((p) => !p.group)
|
||||
}
|
||||
return presets.value.filter((p) => p.group === presetGroupFilter.value)
|
||||
})
|
||||
|
||||
const presetGroupCounts = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const p of presets.value) {
|
||||
const key = p.group || '__ungrouped__'
|
||||
map[key] = (map[key] || 0) + 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 预设内容签名,用于判断用户是否真的修改过(避免把空白/未改动的预设写进库) */
|
||||
function presetSignature(p: AliyunPreset): string {
|
||||
return JSON.stringify([
|
||||
p.name, p.group, p.instanceId, p.authMode, p.accessKeyId,
|
||||
p.accessKeySecret, p.groupId, p.deviceId,
|
||||
p.deviceAccessKeyId, p.deviceAccessKeySecret,
|
||||
])
|
||||
}
|
||||
let presetBaseline = ''
|
||||
|
||||
function markPresetBaseline() {
|
||||
if (aliyunPreset.value) presetBaseline = presetSignature(aliyunPreset.value)
|
||||
}
|
||||
|
||||
function handleSelectPreset(id: string) {
|
||||
const found = id ? presets.value.find((p) => p.id === id) : undefined
|
||||
if (found) {
|
||||
selectedPresetId.value = id
|
||||
aliyunPreset.value = { ...found }
|
||||
} else {
|
||||
// 清空选择 → 空白预设,字段保持可编辑
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
}
|
||||
markPresetBaseline()
|
||||
applyAliyunPreset()
|
||||
}
|
||||
|
||||
function handleNewPreset() {
|
||||
// 不强制先命名:直接给空白预设,填入有效内容后自动入库
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = {
|
||||
...defaultAliyunPreset(),
|
||||
group: presetGroupFilter.value && presetGroupFilter.value !== '__ungrouped__' ? presetGroupFilter.value : '',
|
||||
}
|
||||
markPresetBaseline()
|
||||
}
|
||||
|
||||
async function handleDeletePreset() {
|
||||
if (!selectedPresetId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除预设 "${aliyunPreset.value?.name || '未命名'}"?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
removePreset(selectedPresetId.value)
|
||||
selectedPresetId.value = ''
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
markPresetBaseline()
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function handleRenamePresetGroup(group: string) {
|
||||
const { value } = await ElMessageBox.prompt('重命名预设分组', '重命名', {
|
||||
inputValue: group,
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '名称不能为空',
|
||||
}).catch(() => ({ value: undefined }))
|
||||
if (value && value !== group) {
|
||||
renamePresetGroup(group, value)
|
||||
if (presetGroupFilter.value === group) presetGroupFilter.value = value
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePresetGroup(group: string) {
|
||||
const count = presetGroupCounts.value[group] || 0
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除分组 "${group}"?组内 ${count} 个预设将变为未分组`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deletePresetGroup(group)
|
||||
if (presetGroupFilter.value === group) presetGroupFilter.value = null
|
||||
}
|
||||
|
||||
// ─── 连接监听 ──────────────────────────────────────────────────────────────
|
||||
|
||||
watch(
|
||||
() => props.connection,
|
||||
(conn) => {
|
||||
if (conn) {
|
||||
form.value = { ...conn }
|
||||
if (conn.host.includes('.mqtt.aliyuncs.com') && conn.username.startsWith('Signature|')) {
|
||||
cloudPreset.value = 'aliyun'
|
||||
parseAliyunFromConnection(conn)
|
||||
} else {
|
||||
cloudPreset.value = ''
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
} else {
|
||||
form.value = defaultConnection()
|
||||
cloudPreset.value = ''
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function parseAliyunFromConnection(conn: MqttConnection) {
|
||||
const hostMatch = conn.host.match(/^(.+)\.mqtt\.aliyuncs\.com$/)
|
||||
const userMatch = conn.username.match(/^Signature\|(.+)\|(.+)$/)
|
||||
const clientMatch = conn.clientId.match(/^(.+)@@@(.+)$/)
|
||||
|
||||
// 尝试匹配已有预设
|
||||
const match = presets.value.find((p) =>
|
||||
p.instanceId === (hostMatch?.[1] ?? '') &&
|
||||
p.accessKeyId === (userMatch?.[1] ?? '') &&
|
||||
p.groupId === (clientMatch?.[1] ?? ''),
|
||||
)
|
||||
|
||||
if (match) {
|
||||
selectedPresetId.value = match.id
|
||||
aliyunPreset.value = { ...match }
|
||||
} else {
|
||||
aliyunPreset.value = {
|
||||
...defaultAliyunPreset(),
|
||||
name: '',
|
||||
instanceId: hostMatch?.[1] ?? '',
|
||||
accessKeyId: userMatch?.[1] ?? '',
|
||||
groupId: clientMatch?.[1] ?? '',
|
||||
deviceId: clientMatch?.[2] ?? '',
|
||||
}
|
||||
}
|
||||
markPresetBaseline()
|
||||
}
|
||||
|
||||
async function handlePresetChange(val: CloudPreset | undefined) {
|
||||
if (val === 'aliyun') {
|
||||
if (!aliyunPreset.value) {
|
||||
aliyunPreset.value = defaultAliyunPreset()
|
||||
markPresetBaseline()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 退出阿里云模式:已生成过连接参数时先确认,保留名称和分组
|
||||
if (form.value.host.endsWith('.mqtt.aliyuncs.com')) {
|
||||
try {
|
||||
await ElMessageBox.confirm('退出阿里云模式将清空已生成的连接参数(保留名称和分组)', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
cloudPreset.value = 'aliyun'
|
||||
return
|
||||
}
|
||||
}
|
||||
const { name, group } = form.value
|
||||
form.value = { ...defaultConnection(), id: form.value.id, name, group }
|
||||
aliyunPreset.value = null
|
||||
selectedPresetId.value = ''
|
||||
}
|
||||
|
||||
async function applyAliyunPreset() {
|
||||
const p = aliyunPreset.value
|
||||
if (!p || !p.instanceId || !p.accessKeyId || !p.accessKeySecret || !p.groupId) return
|
||||
|
||||
const generated = await generateAliyunConnection(p)
|
||||
|
||||
// 用户手动改过连接名时不覆盖(自动名以“阿里云 ”开头)
|
||||
const autoName = p.name || `阿里云 ${p.groupId}`
|
||||
const keepName = form.value.name !== '' && form.value.name !== autoName && !form.value.name.startsWith('阿里云 ')
|
||||
|
||||
form.value = {
|
||||
...form.value,
|
||||
...generated,
|
||||
port: form.value.ssl ? 8883 : 1883,
|
||||
name: keepName ? form.value.name : autoName,
|
||||
}
|
||||
}
|
||||
|
||||
function persistPresetIfChanged() {
|
||||
const p = aliyunPreset.value
|
||||
if (!p || cloudPreset.value !== 'aliyun') return
|
||||
if (presetSignature(p) === presetBaseline) return
|
||||
presetBaseline = presetSignature(p)
|
||||
if (selectedPresetId.value) {
|
||||
updatePreset(selectedPresetId.value, p)
|
||||
} else if (p.name || p.instanceId || p.groupId || p.accessKeyId) {
|
||||
// 空白预设填入有效内容后才入库
|
||||
const added = addPreset({ ...p })
|
||||
selectedPresetId.value = added.id
|
||||
}
|
||||
}
|
||||
|
||||
// 预设内容变化:去抖后自动入库 + 重新生成连接参数
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(aliyunPreset, () => {
|
||||
if (cloudPreset.value !== 'aliyun' || !aliyunPreset.value) return
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = undefined
|
||||
persistPresetIfChanged()
|
||||
applyAliyunPreset()
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
async function flushPendingPreset() {
|
||||
if (!persistTimer) return
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = undefined
|
||||
persistPresetIfChanged()
|
||||
await applyAliyunPreset()
|
||||
}
|
||||
|
||||
// 阿里云模式下切换 TLS 时端口跟随(1883 / 8883)
|
||||
watch(() => form.value.ssl, (ssl) => {
|
||||
if (cloudPreset.value === 'aliyun' && form.value.host.endsWith('.mqtt.aliyuncs.com')) {
|
||||
form.value.port = ssl ? 8883 : 1883
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 预设导入导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleExportPresets() {
|
||||
const json = exportPresets()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mqttx_presets_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${presets.value.length} 个预设(含明文密钥,请妥善保管)`)
|
||||
}
|
||||
|
||||
function handleImportPresetsClick() {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportPresetsFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const { imported, skipped } = importPresets(text)
|
||||
if (imported === 0 && skipped > 0) {
|
||||
ElMessage.warning(`全部 ${skipped} 个预设与现有内容重复,未导入`)
|
||||
} else {
|
||||
ElMessage.success(skipped > 0 ? `导入 ${imported} 个预设,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个预设`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(`导入失败: ${e}`)
|
||||
} finally {
|
||||
if (importInput.value) importInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 其他 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function generateClientId() {
|
||||
form.value.clientId = `mqttx_${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
async function handleSave() {
|
||||
await flushPendingPreset()
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入连接名称')
|
||||
return
|
||||
@@ -57,6 +337,7 @@ function handleCancel() {
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
await flushPendingPreset()
|
||||
if (!form.value.host.trim()) {
|
||||
ElMessage.warning('请输入主机地址')
|
||||
return
|
||||
@@ -66,12 +347,24 @@ async function handleTestConnection() {
|
||||
const msg = await mqttTestConnection(toConnectionDto(form.value))
|
||||
ElMessage.success(msg)
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e.message : String(e)
|
||||
ElMessage.error(err)
|
||||
ElMessage.error(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDebugLog() {
|
||||
try {
|
||||
const log = await mqttReadDebugLog()
|
||||
ElMessageBox.alert(
|
||||
log || '暂无调试日志',
|
||||
'MQTT 调试日志',
|
||||
{ type: 'info', customStyle: { whiteSpace: 'pre-wrap', maxWidth: '800px' }, confirmButtonText: '关闭' },
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error('读取日志失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,9 +381,148 @@ async function handleTestConnection() {
|
||||
label-position="top"
|
||||
size="small"
|
||||
>
|
||||
<!-- 云厂商预设 -->
|
||||
<el-form-item label="云厂商预设">
|
||||
<el-select
|
||||
v-model="cloudPreset"
|
||||
placeholder="无"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handlePresetChange"
|
||||
>
|
||||
<el-option value="aliyun" label="阿里云 MQTT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 阿里云预设配置 -->
|
||||
<template v-if="cloudPreset === 'aliyun'">
|
||||
<div class="section-title">
|
||||
<span>阿里云 MQTT 配置</span>
|
||||
<div class="section-actions">
|
||||
<span class="autosave-hint">修改自动保存</span>
|
||||
<el-button size="small" @click="handleImportPresetsClick">导入预设</el-button>
|
||||
<el-button size="small" :disabled="presets.length === 0" @click="handleExportPresets">导出预设</el-button>
|
||||
</div>
|
||||
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportPresetsFile" />
|
||||
</div>
|
||||
|
||||
<!-- 预设分组筛选 -->
|
||||
<div v-if="presetGroups.length > 0" class="preset-group-tabs">
|
||||
<span
|
||||
:class="['preset-tab', presetGroupFilter === null && 'active']"
|
||||
@click="presetGroupFilter = null"
|
||||
>全部</span>
|
||||
<span
|
||||
:class="['preset-tab', presetGroupFilter === '__ungrouped__' && 'active']"
|
||||
@click="presetGroupFilter = '__ungrouped__'"
|
||||
>未分组</span>
|
||||
<span
|
||||
v-for="g in presetGroups"
|
||||
:key="g"
|
||||
:class="['preset-tab', presetGroupFilter === g && 'active']"
|
||||
@click="presetGroupFilter = g"
|
||||
>
|
||||
{{ g }}
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenamePresetGroup(g) : handleDeletePresetGroup(g)">
|
||||
<span class="preset-tab-menu" @click.stop>…</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 预设选择 -->
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="选择预设">
|
||||
<el-select
|
||||
:model-value="selectedPresetId"
|
||||
placeholder="选择已保存的预设"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="(val: string) => handleSelectPreset(val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="p in filteredPresets"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
:label="p.name || `${p.instanceId} / ${p.groupId}`"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8" style="display: flex; align-items: flex-end; gap: 4px; padding-bottom: 18px;">
|
||||
<el-button size="small" @click="handleNewPreset">新建</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!selectedPresetId" @click="handleDeletePreset">删除</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="aliyunPreset" label="预设名称(可选)">
|
||||
<el-input v-model="aliyunPreset.name" placeholder="如:生产环境-门禁设备" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="aliyunPreset">
|
||||
<el-form-item v-if="presetGroups.length > 0 || aliyunPreset.group" label="预设分组">
|
||||
<el-select v-model="aliyunPreset.group" placeholder="未分组" clearable style="width: 100%">
|
||||
<el-option v-for="g in presetGroups" :key="g" :label="g" :value="g" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="实例 ID">
|
||||
<el-input v-model="aliyunPreset.instanceId" placeholder="post-cn-xxx" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Group ID">
|
||||
<el-input v-model="aliyunPreset.groupId" placeholder="GID-prod" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key ID">
|
||||
<el-input v-model="aliyunPreset.accessKeyId" placeholder="LTAI4xxx" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key Secret">
|
||||
<el-input
|
||||
v-model="aliyunPreset.accessKeySecret"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="K24iRxxx"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="设备 ID">
|
||||
<el-input v-model="aliyunPreset.deviceId" placeholder="device_xxx">
|
||||
<template #append>
|
||||
<el-button @click="aliyunPreset!.deviceId = `device_${Math.random().toString(36).slice(2, 10)}`">
|
||||
重新生成
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider border-style="dashed" />
|
||||
</template>
|
||||
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="给连接起个名字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="form.group" placeholder="未分组" clearable style="width: 100%">
|
||||
<el-option v-for="opt in connectionGroupOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="协议">
|
||||
@@ -107,126 +539,127 @@ async function handleTestConnection() {
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="端口">
|
||||
<el-input-number
|
||||
v-model="form.port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="(可选)" />
|
||||
<el-input v-model="form.username" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="(可选)"
|
||||
/>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="Client ID">
|
||||
<el-input v-model="form.clientId" placeholder="自动生成即可">
|
||||
<template #append>
|
||||
<el-button @click="generateClientId">重新生成</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-divider border-style="dashed" />
|
||||
<div class="section-title">MQTT v5 选项</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Clean Start">
|
||||
<el-switch v-model="form.cleanStart" />
|
||||
<el-col :span="16">
|
||||
<el-form-item label="Client ID">
|
||||
<el-input v-model="form.clientId" placeholder="客户端 ID">
|
||||
<template #append>
|
||||
<el-button @click="generateClientId">随机生成</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="协议版本">
|
||||
<el-select v-model="form.protocolVersion" style="width: 100%">
|
||||
<el-option value="5.0" label="MQTT 5.0" />
|
||||
<el-option value="3.1.1" label="MQTT 3.1.1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- MQTT 选项 -->
|
||||
<div class="section-title">
|
||||
{{ form.protocolVersion === '5.0' ? 'MQTT v5 选项' : 'MQTT 选项' }}
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="form.protocolVersion === '5.0' ? 'Clean Start' : 'Clean Session'">
|
||||
<el-switch v-model="form.cleanStart" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.protocolVersion === '5.0'" :span="8">
|
||||
<el-form-item label="Session Expiry">
|
||||
<el-input-number
|
||||
v-model="form.sessionExpiryInterval"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="unit">秒</span>
|
||||
<el-input-number v-model="form.sessionExpiryInterval" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Keep Alive">
|
||||
<el-input-number
|
||||
v-model="form.keepAlive"
|
||||
:min="10"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="unit">秒</span>
|
||||
<el-input-number v-model="form.keepAlive" :min="0" :max="65535" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-row v-if="form.protocolVersion === '5.0'" :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Receive Max">
|
||||
<el-input-number
|
||||
v-model="form.receiveMaximum"
|
||||
:min="0"
|
||||
:value-on-clear="null"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-form-item label="Receive Maximum">
|
||||
<el-input-number v-model="form.receiveMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Max Packet Size">
|
||||
<el-input-number
|
||||
v-model="form.maximumPacketSize"
|
||||
:min="0"
|
||||
:value-on-clear="null"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-input-number v-model="form.maximumPacketSize" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Topic Alias Max">
|
||||
<el-input-number
|
||||
v-model="form.topicAliasMaximum"
|
||||
:min="0"
|
||||
:value-on-clear="null"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-input-number v-model="form.topicAliasMaximum" :min="0" :value-on-clear="null" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
@click="handleTestConnection"
|
||||
>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%;">
|
||||
<el-button type="info" link @click="handleViewDebugLog">查看调试日志</el-button>
|
||||
<div>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button :loading="testing" :disabled="testing" @click="handleTestConnection">测试连接</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.section-actions { display: flex; align-items: center; gap: 4px; font-weight: normal; }
|
||||
.autosave-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
|
||||
|
||||
.preset-group-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.unit {
|
||||
.preset-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
||||
.preset-tab:hover { background: var(--el-fill-color); }
|
||||
.preset-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
|
||||
.preset-tab-menu { opacity: 0.5; font-size: 14px; }
|
||||
.preset-tab-menu:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { MqttConnection } from '../../types/mqtt'
|
||||
import { useConnections } from '../../stores/useConnections'
|
||||
@@ -7,18 +7,85 @@ import { toConnectionDto } from '../../types/mqtt'
|
||||
import { mqttConnect, mqttDisconnect } from '../../stores/useMqttBridge'
|
||||
import ConnectionForm from './ConnectionForm.vue'
|
||||
|
||||
const { connections, add, update, remove, updateStatus } = useConnections()
|
||||
const {
|
||||
connections,
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
updateStatus,
|
||||
getGroups,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportConnections,
|
||||
importConnections,
|
||||
} = useConnections()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConnection = ref<MqttConnection | null>(null)
|
||||
const selectedGroup = ref<string | null>(null) // null = 全部
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// ─── Connection actions ────────────────────────────────────────────────────────
|
||||
// ─── 分组 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const groups = computed(() => getGroups())
|
||||
|
||||
const filteredConnections = computed(() => {
|
||||
if (selectedGroup.value === null) return connections.value
|
||||
if (selectedGroup.value === '__ungrouped__') {
|
||||
return connections.value.filter((c) => !c.group)
|
||||
}
|
||||
return connections.value.filter((c) => c.group === selectedGroup.value)
|
||||
})
|
||||
|
||||
const groupCounts = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const c of connections.value) {
|
||||
const key = c.group || '__ungrouped__'
|
||||
map[key] = (map[key] || 0) + 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 列表内就地修改连接分组(清空 = 未分组,输入新名称 = 直接创建分组) */
|
||||
function handleGroupChange(conn: MqttConnection, group: string | undefined) {
|
||||
update(conn.id, { group: group || '' })
|
||||
}
|
||||
|
||||
async function handleRenameGroup(group: string) {
|
||||
const { value } = await ElMessageBox.prompt('重命名分组', '重命名', {
|
||||
inputValue: group,
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: '分组名称不能为空',
|
||||
}).catch(() => ({ value: undefined }))
|
||||
if (value && value !== group) {
|
||||
renameGroup(group, value)
|
||||
if (selectedGroup.value === group) selectedGroup.value = value
|
||||
ElMessage.success('分组已重命名')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(group: string) {
|
||||
const count = groupCounts.value[group] || 0
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除分组 "${group}"?组内 ${count} 个连接将变为未分组`,
|
||||
'确认删除',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deleteGroup(group)
|
||||
if (selectedGroup.value === group) selectedGroup.value = null
|
||||
ElMessage.success('分组已删除')
|
||||
}
|
||||
|
||||
// ─── 连接操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleConnect(conn: MqttConnection) {
|
||||
try {
|
||||
updateStatus(conn.id, 'connecting')
|
||||
await mqttConnect(toConnectionDto(conn))
|
||||
// Status will be updated by the event listener
|
||||
} catch (e: any) {
|
||||
updateStatus(conn.id, 'error', String(e))
|
||||
ElMessage.error(`连接失败: ${e}`)
|
||||
@@ -35,7 +102,7 @@ async function handleDisconnect(conn: MqttConnection) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
// ─── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleAdd() {
|
||||
editingConnection.value = null
|
||||
@@ -49,14 +116,11 @@ function handleEdit(conn: MqttConnection) {
|
||||
|
||||
function handleSave(data: MqttConnection) {
|
||||
if (editingConnection.value) {
|
||||
// Update existing
|
||||
update(editingConnection.value.id, data)
|
||||
ElMessage.success('连接已更新')
|
||||
} else {
|
||||
// Add new
|
||||
const newConn = { ...data, id: crypto.randomUUID() }
|
||||
add()
|
||||
// Replace the default with our data
|
||||
const idx = connections.value.length - 1
|
||||
connections.value[idx] = { ...connections.value[idx], ...newConn }
|
||||
ElMessage.success('连接已创建')
|
||||
@@ -79,31 +143,59 @@ async function handleDelete(conn: MqttConnection) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status helpers ────────────────────────────────────────────────────────────
|
||||
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleExport() {
|
||||
const json = exportConnections()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mqttx_connections_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${connections.value.length} 个连接(含明文密码,请妥善保管)`)
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const { imported, skipped } = importConnections(text)
|
||||
if (imported === 0 && skipped > 0) {
|
||||
ElMessage.warning(`全部 ${skipped} 个连接与现有内容重复,未导入`)
|
||||
} else {
|
||||
ElMessage.success(skipped > 0 ? `导入 ${imported} 个连接,跳过 ${skipped} 个重复` : `成功导入 ${imported} 个连接`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(`导入失败: ${e}`)
|
||||
} finally {
|
||||
if (importInput.value) importInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function statusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return '已连接'
|
||||
case 'connecting':
|
||||
return '连接中...'
|
||||
case 'error':
|
||||
return '错误'
|
||||
default:
|
||||
return '已断开'
|
||||
case 'connected': return '已连接'
|
||||
case 'connecting': return '连接中...'
|
||||
case 'error': return '错误'
|
||||
default: return '已断开'
|
||||
}
|
||||
}
|
||||
|
||||
function statusType(status: string): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'success'
|
||||
case 'connecting':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
case 'connected': return 'success'
|
||||
case 'connecting': return 'warning'
|
||||
case 'error': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -112,21 +204,78 @@ function statusType(status: string): string {
|
||||
<div class="connection-list">
|
||||
<div class="list-header">
|
||||
<h3>连接管理</h3>
|
||||
<el-button type="primary" size="small" @click="handleAdd">
|
||||
+ 新增连接
|
||||
</el-button>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="handleImportClick">导入</el-button>
|
||||
<el-button size="small" :disabled="connections.length === 0" @click="handleExport">导出</el-button>
|
||||
<el-button type="primary" size="small" @click="handleAdd">+ 新增连接</el-button>
|
||||
</div>
|
||||
<input ref="importInput" type="file" accept=".json" style="display: none" @change="handleImportFile" />
|
||||
</div>
|
||||
|
||||
<el-table :data="connections" style="width: 100%" stripe>
|
||||
<!-- 分组侧栏 -->
|
||||
<div v-if="groups.length > 0" class="group-tabs">
|
||||
<div
|
||||
:class="['group-tab', selectedGroup === null && 'active']"
|
||||
@click="selectedGroup = null"
|
||||
>
|
||||
全部
|
||||
<span class="badge">{{ connections.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['group-tab', selectedGroup === '__ungrouped__' && 'active']"
|
||||
@click="selectedGroup = '__ungrouped__'"
|
||||
>
|
||||
未分组
|
||||
<span class="badge">{{ groupCounts['__ungrouped__'] || 0 }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g"
|
||||
:class="['group-tab', selectedGroup === g && 'active']"
|
||||
@click="selectedGroup = g"
|
||||
>
|
||||
<span class="group-name">{{ g }}</span>
|
||||
<span class="badge">{{ groupCounts[g] || 0 }}</span>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => cmd === 'rename' ? handleRenameGroup(g) : handleDeleteGroup(g)">
|
||||
<span class="group-menu" @click.stop>…</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除分组</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接表格 -->
|
||||
<el-table :data="filteredConnections" style="width: 100%" stripe>
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="分组" width="130">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
:model-value="scope.row.group"
|
||||
placeholder="未分组"
|
||||
size="small"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="(g: string) => handleGroupChange(scope.row, g)"
|
||||
>
|
||||
<el-option v-for="g in groups" :key="g" :label="g" :value="g" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地址" min-width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.ssl ? 'mqtts' : 'mqtt' }}://{{ scope.row.host }}:{{ scope.row.port }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="60">
|
||||
<template #default>
|
||||
<el-tag size="small">v5</el-tag>
|
||||
<template #default="scope">
|
||||
<el-tag size="small">{{ scope.row.protocolVersion === '3.1.1' ? 'v3' : 'v5' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
@@ -145,35 +294,16 @@ function statusType(status: string): string {
|
||||
type="success"
|
||||
:loading="scope.row.status === 'connecting'"
|
||||
@click="handleConnect(scope.row)"
|
||||
>
|
||||
连接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
size="small"
|
||||
@click="handleDisconnect(scope.row)"
|
||||
>
|
||||
断开
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleEdit(scope.row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
>连接</el-button>
|
||||
<el-button v-else size="small" @click="handleDisconnect(scope.row)">断开</el-button>
|
||||
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="connections.length === 0" class="empty-hint">
|
||||
暂无连接,点击上方按钮创建
|
||||
<div v-if="filteredConnections.length === 0" class="empty-hint">
|
||||
{{ connections.length === 0 ? '暂无连接,点击上方按钮创建' : '当前分组下暂无连接' }}
|
||||
</div>
|
||||
|
||||
<ConnectionForm
|
||||
@@ -185,46 +315,55 @@ function statusType(status: string): string {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connection-list {
|
||||
height: 100%;
|
||||
}
|
||||
.connection-list { height: 100%; }
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.list-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
.list-header h3 { margin: 0; font-size: 16px; }
|
||||
.header-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* 分组标签 */
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
.group-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
background: var(--el-fill-color-light);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
.group-tab:hover { background: var(--el-fill-color); }
|
||||
.group-tab.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 500; }
|
||||
.group-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-dark);
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 16px;
|
||||
}
|
||||
.status-indicator.connected {
|
||||
background: var(--el-color-success);
|
||||
}
|
||||
.status-indicator.connecting {
|
||||
background: var(--el-color-warning);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.status-indicator.error {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
.status-indicator.disconnected {
|
||||
background: var(--el-text-color-disabled);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
.group-tab.active .badge { background: var(--el-color-primary); color: #fff; }
|
||||
.group-menu { cursor: pointer; font-size: 14px; opacity: 0.5; }
|
||||
.group-menu:hover { opacity: 1; }
|
||||
|
||||
.empty-hint { text-align: center; padding: 40px; color: var(--el-text-color-placeholder); }
|
||||
.status-indicator { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
|
||||
.status-indicator.connected { background: var(--el-color-success); }
|
||||
.status-indicator.connecting { background: var(--el-color-warning); animation: pulse 1s infinite; }
|
||||
.status-indicator.error { background: var(--el-color-danger); }
|
||||
.status-indicator.disconnected { background: var(--el-text-color-disabled); }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
</style>
|
||||
|
||||
@@ -60,6 +60,13 @@ const connectedConnections = computed(() =>
|
||||
props.connections.filter((c) => c.status === 'connected'),
|
||||
)
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
props.connections.find((c) => c.id === selectedConnectionId.value),
|
||||
)
|
||||
|
||||
/** MQTT 3.1.1 连接不支持 v5 属性 */
|
||||
const isV5Connection = computed(() => selectedConnection.value?.protocolVersion !== '3.1.1')
|
||||
|
||||
const activePreset = computed(() =>
|
||||
props.presets.find((p) => p.id === activePresetId.value),
|
||||
)
|
||||
@@ -440,6 +447,7 @@ function onJsonPayloadChange(value: JsonValue) {
|
||||
<span v-if="presetVariables.length" class="toggle-badge">{{ presetVariables.length }}</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isV5Connection"
|
||||
size="small"
|
||||
:class="['toggle-btn', { active: showV5Props }]"
|
||||
@click="showV5Props = !showV5Props"
|
||||
@@ -496,7 +504,7 @@ function onJsonPayloadChange(value: JsonValue) {
|
||||
|
||||
<!-- v5 Props -->
|
||||
<transition name="slide">
|
||||
<div v-if="showV5Props" class="expand-section">
|
||||
<div v-if="showV5Props && isV5Connection" class="expand-section">
|
||||
<div class="v5-grid">
|
||||
<div class="field">
|
||||
<label>Content Type</label>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AliyunPreset } from '../types/mqtt'
|
||||
import { defaultAliyunPreset } from '../types/mqtt'
|
||||
|
||||
const STORAGE_KEY = 'mqttx_aliyun_presets'
|
||||
|
||||
function loadPresets(): AliyunPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as AliyunPreset[]
|
||||
return parsed.map((p) => ({
|
||||
...defaultAliyunPreset(),
|
||||
...p,
|
||||
id: p.id || crypto.randomUUID(),
|
||||
group: p.group ?? '',
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePresets(presets: AliyunPreset[]) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const presets = ref<AliyunPreset[]>(loadPresets())
|
||||
|
||||
watch(presets, (val) => savePresets(val), { deep: true })
|
||||
|
||||
export function useAliyunPresets() {
|
||||
function add(preset?: Partial<AliyunPreset>): AliyunPreset {
|
||||
const p: AliyunPreset = { ...defaultAliyunPreset(), ...preset }
|
||||
presets.value.push(p)
|
||||
return p
|
||||
}
|
||||
|
||||
function update(id: string, data: Partial<AliyunPreset>) {
|
||||
const idx = presets.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) {
|
||||
presets.value[idx] = { ...presets.value[idx], ...data }
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
const idx = presets.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) presets.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function getById(id: string): AliyunPreset | undefined {
|
||||
return presets.value.find((p) => p.id === id)
|
||||
}
|
||||
|
||||
// ─── 分组 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getGroups(): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const p of presets.value) {
|
||||
if (p.group) set.add(p.group)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
function renameGroup(oldName: string, newName: string) {
|
||||
for (const p of presets.value) {
|
||||
if (p.group === oldName) p.group = newName
|
||||
}
|
||||
}
|
||||
|
||||
function deleteGroup(group: string) {
|
||||
for (const p of presets.value) {
|
||||
if (p.group === group) p.group = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 / 导出 ────────────────────────────────────────────────────────
|
||||
|
||||
function exportPresets(ids?: string[]): string {
|
||||
const list = ids
|
||||
? presets.value.filter((p) => ids.includes(p.id))
|
||||
: presets.value
|
||||
return JSON.stringify(list, null, 2)
|
||||
}
|
||||
|
||||
/** 按 实例ID+GroupID+设备ID+AK 去重,返回导入/跳过数量 */
|
||||
function importPresets(json: string): { imported: number; skipped: number } {
|
||||
const parsed = JSON.parse(json) as Partial<AliyunPreset>[]
|
||||
const seen = new Set(
|
||||
presets.value.map((p) => `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`),
|
||||
)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
for (const item of parsed) {
|
||||
const p: AliyunPreset = {
|
||||
...defaultAliyunPreset(),
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
const key = `${p.instanceId}|${p.groupId}|${p.deviceId}|${p.accessKeyId}`
|
||||
if (seen.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
presets.value.push(p)
|
||||
imported++
|
||||
}
|
||||
return { imported, skipped }
|
||||
}
|
||||
|
||||
return {
|
||||
presets,
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
getById,
|
||||
getGroups,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportPresets,
|
||||
importPresets,
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ function loadConnections(): MqttConnection[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as MqttConnection[]
|
||||
// Ensure each connection has runtime status fields
|
||||
return parsed.map((c) => ({
|
||||
...c,
|
||||
protocolVersion: c.protocolVersion ?? '5.0',
|
||||
group: c.group ?? '',
|
||||
status: 'disconnected' as const,
|
||||
lastError: '',
|
||||
}))
|
||||
@@ -22,17 +23,15 @@ function loadConnections(): MqttConnection[] {
|
||||
|
||||
function saveConnections(connections: MqttConnection[]) {
|
||||
try {
|
||||
// Strip runtime fields before persisting
|
||||
const toSave = connections.map(({ status: _, lastError: __, ...rest }) => rest)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently ignore
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const connections = ref<MqttConnection[]>(loadConnections())
|
||||
|
||||
// Auto-persist on changes
|
||||
watch(
|
||||
connections,
|
||||
(val) => {
|
||||
@@ -42,7 +41,7 @@ watch(
|
||||
)
|
||||
|
||||
export function useConnections() {
|
||||
function add() {
|
||||
function add(): MqttConnection {
|
||||
const conn = defaultConnection()
|
||||
connections.value.push(conn)
|
||||
return conn
|
||||
@@ -82,6 +81,79 @@ export function useConnections() {
|
||||
return connections.value.filter((c) => c.status === 'connected')
|
||||
}
|
||||
|
||||
// ─── 分组操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 获取所有分组名称(去重) */
|
||||
function getGroups(): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const c of connections.value) {
|
||||
if (c.group) set.add(c.group)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
/** 按分组获取连接列表 */
|
||||
function getByGroup(group: string): MqttConnection[] {
|
||||
return connections.value.filter((c) => c.group === group)
|
||||
}
|
||||
|
||||
/** 重命名分组 */
|
||||
function renameGroup(oldName: string, newName: string) {
|
||||
for (const c of connections.value) {
|
||||
if (c.group === oldName) {
|
||||
c.group = newName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除分组(将组内连接移到未分组) */
|
||||
function deleteGroup(group: string) {
|
||||
for (const c of connections.value) {
|
||||
if (c.group === group) {
|
||||
c.group = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 / 导出 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** 导出为 JSON 字符串(去除运行时字段) */
|
||||
function exportConnections(ids?: string[]): string {
|
||||
const list = ids
|
||||
? connections.value.filter((c) => ids.includes(c.id))
|
||||
: connections.value
|
||||
const data = list.map(({ status: _, lastError: __, ...rest }) => rest)
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
/** 从 JSON 导入,按名称+地址+端口+ClientID 去重,返回导入/跳过数量 */
|
||||
function importConnections(json: string): { imported: number; skipped: number } {
|
||||
const parsed = JSON.parse(json) as Partial<MqttConnection>[]
|
||||
const seen = new Set(
|
||||
connections.value.map((c) => `${c.name}|${c.host}|${c.port}|${c.clientId}`),
|
||||
)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
for (const item of parsed) {
|
||||
const conn: MqttConnection = {
|
||||
...defaultConnection(),
|
||||
...item,
|
||||
id: crypto.randomUUID(), // 始终生成新 ID 避免冲突
|
||||
status: 'disconnected',
|
||||
lastError: '',
|
||||
}
|
||||
const key = `${conn.name}|${conn.host}|${conn.port}|${conn.clientId}`
|
||||
if (seen.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
connections.value.push(conn)
|
||||
imported++
|
||||
}
|
||||
return { imported, skipped }
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
add,
|
||||
@@ -90,5 +162,11 @@ export function useConnections() {
|
||||
getById,
|
||||
updateStatus,
|
||||
getConnected,
|
||||
getGroups,
|
||||
getByGroup,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
exportConnections,
|
||||
importConnections,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,10 @@ export async function mqttTestConnection(conn: MqttConnectionDto): Promise<strin
|
||||
return await invoke<string>('mqtt_test_connection', { conn })
|
||||
}
|
||||
|
||||
export async function mqttReadDebugLog(): Promise<string> {
|
||||
return await invoke<string>('mqtt_read_debug_log')
|
||||
}
|
||||
|
||||
export async function mqttGetLogDir(): Promise<string> {
|
||||
return await invoke<string>('mqtt_get_log_dir')
|
||||
}
|
||||
+101
-1
@@ -7,6 +7,98 @@ export interface Subscription {
|
||||
qos: number
|
||||
}
|
||||
|
||||
// ─── Aliyun Preset ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 阿里云鉴权模式 */
|
||||
export type AliyunAuthMode = 'token' | 'device_credential'
|
||||
|
||||
export interface AliyunPreset {
|
||||
/** 唯一 ID */
|
||||
id: string
|
||||
/** 预设名称 */
|
||||
name: string
|
||||
/** 分组 */
|
||||
group: string
|
||||
/** 实例 ID,如 post-cn-4591dq2ra1i */
|
||||
instanceId: string
|
||||
/** 鉴权模式 */
|
||||
authMode: AliyunAuthMode
|
||||
/** Access Key ID(Token 模式必填) */
|
||||
accessKeyId: string
|
||||
/** Access Key Secret(Token 模式必填) */
|
||||
accessKeySecret: string
|
||||
/** Group ID,如 GID-prod */
|
||||
groupId: string
|
||||
/** 设备 ID,用于拼接 ClientId */
|
||||
deviceId: string
|
||||
/** 设备 Access Key ID(一机一密模式必填) */
|
||||
deviceAccessKeyId: string
|
||||
/** 设备 Access Key Secret(一机一密模式必填) */
|
||||
deviceAccessKeySecret: string
|
||||
}
|
||||
|
||||
export function defaultAliyunPreset(): AliyunPreset {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
group: '',
|
||||
instanceId: '',
|
||||
authMode: 'token',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
groupId: '',
|
||||
deviceId: `device_${Math.random().toString(36).slice(2, 10)}`,
|
||||
deviceAccessKeyId: '',
|
||||
deviceAccessKeySecret: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算阿里云 MQTT 签名: HMAC-SHA1(Secret, ClientId) -> Base64 (使用 Web Crypto API) */
|
||||
export async function calculateAliyunSignature(clientId: string, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(clientId))
|
||||
return bytesToBase64(new Uint8Array(signature))
|
||||
}
|
||||
|
||||
/** 根据阿里云预设生成连接参数 */
|
||||
export async function generateAliyunConnection(preset: AliyunPreset): Promise<Pick<MqttConnection, 'host' | 'port' | 'username' | 'password' | 'clientId' | 'protocolVersion' | 'cleanStart'>> {
|
||||
const clientId = `${preset.groupId}@@@${preset.deviceId}`
|
||||
const username = `Signature|${preset.accessKeyId}|${preset.instanceId}`
|
||||
const password = await calculateAliyunSignature(clientId, preset.accessKeySecret)
|
||||
|
||||
return {
|
||||
host: `${preset.instanceId}.mqtt.aliyuncs.com`,
|
||||
port: 1883,
|
||||
username,
|
||||
password,
|
||||
clientId,
|
||||
protocolVersion: '3.1.1',
|
||||
cleanStart: true,
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i]
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[(b0 >> 2) & 0x3f]
|
||||
result += chars[((b0 << 4) | (b1 >> 4)) & 0x3f]
|
||||
result += i + 1 < bytes.length ? chars[((b1 << 2) | (b2 >> 6)) & 0x3f] : '='
|
||||
result += i + 2 < bytes.length ? chars[b2 & 0x3f] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Connection ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MqttConnection {
|
||||
@@ -17,10 +109,14 @@ export interface MqttConnection {
|
||||
username: string
|
||||
password: string
|
||||
clientId: string
|
||||
/** MQTT 协议版本:3.1.1 / 5.0 */
|
||||
protocolVersion: '3.1.1' | '5.0'
|
||||
cleanStart: boolean
|
||||
sessionExpiryInterval: number
|
||||
keepAlive: number
|
||||
ssl: boolean
|
||||
/** 分组名称,空字符串表示未分组 */
|
||||
group: string
|
||||
receiveMaximum: number | null
|
||||
maximumPacketSize: number | null
|
||||
topicAliasMaximum: number | null
|
||||
@@ -38,10 +134,12 @@ export function defaultConnection(): MqttConnection {
|
||||
username: '',
|
||||
password: '',
|
||||
clientId: `mqttx_${Math.random().toString(36).slice(2, 10)}`,
|
||||
protocolVersion: '5.0',
|
||||
cleanStart: true,
|
||||
sessionExpiryInterval: 0,
|
||||
keepAlive: 60,
|
||||
ssl: false,
|
||||
group: '',
|
||||
receiveMaximum: null,
|
||||
maximumPacketSize: null,
|
||||
topicAliasMaximum: null,
|
||||
@@ -112,6 +210,7 @@ export interface MqttConnectionDto {
|
||||
username: string
|
||||
password: string
|
||||
client_id: string
|
||||
protocol_version: string
|
||||
clean_start: boolean
|
||||
session_expiry_interval: number
|
||||
keep_alive: number
|
||||
@@ -130,6 +229,7 @@ export function toConnectionDto(conn: MqttConnection): MqttConnectionDto {
|
||||
username: conn.username,
|
||||
password: conn.password,
|
||||
client_id: conn.clientId,
|
||||
protocol_version: conn.protocolVersion,
|
||||
clean_start: conn.cleanStart,
|
||||
session_expiry_interval: conn.sessionExpiryInterval,
|
||||
keep_alive: conn.keepAlive,
|
||||
@@ -197,4 +297,4 @@ export interface MqttLogEvent {
|
||||
event: string
|
||||
message: string
|
||||
details: string | null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user