我看着 iot部分属性保存和场景规则匹配是并行执行的, Redis中可能还没有最新数据,我现在已经实现了modbus rtu/tcp的定时轮询和消息匹配 (hex格式)和场景联动下发命令,但是有个问题,如果使用这个场景规则匹配,那我只能轮询一个物模型

然后我现在是这样做的:设备 hex数据——网关解码——网关转换 (网关组装)——消息总线——场景联动
然后对于设备发送的 json数据————网关转发——场景联动
不知道这样去做是否可行,现在这部分已经完成,测试可行,暂时没有用到生产
pull/842/head
yhl186 2025-12-12 14:06:27 +08:00
parent 423af80a82
commit 7a196bddd5
11 changed files with 819 additions and 14 deletions

View File

@ -16,6 +16,8 @@ export interface ProductVO {
locationType: number // 设备类型
netType: number // 联网方式
codecType: string // 数据格式(编解码器类型)
protocolType?: string // 协议类型STANDARD, MODBUS_RTU, MODBUS_TCP
modbusSlaveId?: number // Modbus从站ID1-247
deviceCount: number // 设备数量
createTime: Date // 创建时间
}

View File

@ -11,6 +11,7 @@ export interface DataSinkVO {
config?:
| HttpConfig
| MqttConfig
| DatabaseConfig
| RocketMQConfig
| KafkaMQConfig
| RabbitMQConfig
@ -79,6 +80,17 @@ export interface RedisStreamMQConfig extends Config {
topic: string
}
/** Database 配置 */
export interface DatabaseConfig extends Config {
url: string // 数据库连接 URL
username: string // 数据库用户名
password: string // 数据库密码
driverClassName: string // 数据库驱动
tableName: string // 目标表名
fieldMapping: Record<string, string> // 字段映射
saveMetadata: boolean // 是否保存元数据
}
/** 数据流转目的类型 */
export const IotDataSinkTypeEnum = {
HTTP: 1,
@ -122,5 +134,31 @@ export const DataSinkApi = {
// 查询数据流转目的(精简)列表
getDataSinkSimpleList() {
return request.get({ url: '/iot/data-sink/simple-list' })
},
// 自动创建数据库表
autoCreateTable: async (data: {
url: string
username: string
password: string
driverClassName: string
tableName: string
fieldMapping: Record<string, string>
saveMetadata: boolean
}) => {
return await request.post({ url: `/iot/data-sink/database/auto-create-table`, data })
},
// 同步数据库表结构
syncTableStructure: async (data: {
url: string
username: string
password: string
driverClassName: string
tableName: string
fieldMapping: Record<string, string>
saveMetadata: boolean
}) => {
return await request.post({ url: `/iot/data-sink/database/sync-table-structure`, data })
}
}

View File

@ -78,6 +78,18 @@ export interface ThingModelProperty {
description?: string
dataSpecs?: ThingModelProperty
dataSpecsList?: ThingModelProperty[]
modbusConfig?: ModbusConfig
}
/** Modbus 配置 */
export interface ModbusConfig {
collectionMode?: number // 数据采集模式1-定时轮询2-主动上报
functionCode?: number // 功能码0x03-读保持寄存器0x04-读输入寄存器等默认0x03
registerAddress?: number // 寄存器地址(低位字节)
registerCount?: number // 寄存器数量 / 线圈数量默认1
pollingInterval?: number // 采集频率(秒)
fullFrame?: string // 完整的数据帧自动生成的HEX字符串
crcHex?: string // CRC 校验码自动生成的HEX字符串
}
/** 物模型事件 */
@ -101,6 +113,7 @@ export interface ThingModelService {
inputParams?: ThingModelParam[]
outputParams?: ThingModelParam[]
method?: string
modbusConfig?: ModbusConfig // 添加 Modbus 配置
}
/** 物模型参数 */
@ -197,6 +210,11 @@ export const ThingModelApi = {
// 删除产品物模型
deleteThingModel: async (id: number) => {
return await request.delete({ url: `/iot/thing-model/delete?id=` + id })
},
// 计算 Modbus 命令(用于前端自动生成)
calculateModbusCommand: async (data: { productId: number; registerAddress: number; functionCode?: number }) => {
return await request.post({ url: `/iot/thing-model/calculate-modbus-command`, data })
}
}

View File

@ -74,7 +74,7 @@
</el-radio-group>
</el-form-item>
<el-form-item label="数据格式" prop="codecType">
<el-radio-group v-model="formData.codecType" :disabled="formType === 'update'">
<el-radio-group v-model="formData.codecType" :disabled="formType === 'update' || isModbusProtocol">
<el-radio
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_CODEC_TYPE)"
:key="dict.value"
@ -83,6 +83,27 @@
{{ dict.label }}
</el-radio>
</el-radio-group>
<div v-if="isModbusProtocol" class="form-tip">Modbus 使 HEX </div>
</el-form-item>
<el-form-item label="协议类型" prop="protocolType">
<el-select v-model="formData.protocolType" placeholder="请选择协议类型" clearable @change="handleProtocolTypeChange">
<el-option label="标准协议JSON" value="STANDARD" />
<el-option label="Modbus RTU" value="MODBUS_RTU" />
<el-option label="Modbus TCP" value="MODBUS_TCP" />
</el-select>
</el-form-item>
<el-form-item
v-if="isModbusProtocol"
label="Modbus从站ID"
prop="modbusSlaveId"
>
<el-input-number
v-model="formData.modbusSlaveId"
:min="1"
:max="247"
placeholder="请输入从站ID1-247"
/>
<div class="form-tip">默认值1</div>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
@ -132,7 +153,9 @@ const formData = ref({
deviceType: undefined,
locationType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
protocolType: 'STANDARD',
modbusSlaveId: 1
})
const formRules = reactive({
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
@ -152,6 +175,26 @@ const formRules = reactive({
const formRef = ref()
const categoryList = ref<ProductCategoryVO[]>([]) //
// Modbus
const isModbusProtocol = computed(() => {
return formData.value.protocolType === 'MODBUS_RTU' || formData.value.protocolType === 'MODBUS_TCP'
})
//
const handleProtocolTypeChange = (value: string) => {
if (value === 'MODBUS_RTU' || value === 'MODBUS_TCP') {
// Modbus HEX
formData.value.codecType = 'HEX'
// ID
if (!formData.value.modbusSlaveId) {
formData.value.modbusSlaveId = 1
}
} else {
// 使 Alink
formData.value.codecType = CodecTypeEnum.ALINK
}
}
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
@ -208,7 +251,9 @@ const resetForm = () => {
deviceType: undefined,
locationType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
protocolType: 'STANDARD',
modbusSlaveId: 1
}
formRef.value?.resetFields()
}
@ -218,3 +263,11 @@ const generateProductKey = () => {
formData.value.productKey = generateRandomStr(16)
}
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
</style>

View File

@ -25,6 +25,10 @@
</el-form-item>
<HttpConfigForm v-if="IotDataSinkTypeEnum.HTTP === formData.type" v-model="formData.config" />
<MqttConfigForm v-if="IotDataSinkTypeEnum.MQTT === formData.type" v-model="formData.config" />
<DatabaseConfigForm
v-if="IotDataSinkTypeEnum.DATABASE === formData.type"
v-model="formData.config"
/>
<RocketMQConfigForm
v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
v-model="formData.config"
@ -65,6 +69,7 @@ import { CommonStatusEnum } from '@/utils/constants'
import { DataSinkApi, DataSinkVO, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
import {
HttpConfigForm,
DatabaseConfigForm,
KafkaMQConfigForm,
MqttConfigForm,
RabbitMQConfigForm,
@ -122,7 +127,10 @@ const formRules = reactive({
'config.database': [
{ required: true, message: '数据库索引不能为空', trigger: 'blur' },
{ type: 'number', min: 0, message: '数据库索引必须是非负整数', trigger: 'blur' }
]
],
// Database
'config.tableName': [{ required: true, message: '目标表名不能为空', trigger: 'blur' }],
'config.driverClassName': [{ required: true, message: '数据库驱动不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref

View File

@ -0,0 +1,367 @@
<template>
<el-form-item label="数据库地址" prop="config.url">
<el-input v-model="jdbcUrl" placeholder="请输入数据库地址localhost:3306/ruoyi-vue-pro">
<template #prepend>
<el-select v-model="dbType" placeholder="Select" style="width: 115px">
<el-option label="MySQL" value="mysql" />
<el-option label="PostgreSQL" value="postgresql" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户名" prop="config.username">
<el-input v-model="config.username" placeholder="请输入数据库用户名" />
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input v-model="config.password" type="password" placeholder="请输入数据库密码" show-password />
</el-form-item>
<el-form-item label="目标表名" prop="config.tableName">
<el-input
v-model="config.tableName"
placeholder="请输入目标表名iot_device_data"
@blur="validateTableName"
>
<template #append>
<el-button
@click="handleAutoCreateTable"
:loading="createTableLoading"
:disabled="!canAutoCreateTable"
:title="canAutoCreateTableTip"
>
自动建表
</el-button>
</template>
</el-input>
<div v-if="tableNameError" class="form-error">{{ tableNameError }}</div>
<div v-else class="form-tip">如果配置了字段映射可点击自动建表根据字段映射自动创建数据库表</div>
</el-form-item>
<el-form-item label="字段映射" prop="config.fieldMapping">
<div class="flex items-center mb-2">
<el-button
@click="handleSyncTableStructure"
type="warning"
plain
size="small"
:loading="syncTableLoading"
:disabled="!canSyncTableStructure"
:title="syncTableStructureTip"
>
同步表结构
</el-button>
<span class="ml-2 text-xs text-gray-500">根据字段映射自动添加数据库表的列</span>
</div>
<key-value-editor
v-model="config.fieldMapping"
add-button-text="添加字段映射"
key-placeholder="设备消息字段"
value-placeholder="数据库列名"
/>
<div class="form-tip">可选配置如果不配置将使用设备消息中的字段名作为数据库列名</div>
</el-form-item>
<el-form-item label="保存元数据" prop="config.saveMetadata">
<el-switch v-model="config.saveMetadata" />
<div class="form-tip">
启用后会自动保存设备元数据device_id设备IDtenant_id租户IDreport_time上报时间
</div>
</el-form-item>
</template>
<script lang="ts" setup>
import { DatabaseConfig, IotDataSinkTypeEnum, DataSinkApi } from '@/api/iot/rule/data/sink'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import KeyValueEditor from './components/KeyValueEditor.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({ name: 'DatabaseConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<DatabaseConfig>
/** 数据库类型 */
const dbType = ref('mysql')
const jdbcUrl = ref('')
const createTableLoading = ref(false)
const syncTableLoading = ref(false)
const tableNameError = ref('')
/**
* 验证标识符表名列名是否合法
* 只允许字母数字下划线且不能以数字开头
*/
const validateIdentifier = (identifier: string, fieldName: string): { valid: boolean; message?: string } => {
if (!identifier) {
return { valid: false, message: `${fieldName}不能为空` }
}
//
if (identifier.length > 64) {
return { valid: false, message: `${fieldName}长度不能超过 64 个字符` }
}
// 线
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
return { valid: false, message: `${fieldName}只能包含字母、数字、下划线,且不能以数字开头` }
}
// SQL
const sqlKeywords = [
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE',
'GRANT', 'REVOKE', 'EXEC', 'EXECUTE', 'UNION', 'WHERE', 'AND', 'OR', 'NOT',
'TABLE', 'DATABASE', 'SCHEMA', 'INDEX', 'VIEW', 'PROCEDURE', 'FUNCTION'
]
if (sqlKeywords.includes(identifier.toUpperCase())) {
return { valid: false, message: `${fieldName}不能使用 SQL 关键字` }
}
return { valid: true }
}
/** 验证表名 */
const validateTableName = () => {
if (!config.value.tableName) {
tableNameError.value = ''
return
}
const validation = validateIdentifier(config.value.tableName, '表名')
tableNameError.value = validation.valid ? '' : validation.message || ''
}
/** 判断是否可以自动建表 */
const canAutoCreateTable = computed(() => {
return config.value.tableName &&
config.value.url &&
config.value.username &&
!isEmpty(config.value.fieldMapping) //
})
/** 自动建表提示 */
const canAutoCreateTableTip = computed(() => {
if (!config.value.tableName) return '请先填写表名'
if (!config.value.url) return '请先填写数据库连接信息'
if (!config.value.username) return '请先填写数据库用户名'
if (isEmpty(config.value.fieldMapping)) return '请先配置字段映射'
return '点击自动创建数据库表'
})
/** 判断是否可以同步表结构 */
const canSyncTableStructure = computed(() => {
return config.value.tableName &&
config.value.url &&
config.value.username &&
!isEmpty(config.value.fieldMapping)
})
/** 同步表结构提示 */
const syncTableStructureTip = computed(() => {
if (!config.value.tableName) return '请先填写表名'
if (!config.value.url) return '请先填写数据库连接信息'
if (!config.value.username) return '请先填写数据库用户名'
if (isEmpty(config.value.fieldMapping)) return '请先配置字段映射'
return '点击同步数据库表结构,根据字段映射自动添加新列'
})
/** 完整的 JDBC URL */
const fullJdbcUrl = computed(() => {
if (!jdbcUrl.value) return ''
const urlParams = 'useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8'
if (dbType.value === 'mysql') {
return `jdbc:mysql://${jdbcUrl.value}?${urlParams}`
} else if (dbType.value === 'postgresql') {
return `jdbc:postgresql://${jdbcUrl.value}`
}
return jdbcUrl.value
})
/** 数据库驱动类名 */
const driverClassName = computed(() => {
if (dbType.value === 'mysql') {
return 'com.mysql.cj.jdbc.Driver'
} else if (dbType.value === 'postgresql') {
return 'org.postgresql.Driver'
}
return ''
})
/** 自动创建数据库表 */
const handleAutoCreateTable = async () => {
if (!canAutoCreateTable.value) {
ElMessage.warning('请先完善数据库连接信息、表名和字段映射')
return
}
//
const tableNameValidation = validateIdentifier(config.value.tableName, '表名')
if (!tableNameValidation.valid) {
ElMessage.error(tableNameValidation.message)
return
}
//
if (config.value.fieldMapping) {
for (const [key, columnName] of Object.entries(config.value.fieldMapping)) {
const validation = validateIdentifier(columnName, `列名 "${columnName}"`)
if (!validation.valid) {
ElMessage.error(validation.message)
return
}
}
}
try {
await ElMessageBox.confirm(
`确定要在数据库中创建表 "${config.value.tableName}" 吗?`,
'确认创建',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
createTableLoading.value = true
await DataSinkApi.autoCreateTable({
url: config.value.url,
username: config.value.username,
password: config.value.password,
driverClassName: config.value.driverClassName,
tableName: config.value.tableName,
fieldMapping: config.value.fieldMapping,
saveMetadata: config.value.saveMetadata
})
ElMessage.success('数据库表创建成功')
} catch (error: any) {
if (error !== 'cancel') {
console.error('创建表失败:', error)
ElMessage.error(error.message || '创建表失败,请检查数据库连接和权限')
}
} finally {
createTableLoading.value = false
}
}
/** 同步数据库表结构 */
const handleSyncTableStructure = async () => {
if (!canSyncTableStructure.value) {
ElMessage.warning('请先完善数据库连接信息、表名和字段映射')
return
}
//
const tableNameValidation = validateIdentifier(config.value.tableName, '表名')
if (!tableNameValidation.valid) {
ElMessage.error(tableNameValidation.message)
return
}
//
if (config.value.fieldMapping) {
for (const [key, columnName] of Object.entries(config.value.fieldMapping)) {
const validation = validateIdentifier(columnName, `列名 "${columnName}"`)
if (!validation.valid) {
ElMessage.error(validation.message)
return
}
}
}
try {
await ElMessageBox.confirm(
`确定要同步表 "${config.value.tableName}" 的结构吗?
此操作将会
添加字段映射中新增的列
删除不在字段映射中的列系统列除外
删除列会导致数据丢失请谨慎操作
系统列不会删除
- id, create_time
- device_id, tenant_id, report_time如果启用保存元数据`,
'确认同步',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: false
}
)
syncTableLoading.value = true
await DataSinkApi.syncTableStructure({
url: config.value.url,
username: config.value.username,
password: config.value.password,
driverClassName: config.value.driverClassName,
tableName: config.value.tableName,
fieldMapping: config.value.fieldMapping,
saveMetadata: config.value.saveMetadata
})
ElMessage.success('数据库表结构同步成功')
} catch (error: any) {
if (error !== 'cancel') {
console.error('同步表结构失败:', error)
ElMessage.error(error.message || '同步表结构失败,请检查数据库连接和权限')
}
} finally {
syncTableLoading.value = false
}
}
/** 监听数据库类型和地址变化 */
watch([dbType, jdbcUrl], () => {
config.value.url = fullJdbcUrl.value
config.value.driverClassName = driverClassName.value
})
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
// JDBC URL
if (config.value.url) {
if (config.value.url.startsWith('jdbc:mysql://')) {
dbType.value = 'mysql'
const url = config.value.url.substring(13)
const questionMarkIndex = url.indexOf('?')
jdbcUrl.value = questionMarkIndex > 0 ? url.substring(0, questionMarkIndex) : url
} else if (config.value.url.startsWith('jdbc:postgresql://')) {
dbType.value = 'postgresql'
jdbcUrl.value = config.value.url.substring(18)
} else {
jdbcUrl.value = config.value.url
}
}
return
}
config.value = {
type: IotDataSinkTypeEnum.DATABASE + '', // 使
url: '',
username: '',
password: '',
driverClassName: 'com.mysql.cj.jdbc.Driver',
tableName: '',
fieldMapping: {},
saveMetadata: true
}
})
</script>
<style lang="scss" scoped>
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.form-error {
font-size: 12px;
color: #f56c6c;
margin-top: 5px;
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div v-for="(item, index) in items" :key="index" class="flex mb-2 w-full">
<el-input v-model="item.key" class="mr-2" placeholder="键" />
<el-input v-model="item.value" placeholder="值" />
<el-input v-model="item.key" class="mr-2" :placeholder="keyPlaceholder || ''" />
<el-input v-model="item.value" :placeholder="valuePlaceholder || ''" />
<el-button class="ml-2" text type="danger" @click="removeItem(index)">
<el-icon>
<Delete />
@ -31,20 +31,23 @@ interface KeyValueItem {
const props = defineProps<{
modelValue: Record<string, string>
addButtonText: string
keyPlaceholder?: string
valuePlaceholder?: string
}>()
const emit = defineEmits(['update:modelValue'])
const items = ref<KeyValueItem[]>([]) // key-value
const isInternalUpdate = ref(false) //
/** 添加项目 */
const addItem = () => {
items.value.push({ key: '', value: '' })
updateModelValue()
// updateModelValuewatch
}
/** 移除项目 */
const removeItem = (index: number) => {
items.value.splice(index, 1)
updateModelValue()
// updateModelValuewatch
}
/** 更新 modelValue */
@ -55,19 +58,43 @@ const updateModelValue = () => {
result[item.key] = item.value
}
})
isInternalUpdate.value = true
emit('update:modelValue', result)
nextTick(() => {
isInternalUpdate.value = false
})
}
/** 监听项目变化 */
watch(items, updateModelValue, { deep: true })
/** 监听 modelValue 变化,同步到内部 items */
watch(
() => props.modelValue,
(val) => {
//
if (isEmpty(val) || !isEmpty(items.value)) {
//
if (isInternalUpdate.value) {
return
}
items.value = Object.entries(props.modelValue).map(([key, value]) => ({ key, value }))
}
// null/undefined
if (isEmpty(val)) {
// items
if (items.value.length > 0) {
items.value = []
}
return
}
//
const newItems = Object.entries(val).map(([key, value]) => ({ key, value }))
//
const isDifferent = JSON.stringify(items.value) !== JSON.stringify(newItems)
if (isDifferent) {
items.value = newItems
}
},
{ immediate: true } //
)
</script>

View File

@ -1,5 +1,6 @@
import HttpConfigForm from './HttpConfigForm.vue'
import MqttConfigForm from './MqttConfigForm.vue'
import DatabaseConfigForm from './DatabaseConfigForm.vue'
import RocketMQConfigForm from './RocketMQConfigForm.vue'
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
@ -8,6 +9,7 @@ import RedisStreamConfigForm from './RedisStreamConfigForm.vue'
export {
HttpConfigForm,
MqttConfigForm,
DatabaseConfigForm,
RocketMQConfigForm,
KafkaMQConfigForm,
RabbitMQConfigForm,

View File

@ -93,6 +93,88 @@
</el-radio>
</el-radio-group>
</el-form-item>
<!-- Modbus 配置区域 -->
<div v-if="isModbusProduct && !isStructDataSpecs && !isParams" class="modbus-config-section">
<el-divider content-position="left">Modbus 配置</el-divider>
<!-- 数据采集模式 -->
<el-form-item label="数据采集模式" prop="property.modbusConfig.collectionMode">
<el-radio-group v-model="property.modbusConfig.collectionMode" @change="handleCollectionModeChange">
<el-radio
v-for="mode in Object.values(IoTModbusCollectionModeEnum)"
:key="mode.value"
:label="mode.value"
>
{{ mode.label }}
</el-radio>
</el-radio-group>
<div class="form-tip">{{ getCollectionModeDesc() }}</div>
</el-form-item>
<!-- 采集频率仅在定时轮询模式下显示 -->
<el-form-item
v-if="property.modbusConfig.collectionMode === IoTModbusCollectionModeEnum.POLLING.value"
label="采集频率"
prop="property.modbusConfig.pollingInterval"
>
<el-input-number
v-model="property.modbusConfig.pollingInterval"
:min="10"
:max="86400"
placeholder="请输入采集频率10-86400秒"
class="w-full"
/>
<div class="form-tip">采集频率范围10-86400默认300秒5分钟</div>
</el-form-item>
<!-- 功能码 -->
<el-form-item label="功能码" prop="property.modbusConfig.functionCode">
<el-input-number
v-model="property.modbusConfig.functionCode"
:min="1"
:max="255"
placeholder="请输入功能码1-255"
@change="handleFunctionCodeChange"
class="w-full"
/>
<div class="form-tip">常用0x03-读保持寄存器0x04-读输入寄存器0x01-读线圈0x02-读离散输入</div>
</el-form-item>
<!-- 寄存器地址 -->
<el-form-item label="寄存器地址" prop="property.modbusConfig.registerAddress">
<el-input-number
v-model="property.modbusConfig.registerAddress"
:min="0"
:max="65535"
placeholder="请输入寄存器地址0-65535"
@change="handleRegisterAddressChange"
class="w-full"
/>
</el-form-item>
<!-- 寄存器数量 / 线圈数量 -->
<el-form-item label="寄存器数量" prop="property.modbusConfig.registerCount">
<el-input-number
v-model="property.modbusConfig.registerCount"
:min="1"
:max="255"
placeholder="请输入寄存器数量1-255"
@change="handleRegisterCountChange"
class="w-full"
/>
<div class="form-tip">读取的寄存器或线圈的数量默认为 1</div>
</el-form-item>
<!-- 自动生成的命令只读显示 -->
<el-form-item label="完整数据帧" v-if="property.modbusConfig?.fullFrame">
<el-input
v-model="property.modbusConfig.fullFrame"
readonly
placeholder="自动生成"
/>
</el-form-item>
<el-form-item label="CRC校验码" v-if="property.modbusConfig?.crcHex">
<el-input
v-model="property.modbusConfig.crcHex"
readonly
placeholder="自动生成"
/>
</el-form-item>
</div>
</template>
<script lang="ts" setup>
@ -103,13 +185,18 @@ import {
ThingModelNumberDataSpecs,
ThingModelStructDataSpecs
} from './dataSpecs'
import { ThingModelProperty, validateBoolName } from '@/api/iot/thingmodel'
import { ThingModelApi, ThingModelProperty, validateBoolName } from '@/api/iot/thingmodel'
import { isEmpty } from '@/utils/is'
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
IoTThingModelAccessModeEnum
IoTThingModelAccessModeEnum,
IoTModbusCollectionModeEnum,
IOT_PROVIDE_KEY
} from '@/views/iot/utils/constants'
import { ProductVO } from '@/api/iot/product/product'
import { inject } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
/** IoT 物模型属性 */
defineOptions({ name: 'ThingModelProperty' })
@ -117,6 +204,14 @@ defineOptions({ name: 'ThingModelProperty' })
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean; isParams?: boolean }>()
const emits = defineEmits(['update:modelValue'])
const property = useVModel(props, 'modelValue', emits) as Ref<ThingModelProperty>
const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) //
const message = useMessage()
// Modbus
const isModbusProduct = computed(() => {
if (!product?.value) return false
return product.value.protocolType === 'MODBUS_RTU' || product.value.protocolType === 'MODBUS_TCP'
})
const getDataTypeOptions2 = computed(() => {
if (!props.isStructDataSpecs) {
return getDataTypeOptions()
@ -166,6 +261,100 @@ watch(
},
{ immediate: true }
)
/** 初始化 Modbus 配置 */
watch(
() => isModbusProduct.value,
(isModbus) => {
if (isModbus && !property.value.modbusConfig) {
property.value.modbusConfig = {
collectionMode: IoTModbusCollectionModeEnum.POLLING.value, //
functionCode: undefined, //
registerAddress: undefined,
registerCount: 1, // 1
pollingInterval: 300, // 300 5
fullFrame: '',
crcHex: ''
}
} else if (isModbus && property.value.modbusConfig && !property.value.modbusConfig.collectionMode) {
// collectionMode
property.value.modbusConfig.collectionMode = IoTModbusCollectionModeEnum.POLLING.value
}
// registerCount 1
if (isModbus && property.value.modbusConfig && !property.value.modbusConfig.registerCount) {
property.value.modbusConfig.registerCount = 1
}
// pollingInterval 300
if (isModbus && property.value.modbusConfig && !property.value.modbusConfig.pollingInterval) {
property.value.modbusConfig.pollingInterval = 300
}
},
{ immediate: true }
)
/** 寄存器地址变化时自动计算命令 */
const handleRegisterAddressChange = async (registerAddress: number | undefined) => {
if (!registerAddress || !product?.value?.id) {
return
}
try {
const { data } = await ThingModelApi.calculateModbusCommand({
productId: product.value.id,
registerAddress: registerAddress,
functionCode: property.value.modbusConfig?.functionCode || 3
})
//
if (!property.value.modbusConfig) {
property.value.modbusConfig = {}
}
property.value.modbusConfig.fullFrame = data.fullFrame
property.value.modbusConfig.crcHex = data.crcHex
message.success('Modbus 命令已自动生成')
} catch (error: any) {
console.error('生成 Modbus 命令失败', error)
message.error('生成命令失败:' + (error.message || '未知错误'))
}
}
/** 功能码变化时重新计算命令 */
const handleFunctionCodeChange = async (functionCode: number | undefined) => {
//
if (property.value.modbusConfig?.registerAddress) {
await handleRegisterAddressChange(property.value.modbusConfig.registerAddress)
}
}
/** 寄存器数量变化时重新计算命令 */
const handleRegisterCountChange = async (registerCount: number | undefined) => {
//
if (property.value.modbusConfig?.registerAddress) {
await handleRegisterAddressChange(property.value.modbusConfig.registerAddress)
}
}
/** 采集模式变化时的处理 */
const handleCollectionModeChange = (mode: number) => {
//
if (mode === IoTModbusCollectionModeEnum.POLLING.value) {
if (property.value.modbusConfig && !property.value.modbusConfig.pollingInterval) {
property.value.modbusConfig.pollingInterval = 300
}
}
}
/** 获取采集模式描述 */
const getCollectionModeDesc = () => {
const mode = property.value.modbusConfig?.collectionMode
if (mode === IoTModbusCollectionModeEnum.POLLING.value) {
return IoTModbusCollectionModeEnum.POLLING.desc
} else if (mode === IoTModbusCollectionModeEnum.ACTIVE_REPORT.value) {
return IoTModbusCollectionModeEnum.ACTIVE_REPORT.desc
}
return ''
}
</script>
<style lang="scss" scoped>
@ -174,4 +363,21 @@ watch(
margin-bottom: 0;
}
}
.modbus-config-section {
background-color: #f7f8fa;
padding: 15px;
border-radius: 4px;
margin-top: 20px;
}
.modbus-config-section .el-divider {
margin-top: 0;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
</style>

View File

@ -27,6 +27,52 @@
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
/>
</el-form-item>
<!-- Modbus 配置 -->
<el-divider content-position="left">Modbus 配置可选</el-divider>
<el-form-item label="完整帧">
<el-input
v-model="service.modbusConfig.fullFrame"
placeholder="十六进制完整帧40050000FF00832B"
clearable
/>
<div class="form-item-tip">
如果配置了完整帧将直接使用该帧发送命令推荐用于固定命令
</div>
</el-form-item>
<el-form-item label="功能码">
<el-input-number
v-model="service.modbusConfig.functionCode"
:min="1"
:max="127"
placeholder="如5写单个线圈"
clearable
/>
<div class="form-item-tip">
常用05=写单线圈06=写单寄存器15=写多线圈16=写多寄存器
</div>
</el-form-item>
<el-form-item label="寄存器地址">
<el-input-number
v-model="service.modbusConfig.registerAddress"
:min="0"
:max="65535"
placeholder="如0"
clearable
/>
</el-form-item>
<el-form-item label="写入值">
<el-input-number
v-model="service.modbusConfig.writeValue"
:min="0"
:max="65535"
placeholder="如652800xFF00闭合或 0断开"
clearable
/>
<div class="form-item-tip">
功能码0565280(0xFF00)=闭合0(0x0000)=断开
</div>
</el-form-item>
</template>
<script lang="ts" setup>
@ -46,6 +92,18 @@ const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
const emits = defineEmits(['update:modelValue'])
const service = useVModel(props, 'modelValue', emits) as Ref<ThingModelService>
/** 初始化 modbusConfig */
if (!service.value.modbusConfig) {
service.value.modbusConfig = {
functionCode: undefined,
registerAddress: undefined,
writeValue: undefined,
registerCount: undefined,
fullFrame: '',
crcHex: ''
}
}
/** 默认选中ASYNC 异步 */
watch(
() => service.value.callType,
@ -61,4 +119,10 @@ watch(
margin-bottom: 0;
}
}
.form-item-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
line-height: 1.5;
}
</style>

View File

@ -192,6 +192,26 @@ export const THING_MODEL_GROUP_LABELS = {
SERVICE: '设备服务'
} as const
/** IoT Modbus 数据采集模式枚举 */
export const IoTModbusCollectionModeEnum = {
POLLING: {
label: '定时轮询',
value: 1,
desc: '服务器定时主动发送 Modbus 命令采集数据'
},
ACTIVE_REPORT: {
label: '主动上报',
value: 2,
desc: '设备主动上报数据,服务器被动接收'
}
} as const
/** 获取 Modbus 采集模式标签 */
export const getModbusCollectionModeLabel = (value: number): string => {
const mode = Object.values(IoTModbusCollectionModeEnum).find((mode) => mode.value === value)
return mode?.label || String(value)
}
// IoT OTA 任务设备范围枚举
export const IoTOtaTaskDeviceScopeEnum = {
ALL: {