!863 !854 feat:【iot】接入 modbus 协议,重构所有协议的配置

Merge pull request !863 from 芋道源码/feature/iot
This commit is contained in:
芋道源码
2026-02-14 03:10:48 +00:00
committed by Gitee
16 changed files with 1233 additions and 38 deletions

View File

@@ -0,0 +1,31 @@
import request from '@/config/axios'
/** Modbus 连接配置 VO */
export interface DeviceModbusConfigVO {
id?: number // 主键
deviceId: number // 设备编号
ip: string // Modbus 服务器 IP 地址
port: number // Modbus 服务器端口
slaveId: number // 从站地址
timeout: number // 连接超时时间,单位:毫秒
retryInterval: number // 重试间隔,单位:毫秒
mode: number // 模式
frameFormat: number // 帧格式
status: number // 状态
}
/** Modbus 连接配置 API */
export const DeviceModbusConfigApi = {
/** 获取设备的 Modbus 连接配置 */
getModbusConfig: async (deviceId: number) => {
return await request.get<DeviceModbusConfigVO>({
url: `/iot/device-modbus-config/get`,
params: { deviceId }
})
},
/** 保存 Modbus 连接配置 */
saveModbusConfig: async (data: DeviceModbusConfigVO) => {
return await request.post({ url: `/iot/device-modbus-config/save`, data })
}
}

View File

@@ -0,0 +1,48 @@
import request from '@/config/axios'
/** Modbus 点位配置 VO */
export interface DeviceModbusPointVO {
id?: number // 主键
deviceId: number // 设备编号
thingModelId?: number // 物模型属性编号
identifier: string // 属性标识符
name: string // 属性名称
functionCode?: number // Modbus 功能码
registerAddress?: number // 寄存器起始地址
registerCount?: number // 寄存器数量
byteOrder?: string // 字节序
rawDataType?: string // 原始数据类型
scale: number // 缩放因子
pollInterval: number // 轮询间隔,单位:毫秒
status: number // 状态
}
/** Modbus 点位配置 API */
export const DeviceModbusPointApi = {
/** 获取设备的 Modbus 点位分页 */
getModbusPointPage: async (params: any) => {
return await request.get({ url: `/iot/device-modbus-point/page`, params })
},
/** 获取 Modbus 点位详情 */
getModbusPoint: async (id: number) => {
return await request.get<DeviceModbusPointVO>({
url: `/iot/device-modbus-point/get?id=${id}`
})
},
/** 创建 Modbus 点位配置 */
createModbusPoint: async (data: DeviceModbusPointVO) => {
return await request.post({ url: `/iot/device-modbus-point/create`, data })
},
/** 更新 Modbus 点位配置 */
updateModbusPoint: async (data: DeviceModbusPointVO) => {
return await request.put({ url: `/iot/device-modbus-point/update`, data })
},
/** 删除 Modbus 点位配置 */
deleteModbusPoint: async (id: number) => {
return await request.delete({ url: `/iot/device-modbus-point/delete?id=${id}` })
}
}

View File

