feat: 网关管理页面 + 设备表单网关下拉联动

- 新增 src/api/gateway.js(网关 CRUD + 测试连接 + options API + 枚举映射)
- 新增 src/views/config/gateway/Index.vue(网关管理页面:列表/新增/编辑/测试连接/详情抽屉+下挂设备)
- 修改 src/views/config/device/Index.vue(GatewayCode 文本输入改为 GatewayId 下拉选择,列表列显示 GatewayName)
- 新增 src/api/product.js、src/api/thingPoint.js(产品/物模型点 API)
- 新增 src/components/thing/(PointSendForm、ThingPointTable、ThingAlarmPanel 共享组件)
This commit is contained in:
2026-09-04 17:43:36 +08:00
parent a72e2e6ff5
commit 240ede9769
8 changed files with 1072 additions and 11 deletions
+154
View File
@@ -0,0 +1,154 @@
<template>
<div class="point-send-form">
<div class="ps-toolbar">
<span class="ps-count">
{{ points.length }} 个点可下发 {{ writablePoints.length }}
<span v-if="points.length" class="ps-tip">只读点不支持下发Int32/Float 从地址起连写 2 个寄存器</span>
</span>
<el-button link type="primary" size="small" :loading="loading" @click="fetchPoints">刷新</el-button>
</div>
<el-table :data="points" v-loading="loading" size="small" border stripe>
<el-table-column prop="Code" label="编码" width="120" />
<el-table-column prop="Name" label="名称" min-width="110" show-overflow-tooltip />
<el-table-column label="寄存器" width="90">
<template #default="{ row }">{{ registerLabel(row.RegisterType) }}</template>
</el-table-column>
<el-table-column label="地址" width="70" align="center">
<template #default="{ row }">0x{{ dec2hex(row.Address) }}</template>
</el-table-column>
<el-table-column label="读写" width="60" align="center">
<template #default="{ row }">{{ rwLabel(row.Rw) }}</template>
</el-table-column>
<el-table-column label="状态" width="84" align="center">
<template #default="{ row }">
<el-tag v-if="row.Rw === 1" size="small" type="info">只读</el-tag>
<el-tag v-else size="small" type="success">可下发</el-tag>
</template>
</el-table-column>
<el-table-column label="下发值" width="150">
<template #default="{ row }">
<el-switch
v-if="row.DataType === 1"
:model-value="row._sendValue === 1"
active-text=""
@change="row._sendValue = $event ? 1 : 0"
/>
<el-input-number
v-else
v-model="row._sendValue"
:precision="row.DataType === 5 ? 1 : 0"
:step="1"
:controls="false"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="单位" width="60" align="center">
<template #default="{ row }">{{ row.Unit || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="70" fixed="right" align="center">
<template #default="{ row }">
<el-button
link type="primary"
:disabled="row.Rw === 1"
@click="onSend(row)"
>下发</el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态区分无点 / 点均不可下发 -->
<el-empty
v-if="!loading && points.length === 0"
description="该设备暂无物模型点,请先在「物模型点」标签页为该设备添加数据点"
:image-size="60"
/>
<el-alert
v-else-if="!loading && writablePoints.length === 0"
title="已添加的点均为只读,暂无可下发点(可将点读写属性改为「只写/读写」)"
type="info"
:closable="false"
class="ps-feedback"
/>
<!-- 下发反馈 -->
<el-alert v-if="feedback.text" :title="feedback.text" :type="feedback.type" :closable="false" class="ps-feedback" />
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getThingPoints, registerLabel, rwLabel } from '@/api/thingPoint'
import { sendDeviceCommand } from '@/api/device'
const props = defineProps({
deviceId: { type: String, required: true }
})
const loading = ref(false)
const points = ref([])
const feedback = ref({ text: '', type: 'success' })
// 只读(1)的点不能下发;只写/读写都可以(Int32/Float 走 FC16 连写 2 个寄存器,已支持)
const writablePoints = computed(() => points.value.filter(p => p.Rw !== 1))
function dec2hex(n) {
return (Number(n) || 0).toString(16).toUpperCase().padStart(4, '0')
}
async function fetchPoints() {
loading.value = true
try {
const pts = (await getThingPoints(2, props.deviceId)) || []
// 显示设备全部点(含只读),便于与「物模型点」标签页对照
points.value = pts.map(p => ({ ...p, _sendValue: p.DataType === 1 ? 1 : 0 }))
} catch (e) {
points.value = []
} finally {
loading.value = false
}
}
async function onSend(row) {
try {
await sendDeviceCommand(props.deviceId, {
Id: props.deviceId,
PointId: row.Id,
Value: row._sendValue ?? 0,
IsSimulated: false
})
// res 为后端返回的 Data(无数据时返回 undefined),消息提示走统一处理
feedback.value = { text: `已下发至「${row.Name || row.Code}`, type: 'success' }
ElMessage.success(`指令已下发:${row.Name || row.Code}`)
} catch (e) {
feedback.value = { text: e?.message || '下发失败', type: 'error' }
}
}
watch(() => props.deviceId, (val) => { if (val) fetchPoints() })
onMounted(() => { if (props.deviceId) fetchPoints() })
defineExpose({ refresh: fetchPoints })
</script>
<style scoped>
.ps-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.ps-count {
font-size: 12px;
color: #909399;
}
.ps-tip {
color: #c0c4cc;
}
.ps-feedback {
margin-top: 12px;
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<template>
<div class="thing-alarm-panel">
<div class="tp-toolbar">
<el-button type="primary" size="small" icon="Plus" @click="openAdd">新增告警规则</el-button>
<span v-if="rules.length" class="tp-count"> {{ rules.length }} 条规则</span>
</div>
<el-table :data="rules" v-loading="loading" size="small" border stripe>
<el-table-column prop="RuleName" label="规则名称" min-width="130" show-overflow-tooltip />
<el-table-column label="绑定点" width="130">
<template #default="{ row }">
<el-tag v-if="row.PointCode" size="small" type="info">{{ row.PointCode }}</el-tag>
<span v-else>{{ ownerTypeLabel }}</span>
</template>
</el-table-column>
<el-table-column label="触发条件" min-width="140">
<template #default="{ row }">
{{ metricLabel(row.Metric) }} {{ row.Operator }} {{ row.Threshold }}
</template>
</el-table-column>
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="levelTagType(row.AlarmLevel)" size="small">{{ row.AlarmLevel }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }">
<el-switch :model-value="row.Enabled" size="small" @change="onToggle(row, $event)" />
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑告警规则' : '新增告警规则'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rulesForm" label-width="100px">
<el-form-item label="规则名称" prop="RuleName">
<el-input v-model="form.RuleName" placeholder="如:温度超限报警" maxlength="100" />
</el-form-item>
<el-form-item label="监测指标" prop="Metric">
<el-select v-model="form.Metric" placeholder="请选择该归属下的点" style="width: 100%">
<el-option v-for="p in pointOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<el-form-item label="触发条件" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-select v-model="form.Operator" placeholder="运算符" style="width: 45%">
<el-option v-for="op in operators" :key="op" :label="op" :value="op" />
</el-select>
<el-input-number v-model="form.Threshold" :precision="1" :step="1" style="width: 55%" placeholder="阈值" />
</div>
</el-form-item>
<el-form-item label="告警级别" prop="AlarmLevel">
<el-select v-model="form.AlarmLevel" style="width: 100%">
<el-option v-for="l in levels" :key="l" :label="l" :value="l" />
</el-select>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.Enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getOwnerAlertRules, addAlertRule, updateAlertRule, deleteAlertRule, setAlertRuleEnabled } from '@/api/alertRule'
import { getThingPoints } from '@/api/thingPoint'
const props = defineProps({
ownerType: { type: Number, required: true }, // 1=产品 2=设备
ownerId: { type: String, required: true }
})
const ownerTypeLabel = computed(() => (props.ownerType === 1 ? '产品' : '设备'))
const operators = ['>', '>=', '<', '<=', '=', '≠']
const levels = ['信息', '警告', '紧急']
const levelTagType = (l) => ({ '信息': 'info', '警告': 'warning', '紧急': 'danger' }[l] || 'info')
const loading = ref(false)
const rules = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref(null)
const pointOptions = ref([])
const defaultForm = () => ({
Id: '', RuleName: '', DeviceType: '', Metric: '', Operator: '>',
Threshold: null, AlarmLevel: '警告', Enabled: true, Remark: '',
OwnerType: props.ownerType, OwnerId: props.ownerId, PointCode: ''
})
const form = reactive(defaultForm())
const rulesForm = {
RuleName: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
Metric: [{ required: true, message: '请选择监测指标', trigger: 'change' }],
AlarmLevel: [{ required: true, message: '请选择告警级别', trigger: 'change' }]
}
const metricLabel = (m) => pointOptions.value.find(p => p.value === m)?.label || m || '-'
async function fetchRules() {
loading.value = true
try {
rules.value = (await getOwnerAlertRules(props.ownerType, props.ownerId)) || []
} catch (e) {
rules.value = []
} finally {
loading.value = false
}
}
async function fetchPoints() {
try {
const pts = (await getThingPoints(props.ownerType, props.ownerId)) || []
pointOptions.value = pts.map(p => ({ value: p.Code, label: `${p.Name || p.Code} (${p.Code})` }))
} catch (e) {
pointOptions.value = []
}
}
function openAdd() {
Object.assign(form, defaultForm())
dialogVisible.value = true
}
function openEdit(row) {
Object.assign(form, defaultForm(), row)
dialogVisible.value = true
}
async function onSubmit() {
await formRef.value.validate()
saving.value = true
try {
const payload = {
...form,
OwnerType: props.ownerType,
OwnerId: Number(props.ownerId) || 0,
// 从点选 Metric 时同步 PointCode(点编码即绑定编码)
PointCode: form.Metric || null
}
if (payload.Id) {
await updateAlertRule(payload)
ElMessage.success('修改成功')
} else {
delete payload.Id
await addAlertRule(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchRules()
} catch (e) { /* 拦截器已提示 */ } finally {
saving.value = false
}
}
async function onToggle(row, enabled) {
try {
await setAlertRuleEnabled(row.Id, enabled)
row.Enabled = enabled
ElMessage.success(enabled ? '已启用' : '已停用')
} catch (e) { /* 拦截器已提示 */ }
}
async function onDelete(row) {
await ElMessageBox.confirm(`确定删除规则「${row.RuleName}」吗?`, '提示', { type: 'warning' })
try {
await deleteAlertRule(row.Id)
ElMessage.success('删除成功')
fetchRules()
} catch (e) { /* 拦截器已提示 */ }
}
watch(() => props.ownerId, (val) => { if (val) { fetchPoints(); fetchRules() } })
onMounted(() => { if (props.ownerId) { fetchPoints(); fetchRules() } })
defineExpose({ refresh: fetchRules })
</script>
<style scoped>
.tp-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tp-count {
font-size: 12px;
color: #909399;
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="thing-point-table">
<div class="tp-toolbar">
<el-button type="primary" size="small" icon="Plus" @click="openAdd">新增点</el-button>
<span v-if="points.length" class="tp-count"> {{ points.length }} 个点</span>
</div>
<el-table :data="points" v-loading="loading" size="small" border stripe>
<el-table-column prop="Code" label="编码" width="120" />
<el-table-column prop="Name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="寄存器" width="90">
<template #default="{ row }">{{ registerLabel(row.RegisterType) }}</template>
</el-table-column>
<el-table-column label="读写" width="70" align="center">
<template #default="{ row }">{{ rwLabel(row.Rw) }}</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">{{ dataLabel(row.DataType) }}</template>
</el-table-column>
<el-table-column label="地址" width="80" align="center">
<template #default="{ row }">0x{{ dec2hex(row.Address) }}</template>
</el-table-column>
<el-table-column label="倍率" width="70" align="center">
<template #default="{ row }">{{ row.Scale }}</template>
</el-table-column>
<el-table-column prop="Unit" label="单位" width="60" align="center">
<template #default="{ row }">{{ row.Unit || '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.Enabled ? 'success' : 'info'" size="small">{{ row.Enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="form.Id ? '编辑点' : '新增点'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="点编码" prop="Code">
<el-input v-model="form.Code" placeholder="如 TempPV / PowerOn" maxlength="64" />
</el-form-item>
<el-form-item label="点名称" prop="Name">
<el-input v-model="form.Name" placeholder="如 温度PV" maxlength="100" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="寄存器类型" prop="RegisterType">
<el-select v-model="form.RegisterType" style="width: 100%">
<el-option v-for="o in registerOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="读写权限" prop="Rw">
<el-select v-model="form.Rw" style="width: 100%">
<el-option v-for="o in rwOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="数据类型" prop="DataType">
<el-select v-model="form.DataType" style="width: 100%">
<el-option v-for="o in dataOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="寄存器地址" prop="Address">
<el-input-number v-model="form.Address" :min="0" :max="65535" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="倍率 Scale">
<el-input-number v-model="form.Scale" :min="0.0001" :precision="4" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="偏移 Offset">
<el-input-number v-model="form.Offset" :precision="4" 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.Unit" placeholder="如 ℃ / %" maxlength="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序号">
<el-input-number v-model="form.Sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="是否启用">
<el-switch v-model="form.Enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getThingPoints, addThingPoint, updateThingPoint, deleteThingPoint,
rwOptions, rwLabel, registerOptions, registerLabel, dataOptions, dataLabel
} from '@/api/thingPoint'
const props = defineProps({
ownerType: { type: Number, required: true }, // 1=产品 2=设备
ownerId: { type: String, required: true }
})
const loading = ref(false)
const points = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref(null)
const defaultForm = () => ({
Id: '', Code: '', Name: '', OwnerType: props.ownerType, OwnerId: props.ownerId,
RegisterType: 2, Rw: 1, DataType: 3, Address: 0,
Scale: 1, Offset: 0, Unit: '', Enabled: true, Sort: 0, Remark: ''
})
const form = reactive(defaultForm())
const rules = {
Code: [{ required: true, message: '请输入点编码', trigger: 'blur' }],
Name: [{ required: true, message: '请输入点名称', trigger: 'blur' }],
RegisterType: [{ required: true, message: '请选择寄存器类型', trigger: 'change' }],
Rw: [{ required: true, message: '请选择读写权限', trigger: 'change' }],
DataType: [{ required: true, message: '请选择数据类型', trigger: 'change' }]
}
function dec2hex(n) {
return (Number(n) || 0).toString(16).toUpperCase().padStart(4, '0')
}
async function fetchPoints() {
loading.value = true
try {
points.value = (await getThingPoints(props.ownerType, props.ownerId)) || []
} catch (e) {
points.value = []
} finally {
loading.value = false
}
}
function openAdd() {
Object.assign(form, defaultForm(), { OwnerType: props.ownerType, OwnerId: props.ownerId })
dialogVisible.value = true
}
function openEdit(row) {
Object.assign(form, defaultForm(), {
Id: row.Id, Code: row.Code, Name: row.Name,
RegisterType: row.RegisterType, Rw: row.Rw, DataType: row.DataType,
Address: row.Address, Scale: row.Scale, Offset: row.Offset,
Unit: row.Unit || '', Enabled: row.Enabled, Sort: row.Sort, Remark: row.Remark || ''
})
dialogVisible.value = true
}
async function onSubmit() {
await formRef.value.validate()
saving.value = true
try {
const payload = { ...form, OwnerType: props.ownerType, OwnerId: props.ownerId }
if (payload.Id) {
await updateThingPoint(payload)
ElMessage.success('修改成功')
} else {
delete payload.Id
await addThingPoint(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchPoints()
} catch (e) { /* 拦截器已提示 */ } finally {
saving.value = false
}
}
async function onDelete(row) {
await ElMessageBox.confirm(`确定删除点「${row.Code}」吗?`, '提示', { type: 'warning' })
try {
await deleteThingPoint(row.Id)
ElMessage.success('删除成功')
fetchPoints()
} catch (e) { /* 拦截器已提示 */ }
}
watch(() => props.ownerId, (val) => { if (val) fetchPoints() })
onMounted(() => { if (props.ownerId) fetchPoints() })
defineExpose({ refresh: fetchPoints })
</script>
<style scoped>
.tp-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tp-count {
font-size: 12px;
color: #909399;
}
</style>