Pre Merge pull request !842 from yhl186/feature/iot
commit
81ef1b43a0
2
.env
2
.env
|
|
@ -2,7 +2,7 @@
|
||||||
VITE_APP_TITLE=芋道管理系统
|
VITE_APP_TITLE=芋道管理系统
|
||||||
|
|
||||||
# 项目本地运行端口号
|
# 项目本地运行端口号
|
||||||
VITE_PORT=80
|
VITE_PORT=38080
|
||||||
|
|
||||||
# open 运行 npm run dev 时自动打开浏览器
|
# open 运行 npm run dev 时自动打开浏览器
|
||||||
VITE_OPEN=true
|
VITE_OPEN=true
|
||||||
|
|
|
||||||
5
.env.dev
5
.env.dev
|
|
@ -4,8 +4,9 @@ NODE_ENV=production
|
||||||
VITE_DEV=true
|
VITE_DEV=true
|
||||||
|
|
||||||
# 请求路径
|
# 请求路径
|
||||||
VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
|
#VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
|
||||||
|
#VITE_BASE_URL='http://192.168.20.7:48080'
|
||||||
|
VITE_BASE_URL='http://localhost:48080'
|
||||||
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
||||||
VITE_UPLOAD_TYPE=server
|
VITE_UPLOAD_TYPE=server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ NODE_ENV=development
|
||||||
VITE_DEV=true
|
VITE_DEV=true
|
||||||
|
|
||||||
# 请求路径
|
# 请求路径
|
||||||
|
#VITE_BASE_URL='http://192.168.20.7:48080'
|
||||||
VITE_BASE_URL='http://localhost:48080'
|
VITE_BASE_URL='http://localhost:48080'
|
||||||
|
|
||||||
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
|
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
|
||||||
VITE_UPLOAD_TYPE=server
|
VITE_UPLOAD_TYPE=server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ VITE_DEV=false
|
||||||
|
|
||||||
# 请求路径
|
# 请求路径
|
||||||
VITE_BASE_URL='http://localhost:48080'
|
VITE_BASE_URL='http://localhost:48080'
|
||||||
|
#VITE_BASE_URL='http://192.168.20.7:48080'
|
||||||
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
||||||
VITE_UPLOAD_TYPE=server
|
VITE_UPLOAD_TYPE=server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
/** 客户宝塔信息信息 */
|
||||||
|
export interface Info {
|
||||||
|
id: number; // 主键ID
|
||||||
|
customerName?: string; // 客户名称
|
||||||
|
baotaUrl?: string; // 宝塔面板URL
|
||||||
|
username?: string; // 宝塔面板账号
|
||||||
|
password?: string; // 宝塔面板密码(建议加密存储)
|
||||||
|
rootUsername?: string; // 服务器root账号
|
||||||
|
rootPassword?: string; // 服务器root密码(建议加密存储)
|
||||||
|
mysqlUsername?: string; // MySQL账号
|
||||||
|
mysqlPassword?: string; // MySQL密码(建议加密存储)
|
||||||
|
redisPassword?: string; // Redis密码(建议加密存储)
|
||||||
|
publicIp?: string; // 公网IP(支持IPv4/IPv6)
|
||||||
|
remark: string; // 备注
|
||||||
|
status?: number; // 状态(0正常 1停用)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户宝塔信息 API
|
||||||
|
export const InfoApi = {
|
||||||
|
// 查询客户宝塔信息分页
|
||||||
|
getInfoPage: async (params: any) => {
|
||||||
|
return await request.get({ url: `/baota/info/page`, params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 查询客户宝塔信息详情
|
||||||
|
getInfo: async (id: number) => {
|
||||||
|
return await request.get({ url: `/baota/info/get?id=` + id })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 新增客户宝塔信息
|
||||||
|
createInfo: async (data: Info) => {
|
||||||
|
return await request.post({ url: `/baota/info/create`, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 修改客户宝塔信息
|
||||||
|
updateInfo: async (data: Info) => {
|
||||||
|
return await request.put({ url: `/baota/info/update`, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除客户宝塔信息
|
||||||
|
deleteInfo: async (id: number) => {
|
||||||
|
return await request.delete({ url: `/baota/info/delete?id=` + id })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 批量删除客户宝塔信息 */
|
||||||
|
deleteInfoList: async (ids: number[]) => {
|
||||||
|
return await request.delete({ url: `/baota/info/delete-list?ids=${ids.join(',')}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 导出客户宝塔信息 Excel
|
||||||
|
exportInfo: async (params) => {
|
||||||
|
return await request.download({ url: `/baota/info/export-excel`, params })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,29 @@ export interface IotDeviceMessageSendReqVO {
|
||||||
params?: any // 请求参数
|
params?: any // 请求参数
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IoT 设备消息拉取 Request VO
|
||||||
|
export interface IotDeviceMessagePullReqVO {
|
||||||
|
deviceId: number // 设备 ID
|
||||||
|
limit?: number // 拉取数量限制(默认 10,最大 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoT 设备消息拉取 Response VO
|
||||||
|
export interface IotDeviceMessagePullRespVO {
|
||||||
|
id: number // 离线消息记录 ID
|
||||||
|
messageId: string // 消息 ID
|
||||||
|
method: string // 消息方法
|
||||||
|
message: any // 完整的消息对象
|
||||||
|
retryCount: number // 重试次数
|
||||||
|
createTime: Date // 创建时间
|
||||||
|
expireTime: Date // 过期时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoT 设备消息确认 Request VO
|
||||||
|
export interface IotDeviceMessageAckReqVO {
|
||||||
|
messageId: number // 消息 ID(离线消息表的主键 ID)
|
||||||
|
deviceId: number // 设备 ID
|
||||||
|
}
|
||||||
|
|
||||||
// 设备 API
|
// 设备 API
|
||||||
export const DeviceApi = {
|
export const DeviceApi = {
|
||||||
// 查询设备分页
|
// 查询设备分页
|
||||||
|
|
@ -161,5 +184,32 @@ export const DeviceApi = {
|
||||||
// 发送设备消息
|
// 发送设备消息
|
||||||
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
|
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
|
||||||
return await request.post({ url: `/iot/device/message/send`, data: params })
|
return await request.post({ url: `/iot/device/message/send`, data: params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 拉取待处理消息(Pull 模式)
|
||||||
|
pullDeviceMessages: async (params: IotDeviceMessagePullReqVO) => {
|
||||||
|
return await request.get({ url: `/iot/device/message/pull`, params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 确认消息已处理(Pull 模式)
|
||||||
|
confirmDeviceMessage: async (data: IotDeviceMessageAckReqVO) => {
|
||||||
|
return await request.post({ url: `/iot/device/message/ack`, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取设备离线消息列表(管理后台)
|
||||||
|
getPullMessageList: async (deviceId: number, status?: number) => {
|
||||||
|
return await request.get({
|
||||||
|
url: `/iot/device/message/pull-list`,
|
||||||
|
params: { deviceId, status }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除离线消息(管理后台)
|
||||||
|
deletePullMessage: async (messageId: number, deviceId: number) => {
|
||||||
|
return await request.delete({
|
||||||
|
url: `/iot/device/message/pull-delete`,
|
||||||
|
params: { messageId, deviceId }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
export interface OtaConfig {
|
||||||
|
allowOfflinePush: boolean // 是否允许离线推送
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 OTA 配置
|
||||||
|
export const getOtaConfig = async () => {
|
||||||
|
return await request.get<OtaConfig>({ url: `/iot/ota/config/get` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 OTA 配置
|
||||||
|
export const updateOtaConfig = async (data: OtaConfig) => {
|
||||||
|
return await request.put({ url: `/iot/ota/config/update`, data })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
export interface OtaTaskStatistics {
|
||||||
|
// PUSH 模式统计
|
||||||
|
pushOnlineTotalCount: number // PUSH 在线推送总数
|
||||||
|
pushOnlineSuccessCount: number // PUSH 在线推送成功数
|
||||||
|
|
||||||
|
// PULL 模式统计
|
||||||
|
pullOfflineCount: number // PULL 离线消息总数
|
||||||
|
pullDeliveredCount: number // PULL 已送达数量(设备已拉取)
|
||||||
|
pullTodayCount: number // 今日新增 PULL 消息数
|
||||||
|
|
||||||
|
// 设备统计
|
||||||
|
totalDeviceCount: number // 总设备数
|
||||||
|
onlineDeviceCount: number // 在线设备数
|
||||||
|
offlineDeviceCount: number // 离线设备数
|
||||||
|
|
||||||
|
// 任务完成率
|
||||||
|
totalTaskCount: number // 总任务数
|
||||||
|
completedTaskCount: number // 已完成任务数(成功+失败)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 OTA 任务统计信息
|
||||||
|
export const getOtaTaskStatistics = async (params?: {
|
||||||
|
startTime?: Date
|
||||||
|
endTime?: Date
|
||||||
|
}) => {
|
||||||
|
return await request.get<OtaTaskStatistics>({ url: `/iot/ota/task/statistics`, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 API
|
||||||
|
export const OtaTaskStatisticsApi = {
|
||||||
|
getOtaTaskStatistics
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@ export interface ProductVO {
|
||||||
locationType: number // 设备类型
|
locationType: number // 设备类型
|
||||||
netType: number // 联网方式
|
netType: number // 联网方式
|
||||||
codecType: string // 数据格式(编解码器类型)
|
codecType: string // 数据格式(编解码器类型)
|
||||||
|
protocolType?: string // 协议类型(STANDARD, MODBUS_RTU, MODBUS_TCP)
|
||||||
|
modbusSlaveId?: number // Modbus从站ID(1-247)
|
||||||
deviceCount: number // 设备数量
|
deviceCount: number // 设备数量
|
||||||
createTime: Date // 创建时间
|
createTime: Date // 创建时间
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface DataSinkVO {
|
||||||
| TcpConfig
|
| TcpConfig
|
||||||
| WebSocketConfig
|
| WebSocketConfig
|
||||||
| MqttConfig
|
| MqttConfig
|
||||||
|
| DatabaseConfig
|
||||||
| RocketMQConfig
|
| RocketMQConfig
|
||||||
| KafkaMQConfig
|
| KafkaMQConfig
|
||||||
| RabbitMQConfig
|
| RabbitMQConfig
|
||||||
|
|
@ -113,6 +114,17 @@ export interface RedisStreamMQConfig extends Config {
|
||||||
topic: string
|
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 = {
|
export const IotDataSinkTypeEnum = {
|
||||||
HTTP: 1,
|
HTTP: 1,
|
||||||
|
|
@ -156,5 +168,31 @@ export const DataSinkApi = {
|
||||||
// 查询数据流转目的(精简)列表
|
// 查询数据流转目的(精简)列表
|
||||||
getDataSinkSimpleList() {
|
getDataSinkSimpleList() {
|
||||||
return request.get({ url: '/iot/data-sink/simple-list' })
|
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
|
description?: string
|
||||||
dataSpecs?: ThingModelProperty
|
dataSpecs?: ThingModelProperty
|
||||||
dataSpecsList?: 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[]
|
inputParams?: ThingModelParam[]
|
||||||
outputParams?: ThingModelParam[]
|
outputParams?: ThingModelParam[]
|
||||||
method?: string
|
method?: string
|
||||||
|
modbusConfig?: ModbusConfig // 添加 Modbus 配置
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 物模型参数 */
|
/** 物模型参数 */
|
||||||
|
|
@ -197,6 +210,17 @@ export const ThingModelApi = {
|
||||||
// 删除产品物模型
|
// 删除产品物模型
|
||||||
deleteThingModel: async (id: number) => {
|
deleteThingModel: async (id: number) => {
|
||||||
return await request.delete({ url: `/iot/thing-model/delete?id=` + id })
|
return await request.delete({ url: `/iot/thing-model/delete?id=` + id })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 计算 Modbus 命令(用于前端自动生成)
|
||||||
|
calculateModbusCommand: async (data: {
|
||||||
|
productId: number;
|
||||||
|
registerAddress: number;
|
||||||
|
functionCode?: number;
|
||||||
|
registerCount?: number;
|
||||||
|
byteOrder?: number;
|
||||||
|
}) => {
|
||||||
|
return await request.post({ url: `/iot/thing-model/calculate-modbus-command`, data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,5 +246,6 @@ export enum DICT_TYPE {
|
||||||
IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IoT 告警接收类型
|
IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IoT 告警接收类型
|
||||||
IOT_OTA_TASK_DEVICE_SCOPE = 'iot_ota_task_device_scope', // IoT OTA任务设备范围
|
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_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_STATUS_OPTIONS = 'iot_status_options' //消息状态
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
<template>
|
||||||
|
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="100px"
|
||||||
|
v-loading="formLoading"
|
||||||
|
>
|
||||||
|
<el-form-item label="客户名称" prop="customerName">
|
||||||
|
<el-input v-model="formData.customerName" placeholder="请输入客户名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宝塔面板URL" prop="baotaUrl">
|
||||||
|
<el-input v-model="formData.baotaUrl" placeholder="请输入宝塔面板URL" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宝塔面板账号" prop="username">
|
||||||
|
<el-input v-model="formData.username" placeholder="请输入宝塔面板账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宝塔面板密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入宝塔面板密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务器root账号" prop="rootUsername">
|
||||||
|
<el-input v-model="formData.rootUsername" placeholder="请输入服务器root账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务器root密码" prop="rootPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.rootPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入服务器root密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="MySQL账号" prop="mysqlUsername">
|
||||||
|
<el-input v-model="formData.mysqlUsername" placeholder="请输入MySQL账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="MySQL密码" prop="mysqlPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.mysqlPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入MySQL密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Redis密码" prop="redisPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.redisPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入Redis密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="公网IP" prop="publicIp">
|
||||||
|
<el-input v-model="formData.publicIp" placeholder="请输入公网IP" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input v-model="formData.remark" placeholder="请输入备注" />
|
||||||
|
</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="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||||
|
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||||
|
import { InfoApi, Info } from '@/api/baota/baotainfo'
|
||||||
|
|
||||||
|
/** 客户宝塔信息 表单 */
|
||||||
|
defineOptions({ name: 'InfoForm' })
|
||||||
|
|
||||||
|
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({
|
||||||
|
id: undefined,
|
||||||
|
customerName: undefined,
|
||||||
|
baotaUrl: undefined,
|
||||||
|
username: undefined,
|
||||||
|
password: undefined,
|
||||||
|
rootUsername: undefined,
|
||||||
|
rootPassword: undefined,
|
||||||
|
mysqlUsername: undefined,
|
||||||
|
mysqlPassword: undefined,
|
||||||
|
redisPassword: undefined,
|
||||||
|
publicIp: undefined,
|
||||||
|
remark: undefined,
|
||||||
|
status: undefined
|
||||||
|
})
|
||||||
|
const formRules = reactive({
|
||||||
|
customerName: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
|
||||||
|
baotaUrl: [{ required: true, message: '宝塔面板URL不能为空', trigger: 'blur' }],
|
||||||
|
username: [{ required: true, message: '宝塔面板账号不能为空', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '宝塔面板密码不能为空', trigger: 'blur' }],
|
||||||
|
rootUsername: [{ required: true, message: '服务器root账号不能为空', trigger: 'blur' }],
|
||||||
|
rootPassword: [{ required: true, message: '服务器root密码不能为空', trigger: 'blur' }],
|
||||||
|
mysqlUsername: [{ required: true, message: 'MySQL账号不能为空', trigger: 'blur' }],
|
||||||
|
mysqlPassword: [{ required: true, message: 'MySQL密码不能为空', trigger: 'blur' }],
|
||||||
|
redisPassword: [{ required: true, message: 'Redis密码不能为空', trigger: 'blur' }],
|
||||||
|
publicIp: [{ required: true, message: '公网IP不能为空', trigger: 'blur' }],
|
||||||
|
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
|
||||||
|
})
|
||||||
|
const formRef = ref() // 表单 Ref
|
||||||
|
|
||||||
|
/** 打开弹窗 */
|
||||||
|
const open = async (type: string, id?: number) => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
dialogTitle.value = t('action.' + type)
|
||||||
|
formType.value = type
|
||||||
|
resetForm()
|
||||||
|
// 修改时,设置数据
|
||||||
|
if (id) {
|
||||||
|
formLoading.value = true
|
||||||
|
try {
|
||||||
|
formData.value = await InfoApi.getInfo(id)
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||||
|
|
||||||
|
/** 提交表单 */
|
||||||
|
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||||
|
const submitForm = async () => {
|
||||||
|
// 校验表单
|
||||||
|
await formRef.value.validate()
|
||||||
|
// 提交请求
|
||||||
|
formLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = formData.value as unknown as Info
|
||||||
|
if (formType.value === 'create') {
|
||||||
|
await InfoApi.createInfo(data)
|
||||||
|
message.success(t('common.createSuccess'))
|
||||||
|
} else {
|
||||||
|
await InfoApi.updateInfo(data)
|
||||||
|
message.success(t('common.updateSuccess'))
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
// 发送操作成功的事件
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置表单 */
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
id: undefined,
|
||||||
|
customerName: undefined,
|
||||||
|
baotaUrl: undefined,
|
||||||
|
username: undefined,
|
||||||
|
password: undefined,
|
||||||
|
rootUsername: undefined,
|
||||||
|
rootPassword: undefined,
|
||||||
|
mysqlUsername: undefined,
|
||||||
|
mysqlPassword: undefined,
|
||||||
|
redisPassword: undefined,
|
||||||
|
publicIp: undefined,
|
||||||
|
remark: undefined,
|
||||||
|
status: undefined
|
||||||
|
}
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
<template>
|
||||||
|
<ContentWrap>
|
||||||
|
<!-- 搜索工作栏 -->
|
||||||
|
<el-form
|
||||||
|
class="-mb-15px"
|
||||||
|
:model="queryParams"
|
||||||
|
ref="queryFormRef"
|
||||||
|
:inline="true"
|
||||||
|
label-width="68px"
|
||||||
|
>
|
||||||
|
<el-form-item label="客户名称" prop="customerName">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.customerName"
|
||||||
|
placeholder="请输入客户名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
class="!w-240px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宝塔面板URL" prop="baotaUrl">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.baotaUrl"
|
||||||
|
placeholder="请输入宝塔面板URL"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
class="!w-240px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="公网IP" prop="publicIp">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.publicIp"
|
||||||
|
placeholder="请输入公网IP"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
class="!w-240px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.remark"
|
||||||
|
placeholder="请输入备注"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
class="!w-240px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.status"
|
||||||
|
placeholder="请选择状态"
|
||||||
|
clearable
|
||||||
|
class="!w-240px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||||
|
:key="dict.value"
|
||||||
|
:label="dict.label"
|
||||||
|
:value="dict.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="创建时间" prop="createTime">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="queryParams.createTime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
type="daterange"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||||
|
class="!w-220px"
|
||||||
|
/>
|
||||||
|
</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-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="openForm('create')"
|
||||||
|
v-hasPermi="['baota:info:create']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
@click="handleExport"
|
||||||
|
:loading="exportLoading"
|
||||||
|
v-hasPermi="['baota:info:export']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:download" class="mr-5px" /> 导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="isEmpty(checkedIds)"
|
||||||
|
@click="handleDeleteBatch"
|
||||||
|
v-hasPermi="['baota:info:delete']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</ContentWrap>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<ContentWrap>
|
||||||
|
<el-table
|
||||||
|
row-key="id"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="list"
|
||||||
|
:stripe="true"
|
||||||
|
:show-overflow-tooltip="true"
|
||||||
|
@selection-change="handleRowCheckboxChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="55" />
|
||||||
|
<el-table-column label="主键ID" align="center" prop="id" />
|
||||||
|
<el-table-column label="客户名称" align="center" prop="customerName" />
|
||||||
|
<el-table-column label="宝塔面板URL" align="center" prop="baotaUrl" min-width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-link
|
||||||
|
:href="scope.row.baotaUrl"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
>
|
||||||
|
{{ scope.row.baotaUrl }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="宝塔面板账号" align="center" prop="username" />
|
||||||
|
<el-table-column label="宝塔面板密码" align="center" prop="password" min-width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-input
|
||||||
|
v-model="scope.row.password"
|
||||||
|
:type="scope.row.showPassword ? 'text' : 'password'"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon
|
||||||
|
:icon="scope.row.showPassword ? 'ep:hide' : 'ep:view'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="togglePasswordVisibility(scope.row, 'showPassword')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="服务器root账号" align="center" prop="rootUsername" />
|
||||||
|
<el-table-column label="服务器root密码" align="center" prop="rootPassword" min-width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-input
|
||||||
|
v-model="scope.row.rootPassword"
|
||||||
|
:type="scope.row.showRootPassword ? 'text' : 'password'"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon
|
||||||
|
:icon="scope.row.showRootPassword ? 'ep:hide' : 'ep:view'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="togglePasswordVisibility(scope.row, 'showRootPassword')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="MySQL账号" align="center" prop="mysqlUsername" />
|
||||||
|
<el-table-column label="MySQL密码" align="center" prop="mysqlPassword" min-width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-input
|
||||||
|
v-model="scope.row.mysqlPassword"
|
||||||
|
:type="scope.row.showMysqlPassword ? 'text' : 'password'"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon
|
||||||
|
:icon="scope.row.showMysqlPassword ? 'ep:hide' : 'ep:view'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="togglePasswordVisibility(scope.row, 'showMysqlPassword')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Redis密码" align="center" prop="redisPassword" min-width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-input
|
||||||
|
v-model="scope.row.redisPassword"
|
||||||
|
:type="scope.row.showRedisPassword ? 'text' : 'password'"
|
||||||
|
readonly
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon
|
||||||
|
:icon="scope.row.showRedisPassword ? 'ep:hide' : 'ep:view'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="togglePasswordVisibility(scope.row, 'showRedisPassword')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="公网IP" align="center" prop="publicIp" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-link
|
||||||
|
:href="getIpUrl(scope.row.publicIp)"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
>
|
||||||
|
{{ scope.row.publicIp }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" align="center" prop="remark" />
|
||||||
|
<el-table-column label="状态" align="center" prop="status">
|
||||||
|
<template #default="scope">
|
||||||
|
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="创建时间"
|
||||||
|
align="center"
|
||||||
|
prop="createTime"
|
||||||
|
:formatter="dateFormatter"
|
||||||
|
width="180px"
|
||||||
|
/>
|
||||||
|
<el-table-column label="操作" align="center" min-width="120px">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openForm('update', scope.row.id)"
|
||||||
|
v-hasPermi="['baota:info:update']"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(scope.row.id)"
|
||||||
|
v-hasPermi="['baota:info:delete']"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<!-- 分页 -->
|
||||||
|
<Pagination
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNo"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</ContentWrap>
|
||||||
|
|
||||||
|
<!-- 表单弹窗:添加/修改 -->
|
||||||
|
<InfoForm ref="formRef" @success="getList" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||||
|
import { isEmpty } from '@/utils/is'
|
||||||
|
import { dateFormatter } from '@/utils/formatTime'
|
||||||
|
import download from '@/utils/download'
|
||||||
|
import { InfoApi, Info } from '@/api/baota/baotainfo'
|
||||||
|
import InfoForm from './InfoForm.vue'
|
||||||
|
|
||||||
|
/** 客户宝塔信息 列表 */
|
||||||
|
defineOptions({ name: 'BaotaInfo' })
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
|
const loading = ref(true) // 列表的加载中
|
||||||
|
const list = ref<Info[]>([]) // 列表的数据
|
||||||
|
const total = ref(0) // 列表的总页数
|
||||||
|
const queryParams = reactive({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
customerName: undefined,
|
||||||
|
baotaUrl: undefined,
|
||||||
|
publicIp: undefined,
|
||||||
|
remark: undefined,
|
||||||
|
status: undefined,
|
||||||
|
createTime: []
|
||||||
|
})
|
||||||
|
const queryFormRef = ref() // 搜索的表单
|
||||||
|
const exportLoading = ref(false) // 导出的加载中
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await InfoApi.getInfoPage(queryParams)
|
||||||
|
// 为每一行添加密码显示控制属性
|
||||||
|
list.value = data.list.map(item => ({
|
||||||
|
...item,
|
||||||
|
showPassword: false,
|
||||||
|
showRootPassword: false,
|
||||||
|
showMysqlPassword: false,
|
||||||
|
showRedisPassword: false
|
||||||
|
}))
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.pageNo = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryFormRef.value.resetFields()
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加/修改操作 */
|
||||||
|
const formRef = ref()
|
||||||
|
const openForm = (type: string, id?: number) => {
|
||||||
|
formRef.value.open(type, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
// 删除的二次确认
|
||||||
|
await message.delConfirm()
|
||||||
|
// 发起删除
|
||||||
|
await InfoApi.deleteInfo(id)
|
||||||
|
message.success(t('common.delSuccess'))
|
||||||
|
// 刷新列表
|
||||||
|
await getList()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除客户宝塔信息 */
|
||||||
|
const handleDeleteBatch = async () => {
|
||||||
|
try {
|
||||||
|
// 删除的二次确认
|
||||||
|
await message.delConfirm()
|
||||||
|
await InfoApi.deleteInfoList(checkedIds.value);
|
||||||
|
checkedIds.value = [];
|
||||||
|
message.success(t('common.delSuccess'))
|
||||||
|
await getList();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedIds = ref<number[]>([])
|
||||||
|
const handleRowCheckboxChange = (records: Info[]) => {
|
||||||
|
checkedIds.value = records.map((item) => item.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出按钮操作 */
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
// 导出的二次确认
|
||||||
|
await message.exportConfirm()
|
||||||
|
// 发起导出
|
||||||
|
exportLoading.value = true
|
||||||
|
const data = await InfoApi.exportInfo(queryParams)
|
||||||
|
download.excel(data, '客户宝塔信息.xls')
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换密码可见性 */
|
||||||
|
const togglePasswordVisibility = (row: any, field: string) => {
|
||||||
|
row[field] = !row[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取IP访问URL */
|
||||||
|
const getIpUrl = (ip: string) => {
|
||||||
|
if (!ip) return '#'
|
||||||
|
// 如果IP中包含http/https,直接返回
|
||||||
|
if (ip.startsWith('http://') || ip.startsWith('https://')) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
// 否则添加http://前缀
|
||||||
|
return `http://${ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 **/
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="DeviceName" prop="deviceName">
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.deviceName"
|
v-model="formData.deviceName"
|
||||||
placeholder="请输入 DeviceName"
|
placeholder="请输入设备名称"
|
||||||
:disabled="formType === 'update'"
|
:disabled="formType === 'update'"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="DeviceName" prop="deviceName">
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.deviceName"
|
v-model="queryParams.deviceName"
|
||||||
placeholder="请输入 DeviceName"
|
placeholder="请输入设备名称"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleQuery"
|
@keyup.enter="handleQuery"
|
||||||
class="!w-240px"
|
class="!w-240px"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
<template>
|
||||||
|
<ContentWrap>
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<el-form :inline="true" class="mb-4">
|
||||||
|
<el-form-item label="消息状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.status"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
class="!w-240px"
|
||||||
|
@change="getList"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_STATUS_OPTIONS)"
|
||||||
|
:key="dict.value"
|
||||||
|
:label="dict.label"
|
||||||
|
:value="dict.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button @click="getList" :icon="Search">查询</el-button>
|
||||||
|
<el-button @click="resetQuery" :icon="Refresh">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<el-table v-loading="loading" :data="list" border>
|
||||||
|
<el-table-column label="消息 ID" prop="messageId" width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="消息方法" prop="method" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" prop="status" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<dict-tag :type="DICT_TYPE.IOT_STATUS_OPTIONS" :value="row.message?.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="重试次数" prop="retryCount" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.retryCount > 0" type="warning">{{ row.retryCount }}</el-tag>
|
||||||
|
<span v-else>{{ row.retryCount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消息内容" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-text line-clamp="2" style="max-width: 100%">
|
||||||
|
{{ formatMessageContent(row.message) }}
|
||||||
|
</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" prop="createTime" width="160" :formatter="dateFormatter" />
|
||||||
|
<el-table-column label="过期时间" prop="expireTime" width="160" :formatter="dateFormatter">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'text-danger': isExpiringSoon(row.expireTime) }">
|
||||||
|
{{ dateFormatter(row, '', '', row.expireTime) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="handleViewDetail(row)"
|
||||||
|
:icon="View"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
:icon="Delete"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 消息详情对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailDialogVisible"
|
||||||
|
title="离线消息详情"
|
||||||
|
width="800px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="消息 ID">
|
||||||
|
{{ currentMessage?.messageId }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="消息方法">
|
||||||
|
{{ currentMessage?.method }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<dict-tag :type="DICT_TYPE.IOT_STATUS_OPTIONS" :value="currentMessage?.message?.status" />
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="重试次数">
|
||||||
|
{{ currentMessage?.retryCount }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">
|
||||||
|
{{ dateFormatter(currentMessage, '', '', currentMessage?.createTime) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="过期时间" :span="2">
|
||||||
|
{{ dateFormatter(currentMessage, '', '', currentMessage?.expireTime) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="消息内容" :span="2">
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:value="formatMessageJson(currentMessage?.message)"
|
||||||
|
:rows="10"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-dialog>
|
||||||
|
</ContentWrap>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { Search, Refresh, View, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { DeviceApi, type IotDeviceMessagePullRespVO } from '@/api/iot/device/device'
|
||||||
|
import { dateFormatter } from '@/utils/formatTime'
|
||||||
|
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
deviceId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
const queryParams = reactive({
|
||||||
|
status: undefined as number | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<IotDeviceMessagePullRespVO[]>([])
|
||||||
|
|
||||||
|
// 消息详情
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const currentMessage = ref<IotDeviceMessagePullRespVO>()
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
// 如果 deviceId 不存在,不执行查询
|
||||||
|
if (!props.deviceId) {
|
||||||
|
console.warn('deviceId 为空,跳过查询离线消息列表')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await DeviceApi.getPullMessageList(props.deviceId, queryParams.status)
|
||||||
|
list.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置查询 */
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.status = undefined
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看详情 */
|
||||||
|
const handleViewDetail = (row: IotDeviceMessagePullRespVO) => {
|
||||||
|
currentMessage.value = row
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除消息 */
|
||||||
|
const handleDelete = async (row: IotDeviceMessagePullRespVO) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该离线消息吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await DeviceApi.deletePullMessage(row.id, props.deviceId)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await getList()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化消息内容(简略显示) */
|
||||||
|
const formatMessageContent = (message: any) => {
|
||||||
|
if (!message) return '-'
|
||||||
|
const params = message.params || message.data
|
||||||
|
return JSON.stringify(params || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化消息 JSON(完整显示) */
|
||||||
|
const formatMessageJson = (message: any) => {
|
||||||
|
if (!message) return ''
|
||||||
|
return JSON.stringify(message, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否即将过期(24小时内) */
|
||||||
|
const isExpiringSoon = (expireTime: Date) => {
|
||||||
|
if (!expireTime) return false
|
||||||
|
const now = new Date().getTime()
|
||||||
|
const expire = new Date(expireTime).getTime()
|
||||||
|
const diff = expire - now
|
||||||
|
return diff > 0 && diff < 24 * 60 * 60 * 1000 // 24小时
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 监听 deviceId 变化 */
|
||||||
|
watch(
|
||||||
|
() => props.deviceId,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 暴露刷新方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
refresh: getList
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -131,6 +131,7 @@ const getList = async () => {
|
||||||
if (!props.deviceId) return
|
if (!props.deviceId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
queryParams.deviceId = props.deviceId
|
||||||
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
||||||
list.value = data.list
|
list.value = data.list
|
||||||
total.value = data.length
|
total.value = data.length
|
||||||
|
|
@ -139,6 +140,17 @@ const getList = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 监听 deviceId 变化 */
|
||||||
|
watch(
|
||||||
|
() => props.deviceId,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
queryParams.deviceId = val
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/** 搜索按钮操作 */
|
/** 搜索按钮操作 */
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
queryParams.pageNo = 1
|
queryParams.pageNo = 1
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,9 @@ const queryFormRef = ref() // 搜索的表单
|
||||||
|
|
||||||
/** 查询列表 */
|
/** 查询列表 */
|
||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
|
if (!props.deviceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
|
|
@ -185,6 +188,16 @@ const getList = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 监听 deviceId 变化 */
|
||||||
|
watch(
|
||||||
|
() => props.deviceId,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/** 前端筛选数据 */
|
/** 前端筛选数据 */
|
||||||
const handleFilter = () => {
|
const handleFilter = () => {
|
||||||
if (!queryParams.keyword.trim()) {
|
if (!queryParams.keyword.trim()) {
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ const getList = async () => {
|
||||||
if (!props.deviceId) return
|
if (!props.deviceId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
queryParams.deviceId = props.deviceId
|
||||||
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
||||||
list.value = data.list
|
list.value = data.list
|
||||||
total.value = data.length
|
total.value = data.length
|
||||||
|
|
@ -154,6 +155,17 @@ const getList = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 监听 deviceId 变化 */
|
||||||
|
watch(
|
||||||
|
() => props.deviceId,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
queryParams.deviceId = val
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/** 搜索按钮操作 */
|
/** 搜索按钮操作 */
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
queryParams.pageNo = 1
|
queryParams.pageNo = 1
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
<el-tab-pane label="设备消息" name="log">
|
<el-tab-pane label="设备消息" name="log">
|
||||||
<DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
|
<DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="离线消息" name="pullMessage">
|
||||||
|
<DeviceDetailsPullMessage v-if="activeTab === 'pullMessage'" :device-id="device.id" />
|
||||||
|
</el-tab-pane>
|
||||||
<el-tab-pane label="模拟设备" name="simulator">
|
<el-tab-pane label="模拟设备" name="simulator">
|
||||||
<DeviceDetailsSimulator
|
<DeviceDetailsSimulator
|
||||||
v-if="activeTab === 'simulator'"
|
v-if="activeTab === 'simulator'"
|
||||||
|
|
@ -48,6 +51,7 @@ import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
|
||||||
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
|
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
|
||||||
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
|
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
|
||||||
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
|
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
|
||||||
|
import DeviceDetailsPullMessage from './DeviceDetailsPullMessage.vue'
|
||||||
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
|
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
|
||||||
import DeviceDetailConfig from './DeviceDetailConfig.vue'
|
import DeviceDetailConfig from './DeviceDetailConfig.vue'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="DeviceName" prop="deviceName">
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.deviceName"
|
v-model="queryParams.deviceName"
|
||||||
placeholder="请输入 DeviceName"
|
placeholder="请输入设备名称"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleQuery"
|
@keyup.enter="handleQuery"
|
||||||
class="!w-240px"
|
class="!w-240px"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
<template>
|
||||||
|
<ContentWrap title="OTA 升级监控" class="ota-monitor-card">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<!-- PUSH 成功率 -->
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<Icon icon="ep:upload" class="stat-icon text-blue-500" :size="32" />
|
||||||
|
<span class="stat-title">PUSH 成功率</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value" :class="getPushRateClass(pushSuccessRate)">
|
||||||
|
{{ loading ? '-' : pushSuccessRate.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail">
|
||||||
|
成功 {{ pushOnlineSuccessCount }} / 总数 {{ pushOnlineTotalCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="pushSuccessRate"
|
||||||
|
:color="getPushRateColor(pushSuccessRate)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- PULL 消息数量 -->
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<Icon icon="ep:download" class="stat-icon text-orange-500" :size="32" />
|
||||||
|
<span class="stat-title">PULL 消息数量</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value text-orange-500">
|
||||||
|
{{ loading ? '-' : pullOfflineCount }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail">
|
||||||
|
已送达 {{ pullDeliveredCount }} / 待拉取 {{ pullPendingCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">
|
||||||
|
<span class="trend-label">今日新增:</span>
|
||||||
|
<span class="trend-value">+{{ pullTodayCount }}</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 离线设备比例 -->
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<Icon icon="ep:connection" class="stat-icon text-purple-500" :size="32" />
|
||||||
|
<span class="stat-title">离线设备比例</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value" :class="getOfflineRateClass(offlineRate)">
|
||||||
|
{{ loading ? '-' : offlineRate.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail">
|
||||||
|
离线 {{ offlineDeviceCount }} / 总数 {{ totalDeviceCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="offlineRate"
|
||||||
|
:color="getOfflineRateColor(offlineRate)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 升级任务完成率 -->
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<Icon icon="ep:circle-check" class="stat-icon text-green-500" :size="32" />
|
||||||
|
<span class="stat-title">任务完成率</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value text-green-500">
|
||||||
|
{{ loading ? '-' : completionRate.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-detail">
|
||||||
|
完成 {{ completedTaskCount }} / 总数 {{ totalTaskCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="completionRate"
|
||||||
|
color="#67C23A"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 告警提示 -->
|
||||||
|
<el-row v-if="hasAlerts" class="mt-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-alert
|
||||||
|
v-if="pushSuccessRate < 90 && pushSuccessRate >= 0"
|
||||||
|
:title="`PUSH 成功率过低 (${pushSuccessRate.toFixed(1)}%),建议检查网络连接和设备状态`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="offlineRate > 50"
|
||||||
|
:title="`离线设备比例过高 (${offlineRate.toFixed(1)}%),建议检查设备连接状况`"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</ContentWrap>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { OtaTaskStatisticsApi, type OtaTaskStatistics } from '@/api/iot/ota/statistics'
|
||||||
|
|
||||||
|
/** OTA 监控卡片 */
|
||||||
|
defineOptions({ name: 'OtaMonitorCard' })
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// PUSH 统计
|
||||||
|
const pushOnlineTotalCount = ref(0)
|
||||||
|
const pushOnlineSuccessCount = ref(0)
|
||||||
|
const pushSuccessRate = ref(0)
|
||||||
|
|
||||||
|
// PULL 统计
|
||||||
|
const pullOfflineCount = ref(0)
|
||||||
|
const pullDeliveredCount = ref(0)
|
||||||
|
const pullPendingCount = ref(0)
|
||||||
|
const pullTodayCount = ref(0)
|
||||||
|
|
||||||
|
// 设备统计
|
||||||
|
const totalDeviceCount = ref(0)
|
||||||
|
const offlineDeviceCount = ref(0)
|
||||||
|
const offlineRate = ref(0)
|
||||||
|
|
||||||
|
// 任务统计
|
||||||
|
const totalTaskCount = ref(0)
|
||||||
|
const completedTaskCount = ref(0)
|
||||||
|
const completionRate = ref(0)
|
||||||
|
|
||||||
|
/** 是否有告警 */
|
||||||
|
const hasAlerts = computed(() => {
|
||||||
|
return pushSuccessRate.value < 90 || offlineRate.value > 50
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取 PUSH 成功率颜色类 */
|
||||||
|
const getPushRateClass = (rate: number) => {
|
||||||
|
if (rate >= 95) return 'text-green-500'
|
||||||
|
if (rate >= 90) return 'text-orange-500'
|
||||||
|
return 'text-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 PUSH 成功率颜色 */
|
||||||
|
const getPushRateColor = (rate: number) => {
|
||||||
|
if (rate >= 95) return '#67C23A'
|
||||||
|
if (rate >= 90) return '#E6A23C'
|
||||||
|
return '#F56C6C'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取离线率颜色类 */
|
||||||
|
const getOfflineRateClass = (rate: number) => {
|
||||||
|
if (rate < 30) return 'text-green-500'
|
||||||
|
if (rate < 50) return 'text-orange-500'
|
||||||
|
return 'text-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取离线率颜色 */
|
||||||
|
const getOfflineRateColor = (rate: number) => {
|
||||||
|
if (rate < 30) return '#67C23A'
|
||||||
|
if (rate < 50) return '#E6A23C'
|
||||||
|
return '#F56C6C'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取统计数据 */
|
||||||
|
const getStatistics = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await OtaTaskStatisticsApi.getOtaTaskStatistics()
|
||||||
|
|
||||||
|
// PUSH 统计
|
||||||
|
pushOnlineTotalCount.value = data.pushOnlineTotalCount || 0
|
||||||
|
pushOnlineSuccessCount.value = data.pushOnlineSuccessCount || 0
|
||||||
|
pushSuccessRate.value = pushOnlineTotalCount.value > 0
|
||||||
|
? (pushOnlineSuccessCount.value / pushOnlineTotalCount.value) * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// PULL 统计
|
||||||
|
pullOfflineCount.value = data.pullOfflineCount || 0
|
||||||
|
pullDeliveredCount.value = data.pullDeliveredCount || 0
|
||||||
|
pullPendingCount.value = pullOfflineCount.value - pullDeliveredCount.value
|
||||||
|
pullTodayCount.value = data.pullTodayCount || 0
|
||||||
|
|
||||||
|
// 设备统计
|
||||||
|
totalDeviceCount.value = data.totalDeviceCount || 0
|
||||||
|
offlineDeviceCount.value = data.offlineDeviceCount || 0
|
||||||
|
offlineRate.value = totalDeviceCount.value > 0
|
||||||
|
? (offlineDeviceCount.value / totalDeviceCount.value) * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 任务统计
|
||||||
|
totalTaskCount.value = data.totalTaskCount || 0
|
||||||
|
completedTaskCount.value = data.completedTaskCount || 0
|
||||||
|
completionRate.value = totalTaskCount.value > 0
|
||||||
|
? (completedTaskCount.value / totalTaskCount.value) * 100
|
||||||
|
: 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 OTA 统计数据失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(() => {
|
||||||
|
getStatistics()
|
||||||
|
// 每 30 秒刷新一次
|
||||||
|
setInterval(getStatistics, 30000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ota-monitor-card {
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
height: 160px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.trend-label {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-value {
|
||||||
|
color: #67C23A;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -53,14 +53,21 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 第三行:消息统计行 -->
|
<!-- 第三行:OTA 监控面板 -->
|
||||||
|
<el-row class="mb-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<OtaMonitorCard />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 第四行:消息统计行 -->
|
||||||
<el-row>
|
<el-row>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<MessageTrendCard />
|
<MessageTrendCard />
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- TODO 第四行:地图 -->
|
<!-- TODO 第五行:地图 -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="Index">
|
<script setup lang="ts" name="Index">
|
||||||
|
|
@ -69,6 +76,7 @@ import ComparisonCard from './components/ComparisonCard.vue'
|
||||||
import DeviceCountCard from './components/DeviceCountCard.vue'
|
import DeviceCountCard from './components/DeviceCountCard.vue'
|
||||||
import DeviceStateCountCard from './components/DeviceStateCountCard.vue'
|
import DeviceStateCountCard from './components/DeviceStateCountCard.vue'
|
||||||
import MessageTrendCard from './components/MessageTrendCard.vue'
|
import MessageTrendCard from './components/MessageTrendCard.vue'
|
||||||
|
import OtaMonitorCard from './components/OtaMonitorCard.vue'
|
||||||
|
|
||||||
/** IoT 首页 */
|
/** IoT 首页 */
|
||||||
defineOptions({ name: 'IoTHome' })
|
defineOptions({ name: 'IoTHome' })
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
<el-button type="primary" @click="openTaskForm" v-hasPermi="['iot:ota-task:create']">
|
<el-button type="primary" @click="openTaskForm" v-hasPermi="['iot:ota-task:create']">
|
||||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button @click="openConfigDialog" v-hasPermi="['iot:ota-config:update']">
|
||||||
|
<Icon icon="ep:setting" class="mr-5px" /> 配置
|
||||||
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item class="float-right">
|
<el-form-item class="float-right">
|
||||||
<el-input
|
<el-input
|
||||||
|
|
@ -91,12 +94,36 @@
|
||||||
|
|
||||||
<!-- 任务详情弹窗 -->
|
<!-- 任务详情弹窗 -->
|
||||||
<OtaTaskDetail ref="taskDetailRef" @success="refresh" />
|
<OtaTaskDetail ref="taskDetailRef" @success="refresh" />
|
||||||
|
|
||||||
|
<!-- 配置对话框 -->
|
||||||
|
<el-dialog v-model="configDialogVisible" title="OTA 升级配置" width="500px">
|
||||||
|
<el-form :model="configForm" label-width="140px">
|
||||||
|
<el-form-item label="允许离线推送">
|
||||||
|
<el-switch
|
||||||
|
v-model="configForm.allowOfflinePush"
|
||||||
|
active-text="开启"
|
||||||
|
inactive-text="关闭"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-500 mt-2">
|
||||||
|
开启后,离线设备也会接收升级指令并保存到离线消息表,设备上线后可主动拉取
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSaveConfig" :loading="configSaving">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</ContentWrap>
|
</ContentWrap>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dateFormatter } from '@/utils/formatTime'
|
import { dateFormatter } from '@/utils/formatTime'
|
||||||
import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
|
import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
|
||||||
|
import * as IoTOtaConfigApi from '@/api/iot/ota/config'
|
||||||
|
import type { OtaConfig } from '@/api/iot/ota/config'
|
||||||
import { DICT_TYPE } from '@/utils/dict'
|
import { DICT_TYPE } from '@/utils/dict'
|
||||||
import { IoTOtaTaskStatusEnum } from '@/views/iot/utils/constants'
|
import { IoTOtaTaskStatusEnum } from '@/views/iot/utils/constants'
|
||||||
import OtaTaskForm from './OtaTaskForm.vue'
|
import OtaTaskForm from './OtaTaskForm.vue'
|
||||||
|
|
@ -126,6 +153,13 @@ const queryFormRef = ref() // 查询表单引用
|
||||||
const taskFormRef = ref() // 任务表单引用
|
const taskFormRef = ref() // 任务表单引用
|
||||||
const taskDetailRef = ref() // 任务详情引用
|
const taskDetailRef = ref() // 任务详情引用
|
||||||
|
|
||||||
|
// 配置对话框
|
||||||
|
const configDialogVisible = ref(false)
|
||||||
|
const configSaving = ref(false)
|
||||||
|
const configForm = reactive<OtaConfig>({
|
||||||
|
allowOfflinePush: true
|
||||||
|
})
|
||||||
|
|
||||||
/** 获取任务列表 */
|
/** 获取任务列表 */
|
||||||
const getTaskList = async () => {
|
const getTaskList = async () => {
|
||||||
taskLoading.value = true
|
taskLoading.value = true
|
||||||
|
|
@ -180,6 +214,31 @@ const refresh = async () => {
|
||||||
emit('success')
|
emit('success')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开配置对话框 */
|
||||||
|
const openConfigDialog = async () => {
|
||||||
|
try {
|
||||||
|
const config = await IoTOtaConfigApi.getOtaConfig()
|
||||||
|
configForm.allowOfflinePush = config.allowOfflinePush
|
||||||
|
configDialogVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取配置失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存配置 */
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
try {
|
||||||
|
configSaving.value = true
|
||||||
|
await IoTOtaConfigApi.updateOtaConfig(configForm)
|
||||||
|
message.success('配置保存成功')
|
||||||
|
configDialogVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存配置失败', error)
|
||||||
|
} finally {
|
||||||
|
configSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 初始化 */
|
/** 初始化 */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getTaskList()
|
getTaskList()
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="数据格式" prop="codecType">
|
<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
|
<el-radio
|
||||||
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_CODEC_TYPE)"
|
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_CODEC_TYPE)"
|
||||||
:key="dict.value"
|
:key="dict.value"
|
||||||
|
|
@ -83,6 +83,27 @@
|
||||||
{{ dict.label }}
|
{{ dict.label }}
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<div v-if="isModbusProtocol" class="form-tip modbus-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-form-item>
|
||||||
<el-collapse>
|
<el-collapse>
|
||||||
<el-collapse-item title="更多配置">
|
<el-collapse-item title="更多配置">
|
||||||
|
|
@ -132,7 +153,9 @@ const formData = ref({
|
||||||
deviceType: undefined,
|
deviceType: undefined,
|
||||||
locationType: undefined,
|
locationType: undefined,
|
||||||
netType: undefined,
|
netType: undefined,
|
||||||
codecType: CodecTypeEnum.ALINK
|
codecType: CodecTypeEnum.ALINK,
|
||||||
|
protocolType: 'STANDARD',
|
||||||
|
modbusSlaveId: 1
|
||||||
})
|
})
|
||||||
const formRules = reactive({
|
const formRules = reactive({
|
||||||
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
|
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
|
||||||
|
|
@ -152,6 +175,30 @@ const formRules = reactive({
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表
|
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
|
||||||
|
// 查找已存在的 HEX 选项(忽略大小写)
|
||||||
|
const options = getStrDictOptions(DICT_TYPE.IOT_CODEC_TYPE)
|
||||||
|
const hexOption = options.find((item) => item.value.toUpperCase() === 'HEX')
|
||||||
|
formData.value.codecType = hexOption ? hexOption.value : 'HEX' // 如果存在则使用存在的 options 值,否则使用 'HEX'
|
||||||
|
|
||||||
|
// 设置默认从站ID
|
||||||
|
if (!formData.value.modbusSlaveId) {
|
||||||
|
formData.value.modbusSlaveId = 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他协议使用 Alink 格式
|
||||||
|
formData.value.codecType = CodecTypeEnum.ALINK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开弹窗 */
|
/** 打开弹窗 */
|
||||||
const open = async (type: string, id?: number) => {
|
const open = async (type: string, id?: number) => {
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
@ -208,7 +255,9 @@ const resetForm = () => {
|
||||||
deviceType: undefined,
|
deviceType: undefined,
|
||||||
locationType: undefined,
|
locationType: undefined,
|
||||||
netType: undefined,
|
netType: undefined,
|
||||||
codecType: CodecTypeEnum.ALINK
|
codecType: CodecTypeEnum.ALINK,
|
||||||
|
protocolType: 'STANDARD',
|
||||||
|
modbusSlaveId: 1
|
||||||
}
|
}
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
}
|
}
|
||||||
|
|
@ -218,3 +267,15 @@ const generateProductKey = () => {
|
||||||
formData.value.productKey = generateRandomStr(16)
|
formData.value.productKey = generateRandomStr(16)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.modbus-tip {
|
||||||
|
color: #f56c6c;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
v-model="formData.config"
|
v-model="formData.config"
|
||||||
/>
|
/>
|
||||||
<MqttConfigForm v-if="IotDataSinkTypeEnum.MQTT === 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
|
<RocketMQConfigForm
|
||||||
v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
|
v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
|
||||||
v-model="formData.config"
|
v-model="formData.config"
|
||||||
|
|
@ -70,6 +74,7 @@ import { CommonStatusEnum } from '@/utils/constants'
|
||||||
import { DataSinkApi, DataSinkVO, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
|
import { DataSinkApi, DataSinkVO, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
|
||||||
import {
|
import {
|
||||||
HttpConfigForm,
|
HttpConfigForm,
|
||||||
|
DatabaseConfigForm,
|
||||||
KafkaMQConfigForm,
|
KafkaMQConfigForm,
|
||||||
MqttConfigForm,
|
MqttConfigForm,
|
||||||
RabbitMQConfigForm,
|
RabbitMQConfigForm,
|
||||||
|
|
@ -138,7 +143,10 @@ const formRules = reactive({
|
||||||
'config.database': [
|
'config.database': [
|
||||||
{ required: true, message: '数据库索引不能为空', trigger: 'blur' },
|
{ required: true, message: '数据库索引不能为空', trigger: 'blur' },
|
||||||
{ type: 'number', min: 0, 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
|
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>
|
<template>
|
||||||
<div v-for="(item, index) in items" :key="index" class="flex mb-2 w-full">
|
<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.key" class="mr-2" :placeholder="keyPlaceholder || '键'" />
|
||||||
<el-input v-model="item.value" placeholder="值" />
|
<el-input v-model="item.value" :placeholder="valuePlaceholder || '值'" />
|
||||||
<el-button class="ml-2" text type="danger" @click="removeItem(index)">
|
<el-button class="ml-2" text type="danger" @click="removeItem(index)">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Delete />
|
<Delete />
|
||||||
|
|
@ -31,20 +31,23 @@ interface KeyValueItem {
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: Record<string, string>
|
modelValue: Record<string, string>
|
||||||
addButtonText: string
|
addButtonText: string
|
||||||
|
keyPlaceholder?: string
|
||||||
|
valuePlaceholder?: string
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
const items = ref<KeyValueItem[]>([]) // 内部 key-value 项列表
|
const items = ref<KeyValueItem[]>([]) // 内部 key-value 项列表
|
||||||
|
const isInternalUpdate = ref(false) // 标记是否为内部更新,避免循环触发
|
||||||
|
|
||||||
/** 添加项目 */
|
/** 添加项目 */
|
||||||
const addItem = () => {
|
const addItem = () => {
|
||||||
items.value.push({ key: '', value: '' })
|
items.value.push({ key: '', value: '' })
|
||||||
updateModelValue()
|
// 不需要手动调用 updateModelValue,watch 会自动触发
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除项目 */
|
/** 移除项目 */
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
items.value.splice(index, 1)
|
items.value.splice(index, 1)
|
||||||
updateModelValue()
|
// 不需要手动调用 updateModelValue,watch 会自动触发
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新 modelValue */
|
/** 更新 modelValue */
|
||||||
|
|
@ -55,19 +58,43 @@ const updateModelValue = () => {
|
||||||
result[item.key] = item.value
|
result[item.key] = item.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
isInternalUpdate.value = true
|
||||||
emit('update:modelValue', result)
|
emit('update:modelValue', result)
|
||||||
|
nextTick(() => {
|
||||||
|
isInternalUpdate.value = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 监听项目变化 */
|
/** 监听项目变化 */
|
||||||
watch(items, updateModelValue, { deep: true })
|
watch(items, updateModelValue, { deep: true })
|
||||||
|
|
||||||
|
/** 监听 modelValue 变化,同步到内部 items */
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
// 列表有值后以列表中的值为准
|
// 如果是内部更新触发的,跳过处理
|
||||||
if (isEmpty(val) || !isEmpty(items.value)) {
|
if (isInternalUpdate.value) {
|
||||||
return
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import HttpConfigForm from './HttpConfigForm.vue'
|
||||||
import TcpConfigForm from './TcpConfigForm.vue'
|
import TcpConfigForm from './TcpConfigForm.vue'
|
||||||
import WebSocketConfigForm from './WebSocketConfigForm.vue'
|
import WebSocketConfigForm from './WebSocketConfigForm.vue'
|
||||||
import MqttConfigForm from './MqttConfigForm.vue'
|
import MqttConfigForm from './MqttConfigForm.vue'
|
||||||
|
import DatabaseConfigForm from './DatabaseConfigForm.vue'
|
||||||
import RocketMQConfigForm from './RocketMQConfigForm.vue'
|
import RocketMQConfigForm from './RocketMQConfigForm.vue'
|
||||||
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
|
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
|
||||||
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
|
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
|
||||||
|
|
@ -12,6 +13,7 @@ export {
|
||||||
TcpConfigForm,
|
TcpConfigForm,
|
||||||
WebSocketConfigForm,
|
WebSocketConfigForm,
|
||||||
MqttConfigForm,
|
MqttConfigForm,
|
||||||
|
DatabaseConfigForm,
|
||||||
RocketMQConfigForm,
|
RocketMQConfigForm,
|
||||||
KafkaMQConfigForm,
|
KafkaMQConfigForm,
|
||||||
RabbitMQConfigForm,
|
RabbitMQConfigForm,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
<!-- Modbus 命令详情对话框 -->
|
||||||
|
<template>
|
||||||
|
<Dialog v-model="dialogVisible" title="Modbus 命令详情" width="700px">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
此命令由系统根据您配置的 Modbus 参数自动生成,可直接用于设备通信
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-descriptions :column="1" border v-loading="loading">
|
||||||
|
<!-- 完整数据帧 -->
|
||||||
|
<el-descriptions-item label="完整数据帧">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<el-tag type="primary" size="large" class="font-mono">
|
||||||
|
{{ modbusConfig?.fullFrame || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="CopyDocument"
|
||||||
|
@click="copyToClipboard(modbusConfig?.fullFrame)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- CRC 校验码 -->
|
||||||
|
<el-descriptions-item label="CRC 校验码">
|
||||||
|
<el-tag type="success" class="font-mono">
|
||||||
|
{{ modbusConfig?.crcHex || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 功能码 -->
|
||||||
|
<el-descriptions-item label="功能码">
|
||||||
|
<el-tag type="warning">
|
||||||
|
{{ formatFunctionCode(modbusConfig?.functionCode) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="ml-2 text-gray-500">{{ getFunctionCodeDesc(modbusConfig?.functionCode) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 寄存器地址 -->
|
||||||
|
<el-descriptions-item label="寄存器地址">
|
||||||
|
<el-tag>{{ modbusConfig?.registerAddress ?? '-' }}</el-tag>
|
||||||
|
<span class="ml-2 text-gray-500">(十进制)</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 寄存器数量 -->
|
||||||
|
<el-descriptions-item label="寄存器数量">
|
||||||
|
<el-tag>{{ modbusConfig?.registerCount ?? '-' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 字节序 -->
|
||||||
|
<el-descriptions-item label="字节序">
|
||||||
|
<el-tag :type="modbusConfig?.byteOrder === 'BIG_ENDIAN' ? 'primary' : 'info'">
|
||||||
|
{{ getByteOrderLabel(modbusConfig?.byteOrder) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 数据采集模式 -->
|
||||||
|
<el-descriptions-item label="数据采集模式">
|
||||||
|
<el-tag :type="modbusConfig?.collectionMode === 1 ? 'success' : 'warning'">
|
||||||
|
{{ getCollectionModeLabel(modbusConfig?.collectionMode) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<!-- 采集频率(仅定时轮询模式) -->
|
||||||
|
<el-descriptions-item
|
||||||
|
v-if="modbusConfig?.collectionMode === 1"
|
||||||
|
label="采集频率"
|
||||||
|
>
|
||||||
|
<el-tag type="info">
|
||||||
|
{{ modbusConfig?.pollingInterval ?? '-' }} 秒
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CopyDocument } from '@element-plus/icons-vue'
|
||||||
|
import { ModbusConfig, ThingModelApi } from '@/api/iot/thingmodel'
|
||||||
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
import { IoTModbusCollectionModeEnum, IoTModbusByteOrderEnum } from '@/views/iot/utils/constants'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ThingModelModbusCommandDialog' })
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const { copy } = useClipboard()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const modbusConfig = ref<ModbusConfig | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/** 打开对话框 */
|
||||||
|
const open = async (config: ModbusConfig, productId: number) => {
|
||||||
|
modbusConfig.value = { ...config }
|
||||||
|
dialogVisible.value = true
|
||||||
|
|
||||||
|
// 如果 fullFrame 为空,实时生成命令
|
||||||
|
if (!config.fullFrame && config.registerAddress !== undefined && config.functionCode) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await ThingModelApi.calculateModbusCommand({
|
||||||
|
productId: productId,
|
||||||
|
registerAddress: config.registerAddress,
|
||||||
|
functionCode: config.functionCode,
|
||||||
|
registerCount: config.registerCount,
|
||||||
|
byteOrder: config.byteOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('API 返回结果:', result)
|
||||||
|
|
||||||
|
// 更新命令数据
|
||||||
|
if (modbusConfig.value && result) {
|
||||||
|
modbusConfig.value.fullFrame = result.fullFrame
|
||||||
|
modbusConfig.value.crcHex = result.crcHex
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('生成 Modbus 命令失败', error)
|
||||||
|
message.error('生成命令失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
|
||||||
|
/** 格式化功能码 */
|
||||||
|
const formatFunctionCode = (code: number | undefined) => {
|
||||||
|
if (code === undefined) return '-'
|
||||||
|
return `0x${code.toString(16).toUpperCase().padStart(2, '0')} (${code})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取功能码描述 */
|
||||||
|
const getFunctionCodeDesc = (code: number | undefined) => {
|
||||||
|
const descriptions: Record<number, string> = {
|
||||||
|
1: '读线圈',
|
||||||
|
2: '读离散输入',
|
||||||
|
3: '读保持寄存器',
|
||||||
|
4: '读输入寄存器',
|
||||||
|
5: '写单个线圈',
|
||||||
|
6: '写单个寄存器',
|
||||||
|
15: '写多个线圈',
|
||||||
|
16: '写多个寄存器'
|
||||||
|
}
|
||||||
|
return code !== undefined ? descriptions[code] || '自定义功能码' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取字节序标签 */
|
||||||
|
const getByteOrderLabel = (byteOrder: string | undefined) => {
|
||||||
|
if (!byteOrder) return '-'
|
||||||
|
const order = Object.values(IoTModbusByteOrderEnum).find(o => o.value === byteOrder)
|
||||||
|
return order ? order.label : byteOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取采集模式标签 */
|
||||||
|
const getCollectionModeLabel = (mode: number | undefined) => {
|
||||||
|
if (mode === undefined) return '-'
|
||||||
|
const modeEnum = Object.values(IoTModbusCollectionModeEnum).find(m => m.value === mode)
|
||||||
|
return modeEnum ? modeEnum.label : mode.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制到剪贴板 */
|
||||||
|
const copyToClipboard = async (text: string | undefined) => {
|
||||||
|
if (!text) {
|
||||||
|
message.warning('没有可复制的内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copy(text)
|
||||||
|
message.success('已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-descriptions__label) {
|
||||||
|
width: 120px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tag) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -93,6 +93,98 @@
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</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>
|
||||||
|
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 字节序 -->
|
||||||
|
<el-form-item label="字节序" prop="property.modbusConfig.byteOrder">
|
||||||
|
<el-radio-group v-model="property.modbusConfig.byteOrder">
|
||||||
|
<el-radio
|
||||||
|
v-for="order in Object.values(IoTModbusByteOrderEnum)"
|
||||||
|
:key="order.value"
|
||||||
|
:label="order.value"
|
||||||
|
>
|
||||||
|
{{ order.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
</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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
@ -103,13 +195,19 @@ import {
|
||||||
ThingModelNumberDataSpecs,
|
ThingModelNumberDataSpecs,
|
||||||
ThingModelStructDataSpecs
|
ThingModelStructDataSpecs
|
||||||
} from './dataSpecs'
|
} from './dataSpecs'
|
||||||
import { ThingModelProperty, validateBoolName } from '@/api/iot/thingmodel'
|
import { ThingModelApi, ThingModelProperty, validateBoolName } from '@/api/iot/thingmodel'
|
||||||
import { isEmpty } from '@/utils/is'
|
import { isEmpty } from '@/utils/is'
|
||||||
import {
|
import {
|
||||||
getDataTypeOptions,
|
getDataTypeOptions,
|
||||||
IoTDataSpecsDataTypeEnum,
|
IoTDataSpecsDataTypeEnum,
|
||||||
IoTThingModelAccessModeEnum
|
IoTThingModelAccessModeEnum,
|
||||||
|
IoTModbusCollectionModeEnum,
|
||||||
|
IoTModbusByteOrderEnum,
|
||||||
|
IOT_PROVIDE_KEY
|
||||||
} from '@/views/iot/utils/constants'
|
} from '@/views/iot/utils/constants'
|
||||||
|
import { ProductVO } from '@/api/iot/product/product'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
|
||||||
/** IoT 物模型属性 */
|
/** IoT 物模型属性 */
|
||||||
defineOptions({ name: 'ThingModelProperty' })
|
defineOptions({ name: 'ThingModelProperty' })
|
||||||
|
|
@ -117,6 +215,14 @@ defineOptions({ name: 'ThingModelProperty' })
|
||||||
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean; isParams?: boolean }>()
|
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean; isParams?: boolean }>()
|
||||||
const emits = defineEmits(['update:modelValue'])
|
const emits = defineEmits(['update:modelValue'])
|
||||||
const property = useVModel(props, 'modelValue', emits) as Ref<ThingModelProperty>
|
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(() => {
|
const getDataTypeOptions2 = computed(() => {
|
||||||
if (!props.isStructDataSpecs) {
|
if (!props.isStructDataSpecs) {
|
||||||
return getDataTypeOptions()
|
return getDataTypeOptions()
|
||||||
|
|
@ -166,6 +272,97 @@ watch(
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ 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
|
||||||
|
}
|
||||||
|
// 向下兼容:如果没有 byteOrder,默认设置为大端模式
|
||||||
|
if (isModbus && property.value.modbusConfig && !property.value.modbusConfig.byteOrder) {
|
||||||
|
property.value.modbusConfig.byteOrder = IoTModbusByteOrderEnum.BIG_ENDIAN.value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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,
|
||||||
|
registerCount: property.value.modbusConfig?.registerCount || 1,
|
||||||
|
byteOrder: property.value.modbusConfig?.byteOrder || 2 // 默认小端模式
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
@ -174,4 +371,12 @@ watch(
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modbus-config-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modbus-config-section .el-divider {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,56 @@
|
||||||
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
|
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
placeholder="写入值"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 字节序 -->
|
||||||
|
<el-form-item label="字节序" prop="service.modbusConfig.byteOrder">
|
||||||
|
<el-radio-group v-model="service.modbusConfig.byteOrder">
|
||||||
|
<el-radio
|
||||||
|
v-for="order in Object.values(IoTModbusByteOrderEnum)"
|
||||||
|
:key="order.value"
|
||||||
|
:label="order.value"
|
||||||
|
>
|
||||||
|
{{ order.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
@ -36,7 +86,8 @@ import { ThingModelService } from '@/api/iot/thingmodel'
|
||||||
import { isEmpty } from '@/utils/is'
|
import { isEmpty } from '@/utils/is'
|
||||||
import {
|
import {
|
||||||
IoTThingModelParamDirectionEnum,
|
IoTThingModelParamDirectionEnum,
|
||||||
IoTThingModelServiceCallTypeEnum
|
IoTThingModelServiceCallTypeEnum,
|
||||||
|
IoTModbusByteOrderEnum
|
||||||
} from '@/views/iot/utils/constants'
|
} from '@/views/iot/utils/constants'
|
||||||
|
|
||||||
/** IoT 物模型服务 */
|
/** IoT 物模型服务 */
|
||||||
|
|
@ -46,6 +97,24 @@ const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
|
||||||
const emits = defineEmits(['update:modelValue'])
|
const emits = defineEmits(['update:modelValue'])
|
||||||
const service = useVModel(props, 'modelValue', emits) as Ref<ThingModelService>
|
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: '',
|
||||||
|
byteOrder: IoTModbusByteOrderEnum.BIG_ENDIAN.value // 默认大端模式
|
||||||
|
}
|
||||||
|
} else if (!service.value.modbusConfig.byteOrder) {
|
||||||
|
// 向下兼容:如果没有 byteOrder,默认设置为大端模式
|
||||||
|
service.value.modbusConfig.byteOrder = IoTModbusByteOrderEnum.BIG_ENDIAN.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** 默认选中,ASYNC 异步 */
|
/** 默认选中,ASYNC 异步 */
|
||||||
watch(
|
watch(
|
||||||
() => service.value.callType,
|
() => service.value.callType,
|
||||||
|
|
@ -61,4 +130,10 @@ watch(
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.form-item-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
<DataDefinition :data="row" />
|
<DataDefinition :data="row" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="center" label="操作">
|
<el-table-column align="center" label="操作" width="200">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
v-hasPermi="[`iot:thing-model:update`]"
|
v-hasPermi="[`iot:thing-model:update`]"
|
||||||
|
|
@ -81,6 +81,15 @@
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="showModbusCommandButton(scope.row)"
|
||||||
|
v-hasPermi="['iot:thing-model:query']"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
@click="openModbusCommand(scope.row)"
|
||||||
|
>
|
||||||
|
查看命令
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
@ -97,12 +106,15 @@
|
||||||
<!-- 表单弹窗:添加/修改 -->
|
<!-- 表单弹窗:添加/修改 -->
|
||||||
<ThingModelForm ref="formRef" @success="getList" />
|
<ThingModelForm ref="formRef" @success="getList" />
|
||||||
<ThingModelTSL ref="tslRef" />
|
<ThingModelTSL ref="tslRef" />
|
||||||
|
<!-- Modbus 命令详情对话框 -->
|
||||||
|
<ThingModelModbusCommandDialog ref="modbusCommandDialogRef" />
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
|
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
|
||||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||||
import ThingModelForm from './ThingModelForm.vue'
|
import ThingModelForm from './ThingModelForm.vue'
|
||||||
import ThingModelTSL from './ThingModelTSL.vue'
|
import ThingModelTSL from './ThingModelTSL.vue'
|
||||||
|
import ThingModelModbusCommandDialog from './ThingModelModbusCommandDialog.vue'
|
||||||
import { ProductVO } from '@/api/iot/product/product'
|
import { ProductVO } from '@/api/iot/product/product'
|
||||||
import { getDataTypeOptionsLabel, IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
|
import { getDataTypeOptionsLabel, IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
|
||||||
import { DataDefinition } from './components'
|
import { DataDefinition } from './components'
|
||||||
|
|
@ -169,6 +181,28 @@ const handleDelete = async (id: number) => {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断是否显示 Modbus 命令按钮 */
|
||||||
|
const showModbusCommandButton = (row: ThingModelData) => {
|
||||||
|
// 必须是 Modbus 产品
|
||||||
|
const isModbusProduct = product?.value?.protocolType === 'MODBUS_RTU' || product?.value?.protocolType === 'MODBUS_TCP'
|
||||||
|
if (!isModbusProduct) return false
|
||||||
|
|
||||||
|
// 必须是属性类型(type = 1)
|
||||||
|
if (row.type !== 1) return false
|
||||||
|
|
||||||
|
// 必须有 modbusConfig 且配置了寄存器地址和功能码
|
||||||
|
const config = row.property?.modbusConfig
|
||||||
|
return !!(config && config.registerAddress !== undefined && config.functionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 Modbus 命令详情对话框 */
|
||||||
|
const modbusCommandDialogRef = ref()
|
||||||
|
const openModbusCommand = (row: ThingModelData) => {
|
||||||
|
if (row.property?.modbusConfig && product?.value?.id) {
|
||||||
|
modbusCommandDialogRef.value?.open(row.property.modbusConfig, product.value.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 初始化 **/
|
/** 初始化 **/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getList()
|
getList()
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,43 @@ export const THING_MODEL_GROUP_LABELS = {
|
||||||
SERVICE: '设备服务'
|
SERVICE: '设备服务'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** IoT Modbus 数据采集模式枚举 */
|
||||||
|
export const IoTModbusCollectionModeEnum = {
|
||||||
|
POLLING: {
|
||||||
|
label: '定时轮询',
|
||||||
|
value: 1
|
||||||
|
},
|
||||||
|
ACTIVE_REPORT: {
|
||||||
|
label: '主动上报',
|
||||||
|
value: 2,
|
||||||
|
|
||||||
|
}
|
||||||
|
} 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 Modbus 字节序枚举 */
|
||||||
|
export const IoTModbusByteOrderEnum = {
|
||||||
|
BIG_ENDIAN: {
|
||||||
|
label: 'ABCD',
|
||||||
|
value: 1
|
||||||
|
},
|
||||||
|
LITTLE_ENDIAN: {
|
||||||
|
label: 'CDAB',
|
||||||
|
value: 2
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 获取 Modbus 字节序标签 */
|
||||||
|
export const getModbusByteOrderLabel = (value: number): string => {
|
||||||
|
const order = Object.values(IoTModbusByteOrderEnum).find((order) => order.value === value)
|
||||||
|
return order?.label || String(value)
|
||||||
|
}
|
||||||
|
|
||||||
// IoT OTA 任务设备范围枚举
|
// IoT OTA 任务设备范围枚举
|
||||||
export const IoTOtaTaskDeviceScopeEnum = {
|
export const IoTOtaTaskDeviceScopeEnum = {
|
||||||
ALL: {
|
ALL: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue