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
+54
View File
@@ -0,0 +1,54 @@
import request from '@/utils/request'
// 后端路由前缀(GatewayController: [Route("api/config/gateway")]
const base = '/config/gateway'
// ============ 枚举常量(与后端 IotDeviceProtocolEnum 一一对应,复用 device.js ============
export const PROTOCOL_TYPES = {
1: 'Modbus TCP',
2: 'Modbus RTU',
3: 'S7',
4: 'TCP',
5: '串口'
}
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
// 在线状态
export const ONLINE_STATUS = {
0: { label: '离线', tag: 'info' },
1: { label: '在线', tag: 'success' },
3: { label: '异常', tag: 'danger' }
}
export const onlineStatusLabel = (s) => ONLINE_STATUS[s]?.label || '离线'
export const onlineStatusTag = (s) => ONLINE_STATUS[s]?.tag || 'info'
// 校验位
export const PARITY_OPTIONS = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Odd' },
{ value: 2, label: 'Even' }
]
// ============ 网关接口 ============
// 网关列表(分页;X-Total-Count
export const getGatewayList = (params) => request.get(`${base}/list`, { params })
// 网关详情
export const getGatewayDetail = (id) => request.get(`${base}/${id}`)
// 新增网关
export const addGateway = (data) => request.post(base, data)
// 修改网关
export const updateGateway = (data) => request.put(base, data)
// 删除网关
export const deleteGateway = (id) => request.delete(`${base}/${id}`)
// 测试连接
export const testGatewayConnection = (id) => request.post(`${base}/${id}/test-connection`)
// 下拉选项(设备表单用)
export const getGatewayOptions = () => request.get(`${base}/options`)
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 后端路由前缀(ProductController: [Route("api/config/product")]
const base = '/config/product'
// ============ 枚举常量(与后端 Model.Entity.Config 枚举一一对应) ============
// 通讯协议(IotDeviceProtocolEnum
export const PROTOCOL_TYPES = {
1: 'Modbus TCP',
2: 'Modbus RTU',
3: 'S7',
4: 'TCP',
5: '串口'
}
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
// ============ 产品分类接口 ============
export const getProductCategories = () => request.get(`${base}/category/list`)
export const addProductCategory = (data) => request.post(`${base}/category/add`, data)
export const updateProductCategory = (data) => request.put(`${base}/category/update`, data)
export const deleteProductCategory = (id) => request.delete(`${base}/category/${id}`)
// ============ 产品接口 ============
// 产品下拉选项(设备选择所属产品用)
export const getProductOptions = () => request.get(`${base}/options`)
// 产品列表(服务端分页;结果数组上附带 total,来自响应头 X-Total-Count
export const getProductList = (params) => request.get(`${base}/list`, { params })
// 产品详情
export const getProductDetail = (id) => request.get(`${base}/${id}`)
// 新增产品(后端校验型号唯一)
export const addProduct = (data) => request.post(base, data)
// 修改产品
export const updateProduct = (data) => request.put(base, data)
// 删除产品(软删除)
export const deleteProduct = (id) => request.delete(`${base}/${id}`)
+57
View File
@@ -0,0 +1,57 @@
import request from '@/utils/request'
// 后端路由前缀(ThingPointController: [Route("api/config/thing-point")]
const base = '/config/thing-point'
// ============ 枚举常量(与后端 Model.Entity.Config.ThingEnums 对应) ============
// 归属类型(ThingOwnerTypeEnum
export const OWNER_TYPES = { 1: '产品', 2: '设备' }
// 读写权限(ThingRwEnum
export const RW_TYPES = {
1: { label: '只读' },
2: { label: '只写' },
3: { label: '读写' }
}
export const rwOptions = Object.entries(RW_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
export const rwLabel = (v) => RW_TYPES[v]?.label || '-'
// 寄存器类型(ThingRegisterTypeEnum
export const REGISTER_TYPES = {
1: { label: '线圈', canWrite: true },
2: { label: '保持寄存器', canWrite: true },
3: { label: '输入寄存器', canWrite: false }
}
export const registerOptions = Object.entries(REGISTER_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
export const registerLabel = (v) => REGISTER_TYPES[v]?.label || '-'
// 数据类型(ThingDataTypeEnum
export const DATA_TYPES = {
1: { label: 'Bool' },
2: { label: 'Int16' },
3: { label: 'UInt16' },
4: { label: 'Int32' },
5: { label: 'Float' }
}
export const dataOptions = Object.entries(DATA_TYPES).map(([value, v]) => ({ value: Number(value), label: v.label }))
export const dataLabel = (v) => DATA_TYPES[v]?.label || '-'
// ============ 物模型点接口 ============
// 点列表(ownerType: 1=产品 2=设备)
export const getThingPoints = (ownerType, ownerId, keyword) => request.get(`${base}/list`, {
params: { ownerType, ownerId, keyword: keyword || undefined }
})
// 点详情
export const getThingPoint = (id) => request.get(`${base}/${id}`)
// 新增点
export const addThingPoint = (data) => request.post(base, data)
// 修改点
export const updateThingPoint = (data) => request.put(base, data)
// 删除点
export const deleteThingPoint = (id) => request.delete(`${base}/${id}`)
+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>
+24 -8
View File
@@ -48,7 +48,11 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="GatewayCode" label="所属网关" width="130" show-overflow-tooltip /> <el-table-column label="所属网关" width="130" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.GatewayName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="SlaveId" label="从站地址" width="90" align="center" /> <el-table-column prop="SlaveId" label="从站地址" width="90" align="center" />
<el-table-column label="协议" width="110"> <el-table-column label="协议" width="110">
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template> <template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
@@ -136,8 +140,10 @@
<el-option v-for="p in productOptions" :key="p.Id" :label="`${p.Model}${p.Name ? ' - ' + p.Name : ''}`" :value="p.Id" /> <el-option v-for="p in productOptions" :key="p.Id" :label="`${p.Model}${p.Name ? ' - ' + p.Name : ''}`" :value="p.Id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="所属网关" prop="GatewayCode"> <el-form-item label="所属网关">
<el-input v-model="form.GatewayCode" placeholder="如 GW-ENV-01IP:端口或串口标识)" maxlength="64" /> <el-select v-model="form.GatewayId" placeholder="未关联" clearable style="width: 100%">
<el-option v-for="g in gatewayOptions" :key="g.Id" :label="`${g.Code} - ${g.Name}`" :value="g.Id" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="从站地址" prop="SlaveId"> <el-form-item label="从站地址" prop="SlaveId">
<el-input-number v-model="form.SlaveId" :min="1" :max="247" style="width: 100%" /> <el-input-number v-model="form.SlaveId" :min="1" :max="247" style="width: 100%" />
@@ -172,7 +178,7 @@
<el-descriptions-item label="设备名称">{{ detailDevice.Name }}</el-descriptions-item> <el-descriptions-item label="设备名称">{{ detailDevice.Name }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ detailDevice.DeviceType }}</el-descriptions-item> <el-descriptions-item label="设备类型">{{ detailDevice.DeviceType }}</el-descriptions-item>
<el-descriptions-item label="所属产品">{{ detailDevice.ProductName || '未分类' }}</el-descriptions-item> <el-descriptions-item label="所属产品">{{ detailDevice.ProductName || '未分类' }}</el-descriptions-item>
<el-descriptions-item label="所属网关">{{ detailDevice.GatewayCode }}</el-descriptions-item> <el-descriptions-item label="所属网关">{{ detailDevice.GatewayName || '未关联' }}</el-descriptions-item>
<el-descriptions-item label="从站地址">{{ detailDevice.SlaveId }}</el-descriptions-item> <el-descriptions-item label="从站地址">{{ detailDevice.SlaveId }}</el-descriptions-item>
<el-descriptions-item label="通讯协议">{{ protocolLabel(detailDevice.ProtocolType) }}</el-descriptions-item> <el-descriptions-item label="通讯协议">{{ protocolLabel(detailDevice.ProtocolType) }}</el-descriptions-item>
<el-descriptions-item label="在线状态"> <el-descriptions-item label="在线状态">
@@ -245,6 +251,7 @@ import {
protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag
} from '@/api/device' } from '@/api/device'
import { getProductOptions } from '@/api/product' import { getProductOptions } from '@/api/product'
import { getGatewayOptions } from '@/api/gateway'
import { getCurve } from '@/api/dashboard' import { getCurve } from '@/api/dashboard'
import ThingPointTable from '@/components/thing/ThingPointTable.vue' import ThingPointTable from '@/components/thing/ThingPointTable.vue'
import ThingAlarmPanel from '@/components/thing/ThingAlarmPanel.vue' import ThingAlarmPanel from '@/components/thing/ThingAlarmPanel.vue'
@@ -259,6 +266,7 @@ const pageSize = ref(10)
const keyword = ref('') const keyword = ref('')
const searchProductId = ref('') const searchProductId = ref('')
const productOptions = ref([]) const productOptions = ref([])
const gatewayOptions = ref([])
async function fetchProductOptions() { async function fetchProductOptions() {
try { try {
@@ -268,6 +276,14 @@ async function fetchProductOptions() {
} }
} }
async function fetchGatewayOptions() {
try {
gatewayOptions.value = (await getGatewayOptions()) || []
} catch (e) {
gatewayOptions.value = []
}
}
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
@@ -301,7 +317,7 @@ const deviceTypes = [
] ]
const defaultForm = () => ({ const defaultForm = () => ({
Id: '', Code: '', Name: '', DeviceType: '', ProductId: '', GatewayCode: '', Id: '', Code: '', Name: '', DeviceType: '', ProductId: '', GatewayId: '',
SlaveId: 1, ProtocolType: 1, Manufacturer: '', IsEnabled: true, Remark: '' SlaveId: 1, ProtocolType: 1, Manufacturer: '', IsEnabled: true, Remark: ''
}) })
const form = reactive(defaultForm()) const form = reactive(defaultForm())
@@ -309,8 +325,7 @@ const form = reactive(defaultForm())
const formRules = { const formRules = {
Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }], Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }], DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }]
GatewayCode: [{ required: true, message: '请输入所属网关', trigger: 'blur' }]
} }
function openAdd() { function openAdd() {
@@ -325,7 +340,7 @@ function openEdit(row) {
Name: row.Name, Name: row.Name,
DeviceType: row.DeviceType, DeviceType: row.DeviceType,
ProductId: row.ProductId && row.ProductId !== '0' ? row.ProductId : '', ProductId: row.ProductId && row.ProductId !== '0' ? row.ProductId : '',
GatewayCode: row.GatewayCode, GatewayId: row.GatewayId && row.GatewayId !== '0' ? row.GatewayId : '',
SlaveId: row.SlaveId, SlaveId: row.SlaveId,
ProtocolType: row.ProtocolType, ProtocolType: row.ProtocolType,
Manufacturer: row.Manufacturer || '', Manufacturer: row.Manufacturer || '',
@@ -394,6 +409,7 @@ function onMoreCommand(cmd, row) {
onMounted(() => { onMounted(() => {
fetchList() fetchList()
fetchProductOptions() fetchProductOptions()
fetchGatewayOptions()
window.addEventListener('click', closeCtxMenu) window.addEventListener('click', closeCtxMenu)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
+304 -3
View File
@@ -2,13 +2,314 @@
<div class="page-container"> <div class="page-container">
<el-card> <el-card>
<template #header> <template #header>
<span>网关管理</span> <div class="card-header">
<span>网关管理{{ total }} </span>
<el-button type="primary" icon="Plus" @click="openAdd">添加网关</el-button>
</div>
</template> </template>
<el-empty description="网关管理 - 功能开发中" />
<div class="search-bar">
<el-select v-model="searchProtocol" placeholder="协议类型(全部)" clearable style="width: 160px" @change="onSearch">
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-input v-model="keyword" placeholder="网关编码 / 名称" clearable style="width: 260px" @keyup.enter="onSearch" @clear="onSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe border row-key="Id">
<el-table-column prop="Code" label="网关编码" width="130" />
<el-table-column prop="Name" label="网关名称" min-width="140" show-overflow-tooltip />
<el-table-column label="协议" width="110">
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
</el-table-column>
<el-table-column label="连接地址" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ connAddress(row) }}</template>
</el-table-column>
<el-table-column label="在线状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="onlineStatusTag(row.OnlineStatus)" size="small">{{ onlineStatusLabel(row.OnlineStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最后连接" width="160">
<template #default="{ row }">{{ fmtTime(row.LastConnectedTime) }}</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }"><el-switch :model-value="row.IsEnabled" disabled size="small" /></template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="onTest(row)" :loading="row._testing">测试</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper"
@current-change="fetchList" @size-change="onSizeChange" />
</div>
</el-card> </el-card>
<!-- 添加/编辑网关 -->
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改网关' : '添加网关'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="网关编码" prop="Code">
<el-input v-model="form.Code" placeholder="唯一编码,如 GW-ENV-01" maxlength="64" />
</el-form-item>
<el-form-item label="网关名称" prop="Name">
<el-input v-model="form.Name" placeholder="请输入网关名称" maxlength="100" />
</el-form-item>
<el-form-item label="协议类型" prop="ProtocolType">
<el-select v-model="form.ProtocolType" style="width: 100%" @change="onProtocolChange">
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<!-- TCP 参数组 -->
<template v-if="isTcpProtocol">
<el-form-item label="IP 地址" prop="Host">
<el-input v-model="form.Host" placeholder="如 192.168.1.10" />
</el-form-item>
<el-form-item label="端口号" prop="Port">
<el-input-number v-model="form.Port" :min="1" :max="65535" style="width: 100%" />
</el-form-item>
</template>
<!-- 串口参数组 -->
<template v-if="isSerialProtocol">
<el-form-item label="串口号" prop="ComPort">
<el-input v-model="form.ComPort" placeholder="如 COM3" maxlength="20" />
</el-form-item>
<el-form-item label="波特率" prop="BaudRate">
<el-select v-model="form.BaudRate" style="width: 100%">
<el-option v-for="b in [9600,19200,38400,57600,115200]" :key="b" :label="b" :value="b" />
</el-select>
</el-form-item>
<el-form-item label="数据位">
<el-select v-model="form.DataBits" style="width: 100%">
<el-option v-for="d in [7,8]" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="停止位">
<el-select v-model="form.StopBits" style="width: 100%">
<el-option :value="1" /><el-option :value="2" />
</el-select>
</el-form-item>
<el-form-item label="校验位">
<el-select v-model="form.Parity" style="width: 100%">
<el-option v-for="p in PARITY_OPTIONS" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
</template>
<!-- S7 额外参数 -->
<template v-if="form.ProtocolType === 3">
<el-form-item label="CPU 型号">
<el-select v-model="form.CpuType" style="width: 100%">
<el-option v-for="c in ['S71200','S71500','S7300','S7400']" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="Rack">
<el-input-number v-model="form.Rack" :min="0" :max="7" />
</el-form-item>
<el-form-item label="Slot">
<el-input-number v-model="form.Slot" :min="0" :max="31" />
</el-form-item>
</template>
<el-form-item label="启用">
<el-switch v-model="form.IsEnabled" />
</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>
<!-- 网关详情抽屉 -->
<el-drawer v-model="detailVisible" :title="`网关详情 - ${detailRow?.Name || ''}`" size="720px">
<el-tabs v-if="detailRow" v-model="detailTab">
<el-tab-pane label="基本信息" name="info">
<el-descriptions :column="2" border>
<el-descriptions-item label="网关编码">{{ detailRow.Code }}</el-descriptions-item>
<el-descriptions-item label="网关名称">{{ detailRow.Name }}</el-descriptions-item>
<el-descriptions-item label="协议类型">{{ protocolLabel(detailRow.ProtocolType) }}</el-descriptions-item>
<el-descriptions-item label="连接地址">{{ connAddress(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="在线状态">
<el-tag :type="onlineStatusTag(detailRow.OnlineStatus)" size="small">{{ onlineStatusLabel(detailRow.OnlineStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最后连接">{{ fmtTime(detailRow.LastConnectedTime) }}</el-descriptions-item>
<el-descriptions-item label="最近错误" :span="2">{{ detailRow.LastError || '-' }}</el-descriptions-item>
<el-descriptions-item label="启用">{{ detailRow.IsEnabled ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detailRow.Remark || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="下挂设备" name="devices">
<el-table :data="gatewayDevices" v-loading="devicesLoading" size="small" border stripe max-height="400">
<el-table-column prop="Code" label="设备编号" width="130" />
<el-table-column prop="Name" label="设备名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="SlaveId" label="从站号" width="80" align="center" />
<el-table-column label="在线" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.OnlineStatus === 1 ? 'success' : 'info'" size="small">{{ row.OnlineStatus === 1 ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!devicesLoading && gatewayDevices.length === 0" description="该网关下暂无设备" :image-size="60" />
</el-tab-pane>
</el-tabs>
</el-drawer>
</div> </div>
</template> </template>
<script setup> <script setup>
// TODO: 实现网关管理功能 import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getGatewayList, addGateway, updateGateway, deleteGateway, testGatewayConnection,
protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag, PARITY_OPTIONS
} from '@/api/gateway'
import { getDeviceList } from '@/api/device'
// ---------- 列表 ----------
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const searchProtocol = ref(null)
async function fetchList() {
loading.value = true
try {
list.value = (await getGatewayList({
pageIndex: page.value, pageSize: pageSize.value,
keyword: keyword.value || undefined,
protocolType: searchProtocol.value || undefined
})) || []
total.value = list.value.total || 0
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onSearch() { page.value = 1; fetchList() }
function onReset() { keyword.value = ''; searchProtocol.value = null; page.value = 1; fetchList() }
function onSizeChange() { page.value = 1; fetchList() }
function connAddress(row) {
if ([1, 3, 4].includes(row.ProtocolType)) return row.Host && row.Port ? `${row.Host}:${row.Port}` : '-'
if ([2, 5].includes(row.ProtocolType)) return row.ComPort ? `${row.ComPort} @${row.BaudRate || 9600}` : '-'
return '-'
}
// ---------- 测试连接 ----------
async function onTest(row) {
row._testing = true
try {
const res = await testGatewayConnection(row.Id)
ElMessage({ type: res.Success ? 'success' : 'error', message: res.Message || (res.Success ? '连接成功' : '连接失败') })
fetchList()
} catch (e) { /* 拦截器已提示 */ } finally { row._testing = false }
}
// ---------- 新增/修改 ----------
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref(null)
const defaultForm = () => ({
Id: '', Code: '', Name: '', ProtocolType: 1,
Host: '', Port: 502,
ComPort: 'COM1', BaudRate: 9600, DataBits: 8, StopBits: 1, Parity: 0,
CpuType: 'S71200', Rack: 0, Slot: 1,
IsEnabled: true, Remark: ''
})
const form = reactive(defaultForm())
const isTcpProtocol = computed(() => [1, 3, 4].includes(form.ProtocolType))
const isSerialProtocol = computed(() => [2, 5].includes(form.ProtocolType))
function onProtocolChange() {
// 切协议时重置连接参数
form.Host = ''; form.Port = 502
form.ComPort = 'COM1'; form.BaudRate = 9600
form.CpuType = 'S71200'; form.Rack = 0; form.Slot = 1
}
const formRules = {
Code: [{ required: true, message: '请输入网关编码', trigger: 'blur' }],
Name: [{ required: true, message: '请输入网关名称', trigger: 'blur' }],
ProtocolType: [{ required: true, message: '请选择协议类型', trigger: 'change' }]
}
function openAdd() { Object.assign(form, defaultForm()); dialogVisible.value = true }
function openEdit(row) {
Object.assign(form, defaultForm(), {
Id: row.Id, Code: row.Code, Name: row.Name, ProtocolType: row.ProtocolType,
Host: row.Host || '', Port: row.Port || 502,
ComPort: row.ComPort || 'COM1', BaudRate: row.BaudRate || 9600,
DataBits: row.DataBits || 8, StopBits: row.StopBits || 1, Parity: row.Parity || 0,
CpuType: row.CpuType || 'S71200', Rack: row.Rack ?? 0, Slot: row.Slot ?? 1,
IsEnabled: row.IsEnabled, Remark: row.Remark || ''
})
dialogVisible.value = true
}
async function onSubmit() {
await formRef.value.validate()
saving.value = true
try {
if (form.Id) { await updateGateway({ ...form }); ElMessage.success('修改成功') }
else { const payload = { ...form }; delete payload.Id; await addGateway(payload); ElMessage.success('添加成功') }
dialogVisible.value = false; fetchList()
} catch {} finally { saving.value = false }
}
// ---------- 删除 ----------
async function onDelete(row) {
await ElMessageBox.confirm(`确定删除网关「${row.Name}」吗?该网关下设备的网关关联将被清除。`, '提示', { type: 'warning' })
try { await deleteGateway(row.Id); ElMessage.success('删除成功'); fetchList() } catch {}
}
// ---------- 详情 ----------
const detailVisible = ref(false)
const detailTab = ref('info')
const detailRow = ref(null)
const gatewayDevices = ref([])
const devicesLoading = ref(false)
function openDetail(row) {
detailRow.value = row
detailTab.value = 'info'
detailVisible.value = true
fetchGatewayDevices(row.Id)
}
async function fetchGatewayDevices(gatewayId) {
devicesLoading.value = true
try {
// 复用设备列表接口,按 gatewayId 筛选(需后端支持)
gatewayDevices.value = (await getDeviceList({ pageIndex: 1, pageSize: 200, gatewayId })) || []
} catch { gatewayDevices.value = [] } finally { devicesLoading.value = false }
}
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
onMounted(() => fetchList())
</script> </script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 10px; margin-bottom: 16px; }
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
</style>