@@ -16,7 +16,8 @@ export interface ProductVO {
status: number // 产品状态
deviceType: number // 设备类型
netType: number // 联网方式
codecType: string // 数据格式(编解码器类型
protocolType: string // 协议类型
serializeType: string // 序列化类型
deviceCount: number // 设备数量
createTime: Date // 创建时间
}
@@ -27,9 +28,23 @@ export enum DeviceTypeEnum {
GATEWAY_SUB = 1, // 网关子设备
GATEWAY = 2 // 网关设备
}
// IOT 数据格式(编解码器类型枚举
export enum CodecTypeEnum {
ALINK = 'Alink' // 阿里云 Alink 协议
// IoT 协议类型枚举
export enum ProtocolTypeEnum {
TCP = 'tcp',
UDP = 'udp',
WEBSOCKET = 'websocket',
HTTP = 'http',
MQTT = 'mqtt',
EMQX = 'emqx',
COAP = 'coap',
MODBUS_TCP_CLIENT = 'modbus_tcp_client',
MODBUS_TCP_SERVER = 'modbus_tcp_server'
}
// IoT 序列化类型枚举
export enum SerializeTypeEnum {
JSON = 'json',
BINARY = 'binary'
}
// IoT 产品 API

View File

@@ -233,7 +233,8 @@ export enum DICT_TYPE {
IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
IOT_CODEC_TYPE = 'iot_codec_type', // IOT 数据格式(编解码器类型
IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 协议类型
IOT_SERIALIZE_TYPE = 'iot_serialize_type', // IOT 序列化类型
IOT_LOCATION_TYPE = 'iot_location_type', // IOT 定位类型
IOT_DEVICE_STATE = 'iot_device_state', // IOT 设备状态
IOT_THING_MODEL_TYPE = 'iot_thing_model_type', // IOT 产品功能类型
@@ -247,5 +248,7 @@ export enum DICT_TYPE {
IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IoT 告警接收类型
IOT_OTA_TASK_DEVICE_SCOPE = 'iot_ota_task_device_scope', // IoT OTA任务设备范围
IOT_OTA_TASK_STATUS = 'iot_ota_task_status', // IoT OTA 任务状态
IOT_OTA_TASK_RECORD_STATUS = 'iot_ota_task_record_status' // IoT OTA 记录状态
IOT_OTA_TASK_RECORD_STATUS = 'iot_ota_task_record_status', // IoT OTA 记录状态
IOT_MODBUS_MODE = 'iot_modbus_mode', // IoT Modbus 工作模式
IOT_MODBUS_FRAME_FORMAT = 'iot_modbus_frame_format' // IoT Modbus 帧格式
}

View File

@@ -0,0 +1,292 @@
<!-- Modbus 配置 -->
<template>
<div>
<!-- 连接配置区域 -->
<ContentWrap>
<div class="flex items-center justify-between mb-4">
<span class="text-lg font-medium">连接配置</span>
<el-button type="primary" @click="handleEditConfig" v-hasPermi="['iot:device:create']">
编辑
</el-button>
</div>
<!-- 详情展示 -->
<el-descriptions :column="3" border direction="horizontal">
<!-- Client 模式专有字段 -->
<template v-if="isClient">
<el-descriptions-item label="IP 地址">
{{ modbusConfig.ip || '-' }}
</el-descriptions-item>
<el-descriptions-item label="端口">
{{ modbusConfig.port || '-' }}
</el-descriptions-item>
</template>
<!-- 公共字段 -->
<el-descriptions-item label="从站地址">
{{ modbusConfig.slaveId || '-' }}
</el-descriptions-item>
<!-- Client 模式专有字段 -->
<template v-if="isClient">
<el-descriptions-item label="连接超时">
{{ modbusConfig.timeout ? `${modbusConfig.timeout} ms` : '-' }}
</el-descriptions-item>
<el-descriptions-item label="重试间隔">
{{ modbusConfig.retryInterval ? `${modbusConfig.retryInterval} ms` : '-' }}
</el-descriptions-item>
</template>
<!-- Server 模式专有字段 -->
<template v-if="isServer">
<el-descriptions-item label="工作模式">
<dict-tag :type="DICT_TYPE.IOT_MODBUS_MODE" :value="modbusConfig.mode" />
</el-descriptions-item>
<el-descriptions-item label="帧格式">
<dict-tag :type="DICT_TYPE.IOT_MODBUS_FRAME_FORMAT" :value="modbusConfig.frameFormat" />
</el-descriptions-item>
</template>
<!-- 公共字段 -->
<el-descriptions-item label="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="modbusConfig.status" />
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
<!-- 点位配置区域 -->
<ContentWrap class="mt-4">
<div class="flex items-center justify-between mb-4">
<span class="text-lg font-medium">点位配置</span>
<el-button type="primary" @click="handleAddPoint" v-hasPermi="['iot:device:create']">
<Icon icon="ep:plus" class="mr-1" />
新增点位
</el-button>
</div>
<!-- 搜索栏 -->
<el-form :model="queryParams" :inline="true" class="-mb-15px">
<el-form-item label="属性名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入属性名称"
clearable
class="!w-200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="标识符" prop="identifier">
<el-input
v-model="queryParams.identifier"
placeholder="请输入标识符"
clearable
class="!w-200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
</el-form-item>
</el-form>
<!-- 点位列表 -->
<el-table v-loading="pointLoading" :data="pointList" :stripe="true" class="mt-4">
<el-table-column label="属性名称" align="center" prop="name" min-width="100" />
<el-table-column label="标识符" align="center" prop="identifier" min-width="100">
<template #default="scope">
<el-tag size="small" type="primary">{{ scope.row.identifier }}</el-tag>
</template>
</el-table-column>
<el-table-column label="功能码" align="center" prop="functionCode" min-width="140">
<template #default="scope">
{{ formatFunctionCode(scope.row.functionCode) }}
</template>
</el-table-column>
<el-table-column label="寄存器地址" align="center" prop="registerAddress" min-width="100">
<template #default="scope">
{{ formatRegisterAddress(scope.row.registerAddress) }}
</template>
</el-table-column>
<el-table-column label="寄存器数量" align="center" prop="registerCount" min-width="90" />
<el-table-column label="数据类型" align="center" prop="rawDataType" min-width="90">
<template #default="scope">
<el-tag size="small" type="info">{{ scope.row.rawDataType }}</el-tag>
</template>
</el-table-column>
<el-table-column label="字节序" align="center" prop="byteOrder" min-width="80" />
<el-table-column label="缩放因子" align="center" prop="scale" min-width="80" />
<el-table-column label="轮询间隔" align="center" prop="pollInterval" min-width="90">
<template #default="scope"> {{ scope.row.pollInterval }} ms </template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" min-width="80">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="120">
<template #default="scope">
<el-button
link
type="primary"
@click="handleEditPoint(scope.row)"
v-hasPermi="['iot:device:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDeletePoint(scope.row.id, scope.row.name)"
v-hasPermi="['iot:device:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getPointPage"
/>
</ContentWrap>
<!-- 连接配置弹窗 -->
<DeviceModbusConfigForm
ref="configFormRef"
:device-id="device.id"
:protocol-type="product.protocolType"
@success="getModbusConfig"
/>
<!-- 点位表单弹窗 -->
<DeviceModbusPointForm
ref="pointFormRef"
:device-id="device.id"
:thing-model-list="thingModelList"
@success="getPointPage"
/>
</div>
</template>
<script lang="ts" setup>
import { DeviceVO } from '@/api/iot/device/device'
import { ProductVO, ProtocolTypeEnum } from '@/api/iot/product/product'
import { ThingModelData } from '@/api/iot/thingmodel'
import { DeviceModbusConfigApi, DeviceModbusConfigVO } from '@/api/iot/device/modbus/config'
import { DeviceModbusPointApi, DeviceModbusPointVO } from '@/api/iot/device/modbus/point'
import { ModbusFunctionCodeOptions } from '@/views/iot/utils/constants'
import { DICT_TYPE } from '@/utils/dict'
import DeviceModbusConfigForm from './DeviceModbusConfigForm.vue'
import DeviceModbusPointForm from './DeviceModbusPointForm.vue'
defineOptions({ name: 'DeviceModbusConfig' })
const props = defineProps<{
device: DeviceVO
product: ProductVO
thingModelList: ThingModelData[]
}>()
const message = useMessage()
// ======================= 连接配置 =======================
const isClient = computed(() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_CLIENT) // 是否为 Client 模式
const isServer = computed(() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_SERVER) // 是否为 Server 模式
const modbusConfig = ref<DeviceModbusConfigVO>({} as DeviceModbusConfigVO)
/** 获取连接配置 */
const getModbusConfig = async () => {
modbusConfig.value = await DeviceModbusConfigApi.getModbusConfig(props.device.id)
}
/** 编辑连接配置 */
const configFormRef = ref()
const handleEditConfig = () => {
configFormRef.value?.open(modbusConfig.value)
}
// ======================= 点位配置 =======================
const pointLoading = ref(false)
const pointList = ref<DeviceModbusPointVO[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceId: props.device.id,
name: undefined as string | undefined,
identifier: undefined as string | undefined
})
/** 获取点位分页 */
const getPointPage = async () => {
pointLoading.value = true
try {
const data = await DeviceModbusPointApi.getModbusPointPage(queryParams)
pointList.value = data.list
total.value = data.total
} finally {
pointLoading.value = false
}
}
/** 搜索 */
const handleQuery = () => {
queryParams.pageNo = 1
getPointPage()
}
/** 重置搜索 */
const resetQuery = () => {
queryParams.name = undefined
queryParams.identifier = undefined
handleQuery()
}
/** 格式化功能码 */
const formatFunctionCode = (code: number) => {
const option = ModbusFunctionCodeOptions.find((item) => item.value === code)
return option ? option.label : `${code}`
}
/** 格式化寄存器地址为十六进制 */
const formatRegisterAddress = (address: number) => {
return '0x' + address.toString(16).toUpperCase().padStart(4, '0')
}
/** 新增点位 */
const pointFormRef = ref()
const handleAddPoint = () => {
pointFormRef.value?.open('create')
}
/** 编辑点位 */
const handleEditPoint = (row: DeviceModbusPointVO) => {
pointFormRef.value?.open('update', row.id)
}
/** 删除点位 */
const handleDeletePoint = async (id: number, name: string) => {
try {
// 删除的二次确认
await message.delConfirm('确定要删除点位【' + name + '】吗?')
// 发起删除
await DeviceModbusPointApi.deleteModbusPoint(id)
message.success('删除成功')
// 刷新列表
await getPointPage()
} catch {}
}
/** 初始化 */
onMounted(async () => {
await getModbusConfig()
await getPointPage()
})
</script>

View File

@@ -0,0 +1,205 @@
<!-- Modbus 连接配置弹窗 -->
<template>
<Dialog title="编辑 Modbus 连接配置" v-model="dialogVisible" width="600px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<!-- Client 模式专有字段IP端口超时重试 -->
<template v-if="isClient">
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="formData.ip" placeholder="请输入 Modbus 服务器 IP 地址" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number
v-model="formData.port"
placeholder="请输入端口"
:min="1"
:max="65535"
controls-position="right"
class="!w-full"
/>
</el-form-item>
</template>
<!-- 公共字段从站地址 -->
<el-form-item label="从站地址" prop="slaveId">
<el-input-number
v-model="formData.slaveId"
:min="1"
:max="247"
controls-position="right"
placeholder="请输入从站地址,范围 1-247"
class="!w-full"
/>
</el-form-item>
<!-- Client 模式专有字段超时重试 -->
<template v-if="isClient">
<el-form-item label="连接超时(ms)" prop="timeout">
<el-input-number
v-model="formData.timeout"
:min="1000"
:step="1000"
controls-position="right"
placeholder="请输入连接超时时间"
class="!w-full"
/>
</el-form-item>
<el-form-item label="重试间隔(ms)" prop="retryInterval">
<el-input-number
v-model="formData.retryInterval"
:min="1000"
:step="1000"
controls-position="right"
placeholder="请输入重试间隔"
class="!w-full"
/>
</el-form-item>
</template>
<!-- Server 模式专有字段模式帧格式 -->
<template v-if="isServer">
<el-form-item label="工作模式" prop="mode">
<el-radio-group v-model="formData.mode">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_MODBUS_MODE)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="帧格式" prop="frameFormat">
<el-radio-group v-model="formData.frameFormat">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_MODBUS_FRAME_FORMAT)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<!-- 公共字段状态 -->
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DeviceModbusConfigApi, DeviceModbusConfigVO } from '@/api/iot/device/modbus/config'
import { ProtocolTypeEnum } from '@/api/iot/product/product'
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import { ModbusModeEnum, ModbusFrameFormatEnum } from '@/views/iot/utils/constants'
defineOptions({ name: 'DeviceModbusConfigForm' })
const props = defineProps<{
deviceId: number
protocolType: string
}>()
const emit = defineEmits<{
(e: 'success'): void
}>()
const message = useMessage()
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单提交 loading 状态
const isClient = computed(() => props.protocolType === ProtocolTypeEnum.MODBUS_TCP_CLIENT) // 是否为 Client 模式
const isServer = computed(() => props.protocolType === ProtocolTypeEnum.MODBUS_TCP_SERVER) // 是否为 Server 模式
const formData = ref<DeviceModbusConfigVO>({
deviceId: props.deviceId,
ip: '',
port: 502,
slaveId: 1,
timeout: 3000,
retryInterval: 10000,
mode: ModbusModeEnum.POLLING,
frameFormat: ModbusFrameFormatEnum.MODBUS_TCP,
status: CommonStatusEnum.ENABLE
})
const formRules = computed(() => {
const rules: Record<string, any[]> = {
slaveId: [{ required: true, message: '请输入从站地址', trigger: 'blur' }]
}
if (isClient.value) {
rules.ip = [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }]
rules.port = [{ required: true, message: '请输入端口', trigger: 'blur' }]
rules.timeout = [{ required: true, message: '请输入连接超时时间', trigger: 'blur' }]
rules.retryInterval = [{ required: true, message: '请输入重试间隔', trigger: 'blur' }]
}
if (isServer.value) {
rules.mode = [{ required: true, message: '请选择工作模式', trigger: 'change' }]
rules.frameFormat = [{ required: true, message: '请选择帧格式', trigger: 'change' }]
}
return rules
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (data?: DeviceModbusConfigVO) => {
dialogVisible.value = true
resetForm()
// 编辑模式
if (data && data.id) {
formData.value = { ...data }
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
deviceId: props.deviceId,
ip: '',
port: 502,
slaveId: 1,
timeout: 3000,
retryInterval: 10000,
mode: ModbusModeEnum.POLLING,
frameFormat: ModbusFrameFormatEnum.MODBUS_TCP,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
/** 提交表单 */
const submitForm = async () => {
// 校验表单
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
formData.value.deviceId = props.deviceId
await DeviceModbusConfigApi.saveModbusConfig(formData.value)
message.success('保存成功')
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
/** 暴露方法 */
defineExpose({ open })
</script>

View File

@@ -0,0 +1,286 @@
<!-- Modbus 点位表单弹窗 -->
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="600px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="物模型属性" prop="thingModelId">
<el-select
v-model="formData.thingModelId"
placeholder="请选择物模型属性"
filterable
class="!w-full"
@change="handleThingModelChange"
>
<el-option
v-for="item in propertyList"
:key="item.id!"
:label="`${item.name} (${item.identifier})`"
:value="item.id!"
/>
</el-select>
</el-form-item>
<el-form-item label="功能码" prop="functionCode">
<el-select v-model="formData.functionCode" placeholder="请选择功能码" class="!w-full">
<el-option
v-for="item in ModbusFunctionCodeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="寄存器地址" prop="registerAddress">
<el-input
v-model.number="formData.registerAddress"
type="number"
:min="0"
:max="65535"
placeholder="请输入寄存器地址"
class="!w-full"
>
<template #suffix>
<span class="text-gray-400">{{ registerAddressHex }}</span>
</template>
</el-input>
</el-form-item>
<el-form-item label="寄存器数量" prop="registerCount">
<el-input-number
v-model="formData.registerCount"
:min="1"
:max="125"
controls-position="right"
placeholder="请输入寄存器数量"
class="!w-full"
/>
</el-form-item>
<el-form-item label="原始数据类型" prop="rawDataType">
<el-select
v-model="formData.rawDataType"
placeholder="请选择数据类型"
class="!w-full"
@change="handleRawDataTypeChange"
>
<el-option
v-for="item in ModbusRawDataTypeOptions"
:key="item.value"
:label="`${item.label} - ${item.description}`"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="字节序" prop="byteOrder">
<el-select v-model="formData.byteOrder" placeholder="请选择字节序" class="!w-full">
<el-option
v-for="item in currentByteOrderOptions"
:key="item.value"
:label="`${item.label} - ${item.description}`"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="缩放因子" prop="scale">
<el-input-number
v-model="formData.scale"
:precision="6"
:step="0.1"
controls-position="right"
placeholder="请输入缩放因子"
class="!w-full"
/>
</el-form-item>
<el-form-item label="轮询间隔(ms)" prop="pollInterval">
<el-input-number
v-model="formData.pollInterval"
:min="100"
:step="1000"
controls-position="right"
placeholder="请输入轮询间隔"
class="!w-full"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { ThingModelData } from '@/api/iot/thingmodel'
import { DeviceModbusPointApi, DeviceModbusPointVO } from '@/api/iot/device/modbus/point'
import {
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
getByteOrderOptions,
IoTThingModelTypeEnum
} from '@/views/iot/utils/constants'
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
defineOptions({ name: 'DeviceModbusPointForm' })
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const emit = defineEmits<{
(e: 'success'): void
}>()
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref<DeviceModbusPointVO>({
deviceId: props.deviceId,
thingModelId: undefined,
identifier: '',
name: '',
functionCode: undefined,
registerAddress: undefined,
registerCount: undefined,
byteOrder: undefined,
rawDataType: undefined,
scale: 1,
pollInterval: 5000,
status: CommonStatusEnum.ENABLE
})
const formRules = {
thingModelId: [{ required: true, message: '请选择物模型属性', trigger: 'change' }],
functionCode: [{ required: true, message: '请选择功能码', trigger: 'change' }],
registerAddress: [{ required: true, message: '请输入寄存器地址', trigger: 'blur' }],
registerCount: [{ required: true, message: '请输入寄存器数量', trigger: 'blur' }],
rawDataType: [{ required: true, message: '请选择数据类型', trigger: 'change' }],
pollInterval: [{ required: true, message: '请输入轮询间隔', trigger: 'blur' }]
}
const formRef = ref() // 表单 Ref
/** 寄存器地址十六进制显示 */
const registerAddressHex = computed(() => {
if (formData.value.registerAddress === undefined || formData.value.registerAddress === null) {
return ''
}
return '0x' + formData.value.registerAddress.toString(16).toUpperCase().padStart(4, '0')
})
/** 筛选属性类型的物模型 */
const propertyList = computed(() => {
return props.thingModelList.filter((item) => item.type === IoTThingModelTypeEnum.PROPERTY)
})
/** 当前字节序选项(根据数据类型动态变化) */
const currentByteOrderOptions = computed(() => {
if (!formData.value.rawDataType) {
return []
}
return getByteOrderOptions(formData.value.rawDataType)
})
/** 物模型属性变化 */
const handleThingModelChange = (thingModelId: number) => {
const thingModel = props.thingModelList.find((item) => item.id === thingModelId)
if (thingModel) {
formData.value.identifier = thingModel.identifier!
formData.value.name = thingModel.name!
}
}
/** 数据类型变化 */
const handleRawDataTypeChange = (rawDataType: string) => {
// 根据数据类型自动设置寄存器数量
const option = ModbusRawDataTypeOptions.find((item) => item.value === rawDataType)
if (option && option.registerCount > 0) {
formData.value.registerCount = option.registerCount
}
// 重置字节序为第一个选项
const byteOrderOptions = getByteOrderOptions(rawDataType)
if (byteOrderOptions.length > 0) {
formData.value.byteOrder = byteOrderOptions[0].value
}
}
/** 打开弹窗 */
const open = async (type: 'create' | 'update', id?: number) => {
dialogVisible.value = true
formType.value = type
dialogTitle.value = t('action.' + type)
resetForm()
// 修改时,设置数据
if (type === 'update' && id) {
formLoading.value = true
try {
formData.value = await DeviceModbusPointApi.getModbusPoint(id)
} finally {
formLoading.value = false
}
}
}
/** 提交表单 */
const submitForm = async () => {
// 校验表单
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
if (formType.value === 'create') {
await DeviceModbusPointApi.createModbusPoint(formData.value)
message.success('创建成功')
} else {
await DeviceModbusPointApi.updateModbusPoint(formData.value)
message.success('更新成功')
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
deviceId: props.deviceId,
thingModelId: undefined,
identifier: '',
name: '',
functionCode: undefined,
registerAddress: undefined,
registerCount: undefined,
byteOrder: undefined,
rawDataType: undefined,
scale: 1,
pollInterval: 5000,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
/** 暴露方法 */
defineExpose({ open })
</script>

View File

@@ -42,13 +42,29 @@
@success="getDeviceData"
/>
</el-tab-pane>
<el-tab-pane
label="Modbus 配置"
name="modbus"
v-if="
[ProtocolTypeEnum.MODBUS_TCP_CLIENT, ProtocolTypeEnum.MODBUS_TCP_SERVER].includes(
product.protocolType as ProtocolTypeEnum
)
"
>
<DeviceModbusConfig
v-if="activeTab === 'modbus'"
:device="device"
:product="product"
:thing-model-list="thingModelList"
/>
</el-tab-pane>
</el-tabs>
</el-col>
</template>
<script lang="ts" setup>
import { useTagsViewStore } from '@/store/modules/tagsView'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceTypeEnum, ProductApi, ProductVO, ProtocolTypeEnum } from '@/api/iot/product/product'
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
@@ -56,6 +72,7 @@ import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
import DeviceModbusConfig from './DeviceModbusConfig.vue'
import DeviceDetailsSubDevice from './DeviceDetailsSubDevice.vue'
defineOptions({ name: 'IoTDeviceDetail' })

View File

@@ -173,7 +173,7 @@
<div class="mr-2.5 flex items-center">
<el-image :src="defaultIconUrl" class="w-[18px] h-[18px]" />
</div>
<div class="text-[16px] font-600 flex-1">{{ item.deviceName }}</div>
<div class="text-[16px] font-600 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{{ item.deviceName }}</div>
<!-- 添加设备状态标签 -->
<div class="inline-flex items-center">
<div

View File

@@ -4,7 +4,7 @@
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="ProductKey" prop="productKey">
@@ -49,11 +49,7 @@
label="联网方式"
prop="netType"
>
<el-select
v-model="formData.netType"
placeholder="请选择联网方式"
:disabled="formType === 'update'"
>
<el-select v-model="formData.netType" placeholder="请选择联网方式">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"
:key="dict.value"
@@ -62,16 +58,36 @@
/>
</el-select>
</el-form-item>
<el-form-item label="数据格式" prop="codecType">
<el-radio-group v-model="formData.codecType" :disabled="formType === 'update'">
<el-radio
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_CODEC_TYPE)"
<el-form-item label="协议类型" prop="protocolType">
<el-select v-model="formData.protocolType" placeholder="请选择协议类型">
<el-option
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
:key="dict.value"
:label="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item prop="serializeType">
<template #label>
<el-tooltip
content="iot-gateway-server 默认根据接入的协议类型确定数据格式,仅 MQTT、EMQX 协议支持自定义序列化类型"
placement="top"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
<span>
序列化类型
<Icon icon="ep:question-filled" class="ml-2px" />
</span>
</el-tooltip>
</template>
<el-select v-model="formData.serializeType" placeholder="请选择序列化类型">
<el-option
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_SERIALIZE_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
@@ -109,7 +125,13 @@
</template>
<script setup lang="ts">
import { ProductApi, ProductVO, CodecTypeEnum, DeviceTypeEnum } from '@/api/iot/product/product'
import {
ProductApi,
ProductVO,
ProtocolTypeEnum,
SerializeTypeEnum,
DeviceTypeEnum
} from '@/api/iot/product/product'
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
import { UploadImg } from '@/components/UploadFile'
@@ -134,7 +156,8 @@ const formData = ref({
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK,
protocolType: ProtocolTypeEnum.MQTT,
serializeType: SerializeTypeEnum.JSON,
registerEnabled: false
})
const formRules = reactive({
@@ -149,7 +172,8 @@ const formRules = reactive({
trigger: 'change'
}
],
codecType: [{ required: true, message: '数据格式不能为空', trigger: 'change' }]
protocolType: [{ required: true, message: '协议类型不能为空', trigger: 'change' }],
serializeType: [{ required: true, message: '序列化类型不能为空', trigger: 'change' }]
})
const formRef = ref()
const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表
@@ -209,7 +233,8 @@ const resetForm = () => {
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK,
protocolType: ProtocolTypeEnum.MQTT,
serializeType: SerializeTypeEnum.JSON,
registerEnabled: false
}
formRef.value?.resetFields()

View File

@@ -9,8 +9,11 @@
<el-descriptions-item label="创建时间">
{{ formatDate(product.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="数据格式">
<dict-tag :type="DICT_TYPE.IOT_CODEC_TYPE" :value="product.codecType" />
<el-descriptions-item label="协议类型">
<dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
</el-descriptions-item>
<el-descriptions-item label="序列化类型">
<dict-tag :type="DICT_TYPE.IOT_SERIALIZE_TYPE" :value="product.serializeType" />
</el-descriptions-item>
<el-descriptions-item label="产品状态">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />

View File

@@ -0,0 +1,163 @@
<!-- 定时触发器条件组配置组件 -->
<template>
<div class="space-y-16px">
<!-- 条件组容器头部 -->
<div
class="flex items-center justify-between p-16px bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-8px"
>
<div class="flex items-center gap-12px">
<div class="flex items-center gap-8px text-16px font-600 text-blue-700">
<div
class="w-24px h-24px bg-blue-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
>
</div>
<span>附加条件组</span>
</div>
<el-tag size="small" type="info">定时触发时需满足以下条件</el-tag>
<el-tag size="small" type="warning"> {{ conditionGroups?.length || 0 }} 个子条件组 </el-tag>
</div>
<el-button
type="primary"
size="small"
@click="addConditionGroup"
:disabled="(conditionGroups?.length || 0) >= maxGroups"
>
<Icon icon="ep:plus" />
添加条件组
</el-button>
</div>
<!-- 条件组列表 -->
<div v-if="conditionGroups && conditionGroups.length > 0" class="space-y-16px">
<div
v-for="(group, groupIndex) in conditionGroups"
:key="`group-${groupIndex}`"
class="relative"
>
<!-- 条件组容器 -->
<div
class="border-2 border-orange-200 rounded-8px bg-orange-50 shadow-sm hover:shadow-md transition-shadow"
>
<div
class="flex items-center justify-between p-16px bg-gradient-to-r from-orange-50 to-yellow-50 border-b border-orange-200 rounded-t-6px"
>
<div class="flex items-center gap-12px">
<div class="flex items-center gap-8px text-16px font-600 text-orange-700">
<div
class="w-24px h-24px bg-orange-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
>
{{ groupIndex + 1 }}
</div>
<span>子条件组 {{ groupIndex + 1 }}</span>
</div>
<el-tag size="small" type="warning" class="font-500">组内条件为"且"关系</el-tag>
<el-tag size="small" type="info"> {{ group?.length || 0 }}个条件 </el-tag>
</div>
<el-button
type="danger"
size="small"
text
@click="removeConditionGroup(groupIndex)"
class="hover:bg-red-50"
>
<Icon icon="ep:delete" />
删除组
</el-button>
</div>
<SubConditionGroupConfig
:model-value="group"
@update:model-value="(value) => updateConditionGroup(groupIndex, value)"
:trigger-type="IotRuleSceneTriggerTypeEnum.TIMER"
:max-conditions="maxConditionsPerGroup"
/>
</div>
<!-- 条件组间的""连接符 -->
<div
v-if="groupIndex < conditionGroups.length - 1"
class="flex items-center justify-center py-12px"
>
<div class="flex items-center gap-8px">
<!-- 连接线 -->
<div class="w-32px h-1px bg-orange-300"></div>
<!-- 或标签 -->
<div class="px-16px py-6px bg-orange-100 border-2 border-orange-300 rounded-full">
<span class="text-14px font-600 text-orange-600">或</span>
</div>
<!-- 连接线 -->
<div class="w-32px h-1px bg-orange-300"></div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="p-24px border-2 border-dashed border-blue-200 rounded-8px text-center bg-blue-50"
>
<div class="flex flex-col items-center gap-12px">
<Icon icon="ep:plus" class="text-32px text-blue-400" />
<div class="text-blue-600">
<p class="text-14px font-500 mb-4px">暂无附加条件</p>
<p class="text-12px">定时触发时将直接执行动作</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import SubConditionGroupConfig from './SubConditionGroupConfig.vue'
import type { TriggerCondition } from '@/api/iot/rule/scene'
import { IotRuleSceneTriggerTypeEnum } from '@/views/iot/utils/constants'
/** 定时触发器条件组配置组件 */
defineOptions({ name: 'TimerConditionGroupConfig' })
const props = defineProps<{
modelValue?: TriggerCondition[][]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: TriggerCondition[][]): void
}>()
const conditionGroups = useVModel(props, 'modelValue', emit)
const maxGroups = 3 // 最多 3 个条件组
const maxConditionsPerGroup = 3 // 每组最多 3 个条件
/** 添加条件组 */
const addConditionGroup = async () => {
if (!conditionGroups.value) {
conditionGroups.value = []
}
// 检查是否达到最大条件组数量限制
if (conditionGroups.value.length >= maxGroups) {
return
}
// 使用 nextTick 确保响应式更新完成后再添加新的条件组
await nextTick()
if (conditionGroups.value) {
conditionGroups.value.push([])
}
}
/** 移除条件组 */
const removeConditionGroup = (index: number) => {
if (conditionGroups.value) {
conditionGroups.value.splice(index, 1)
}
}
/** 更新条件组 */
const updateConditionGroup = (index: number, group: TriggerCondition[]) => {
if (conditionGroups.value) {
conditionGroups.value[index] = group
}
}
</script>

View File

@@ -90,6 +90,12 @@
/>
</el-form-item>
</div>
<!-- 附加条件组配置 -->
<TimerConditionGroupConfig
:model-value="triggerItem.conditionGroups"
@update:model-value="(value) => updateTriggerConditionGroups(index, value)"
/>
</div>
</div>
</div>
@@ -115,8 +121,9 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import DeviceTriggerConfig from '../configs/DeviceTriggerConfig.vue'
import TimerConditionGroupConfig from '../configs/TimerConditionGroupConfig.vue'
import { Crontab } from '@/components/Crontab'
import type { Trigger } from '@/api/iot/rule/scene'
import type { Trigger, TriggerCondition } from '@/api/iot/rule/scene'
import {
getTriggerTypeLabel,
IotRuleSceneTriggerTypeEnum,
@@ -197,6 +204,15 @@ const updateTriggerCronConfig = (index: number, cronExpression?: string) => {
triggers.value[index].cronExpression = cronExpression
}
/**
* 更新触发器条件组配置
* @param index 触发器索引
* @param conditionGroups 条件组数组
*/
const updateTriggerConditionGroups = (index: number, conditionGroups: TriggerCondition[][]) => {
triggers.value[index].conditionGroups = conditionGroups
}
/**
* 处理触发器类型变化事件
* @param index 触发器索引

View File

@@ -219,13 +219,23 @@ const thingModelTSL = ref<IotThingModelTSLResp | null>(null) // 物模型TSL数
const propertyGroups = computed(() => {
const groups: { label: string; options: any[] }[] = []
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST) {
groups.push({
label: THING_MODEL_GROUP_LABELS.PROPERTY,
options: propertyList.value.filter((p) => p.type === IoTThingModelTypeEnum.PROPERTY)
})
// 设备属性上报触发器、定时触发器(条件组中的设备属性条件)
if (
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
props.triggerType === IotRuleSceneTriggerTypeEnum.TIMER
) {
const propertyOptions = propertyList.value.filter(
(property) => property.type === IoTThingModelTypeEnum.PROPERTY
)
if (propertyOptions.length > 0) {
groups.push({
label: THING_MODEL_GROUP_LABELS.PROPERTY,
options: propertyOptions
})
}
}
// 设备事件上报触发器
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
groups.push({
label: THING_MODEL_GROUP_LABELS.EVENT,
@@ -233,6 +243,7 @@ const propertyGroups = computed(() => {
})
}
// 设备服务调用触发器
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) {
groups.push({
label: THING_MODEL_GROUP_LABELS.SERVICE,

View File

@@ -599,3 +599,83 @@ export const JSON_PARAMS_EXAMPLE_VALUES = {
[IoTDataSpecsDataTypeEnum.ARRAY]: { display: '[]', value: [] },
DEFAULT: { display: '""', value: '' }
} as const
// ========== Modbus 通用常量 ==========
/** Modbus 模式枚举 */
export const ModbusModeEnum = {
POLLING: 1, // 云端轮询
ACTIVE_REPORT: 2 // 主动上报
} as const
/** Modbus 帧格式枚举 */
export const ModbusFrameFormatEnum = {
MODBUS_TCP: 1, // Modbus TCP
MODBUS_RTU: 2 // Modbus RTU
} as const
/** Modbus 功能码枚举 */
export const ModbusFunctionCodeEnum = {
READ_COILS: 1, // 读线圈
READ_DISCRETE_INPUTS: 2, // 读离散输入
READ_HOLDING_REGISTERS: 3, // 读保持寄存器
READ_INPUT_REGISTERS: 4 // 读输入寄存器
} as const
/** Modbus 功能码选项 */
export const ModbusFunctionCodeOptions = [
{ value: 1, label: '01 - 读线圈 (Coils)', description: '可读写布尔值' },
{ value: 2, label: '02 - 读离散输入 (Discrete Inputs)', description: '只读布尔值' },
{ value: 3, label: '03 - 读保持寄存器 (Holding Registers)', description: '可读写 16 位数据' },
{ value: 4, label: '04 - 读输入寄存器 (Input Registers)', description: '只读 16 位数据' }
]
/** Modbus 原始数据类型枚举 */
export const ModbusRawDataTypeEnum = {
INT16: 'INT16',
UINT16: 'UINT16',
INT32: 'INT32',
UINT32: 'UINT32',
FLOAT: 'FLOAT',
DOUBLE: 'DOUBLE',
BOOLEAN: 'BOOLEAN',
STRING: 'STRING'
} as const
/** Modbus 原始数据类型选项 */
export const ModbusRawDataTypeOptions = [
{ value: 'INT16', label: 'INT16', description: '有符号16位整数', registerCount: 1 },
{ value: 'UINT16', label: 'UINT16', description: '无符号16位整数', registerCount: 1 },
{ value: 'INT32', label: 'INT32', description: '有符号32位整数', registerCount: 2 },
{ value: 'UINT32', label: 'UINT32', description: '无符号32位整数', registerCount: 2 },
{ value: 'FLOAT', label: 'FLOAT', description: '32位浮点数', registerCount: 2 },
{ value: 'DOUBLE', label: 'DOUBLE', description: '64位浮点数', registerCount: 4 },
{ value: 'BOOLEAN', label: 'BOOLEAN', description: '布尔值', registerCount: 1 },
{ value: 'STRING', label: 'STRING', description: '字符串', registerCount: 0 }
]
/** Modbus 字节序选项 - 16位 */
export const ModbusByteOrder16Options = [
{ value: 'AB', label: 'AB', description: '大端序' },
{ value: 'BA', label: 'BA', description: '小端序' }
]
/** Modbus 字节序选项 - 32位 */
export const ModbusByteOrder32Options = [
{ value: 'ABCD', label: 'ABCD', description: '大端序' },
{ value: 'CDAB', label: 'CDAB', description: '大端字交换' },
{ value: 'DCBA', label: 'DCBA', description: '小端序' },
{ value: 'BADC', label: 'BADC', description: '小端字交换' }
]
/** 根据数据类型获取字节序选项 */
export const getByteOrderOptions = (rawDataType: string) => {
if (['INT32', 'UINT32', 'FLOAT'].includes(rawDataType)) {
return ModbusByteOrder32Options
}
if (rawDataType === 'DOUBLE') {
// 64 位暂时复用 32 位字节序
return ModbusByteOrder32Options
}
return ModbusByteOrder16Options
}

View File

@@ -13,15 +13,15 @@
<dict-tag :type="DICT_TYPE.PAY_ORDER_STATUS" :value="detailData.status" size="small" />
</el-descriptions-item>
<el-descriptions-item label="支付金额">
<el-tag type="success" size="small">{{ (detailData.price / 100.0).toFixed(2) }}</el-tag>
<el-tag type="success" size="small">{{ ((detailData.price || 0) / 100.0).toFixed(2) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="手续费">
<el-tag type="warning" size="small">
{{ (detailData.channelFeePrice / 100.0).toFixed(2) }}
{{ ((detailData.channelFeePrice || 0) / 100.0).toFixed(2) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="手续费比例">
{{ detailData.channelFeeRate.toFixed(2) }}%
{{ (detailData.channelFeeRate || 0).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="支付时间">
{{ formatDate(detailData.successTime) }}
@@ -53,7 +53,7 @@
<el-descriptions-item label="渠道用户">{{ detailData.channelUserId }}</el-descriptions-item>
<el-descriptions-item label="退款金额">
<el-tag size="mini" type="danger">
{{ (detailData.refundPrice / 100.0).toFixed(2) }}
{{ ((detailData.refundPrice || 0) / 100.0).toFixed(2) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="通知 URL">{{ detailData.notifyUrl }}</el-descriptions-item>