我看着 iot部分属性保存和场景规则匹配是并行执行的, Redis中可能还没有最新数据,我现在已经实现了modbus rtu/tcp的定时轮询和消息匹配 (hex格式)和场景联动下发命令,但是有个问题,如果使用这个场景规则匹配,那我只能轮询一个物模型
然后我现在是这样做的:设备 hex数据——网关解码——网关转换 (网关组装)——消息总线——场景联动 然后对于设备发送的 json数据————网关转发——场景联动 不知道这样去做是否可行,现在这部分已经完成,测试可行,暂时没有用到生产pull/842/head
parent
423af80a82
commit
7a196bddd5
|
|
@ -16,6 +16,8 @@ export interface ProductVO {
|
|||
locationType: number // 设备类型
|
||||
netType: number // 联网方式
|
||||
codecType: string // 数据格式(编解码器类型)
|
||||
protocolType?: string // 协议类型(STANDARD, MODBUS_RTU, MODBUS_TCP)
|
||||
modbusSlaveId?: number // Modbus从站ID(1-247)
|
||||
deviceCount: number // 设备数量
|
||||
createTime: Date // 创建时间
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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="请输入从站ID(1-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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(设备ID)、tenant_id(租户ID)、report_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>
|
||||
|
|
@ -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()
|
||||
// 不需要手动调用 updateModelValue,watch 会自动触发
|
||||
}
|
||||
|
||||
/** 移除项目 */
|
||||
const removeItem = (index: number) => {
|
||||
items.value.splice(index, 1)
|
||||
updateModelValue()
|
||||
// 不需要手动调用 updateModelValue,watch 会自动触发
|
||||
}
|
||||
|
||||
/** 更新 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="如:65280(0xFF00闭合)或 0(断开)"
|
||||
clearable
|
||||
/>
|
||||
<div class="form-item-tip">
|
||||
功能码05:65280(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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue