!222 Vue3 + Element Plus版本iot前端迁移到vben版本
Merge pull request !222 from huppygo/master
This commit is contained in:
95
apps/web-antd/src/api/iot/alert/config/index.ts
Normal file
95
apps/web-antd/src/api/iot/alert/config/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace AlertConfigApi {
|
||||
/** IoT 告警配置 VO */
|
||||
export interface AlertConfig {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
level?: number;
|
||||
status?: number;
|
||||
sceneRuleIds?: number[];
|
||||
receiveUserIds?: number[];
|
||||
receiveUserNames?: string;
|
||||
receiveTypes?: number[];
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 告警配置 */
|
||||
export interface AlertConfig {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
level?: number;
|
||||
status?: number;
|
||||
sceneRuleIds?: number[];
|
||||
receiveUserIds?: number[];
|
||||
receiveUserNames?: string;
|
||||
receiveTypes?: number[];
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询告警配置分页 */
|
||||
export function getAlertConfigPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<AlertConfigApi.AlertConfig>>(
|
||||
'/iot/alert-config/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询告警配置详情 */
|
||||
export function getAlertConfig(id: number) {
|
||||
return requestClient.get<AlertConfigApi.AlertConfig>(
|
||||
`/iot/alert-config/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询所有告警配置列表 */
|
||||
export function getAlertConfigList() {
|
||||
return requestClient.get<AlertConfigApi.AlertConfig[]>('/iot/alert-config/list');
|
||||
}
|
||||
|
||||
/** 新增告警配置 */
|
||||
export function createAlertConfig(data: AlertConfig) {
|
||||
return requestClient.post('/iot/alert-config/create', data);
|
||||
}
|
||||
|
||||
/** 修改告警配置 */
|
||||
export function updateAlertConfig(data: AlertConfig) {
|
||||
return requestClient.put('/iot/alert-config/update', data);
|
||||
}
|
||||
|
||||
/** 删除告警配置 */
|
||||
export function deleteAlertConfig(id: number) {
|
||||
return requestClient.delete(`/iot/alert-config/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除告警配置 */
|
||||
export function deleteAlertConfigList(ids: number[]) {
|
||||
return requestClient.delete('/iot/alert-config/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 启用/禁用告警配置 */
|
||||
export function toggleAlertConfig(id: number, enabled: boolean) {
|
||||
return requestClient.put(`/iot/alert-config/toggle`, {
|
||||
id,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取告警配置简单列表 */
|
||||
export function getSimpleAlertConfigList() {
|
||||
return requestClient.get<AlertConfigApi.AlertConfig[]>(
|
||||
'/iot/alert-config/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
export { AlertConfigApi };
|
||||
|
||||
85
apps/web-antd/src/api/iot/alert/record/index.ts
Normal file
85
apps/web-antd/src/api/iot/alert/record/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace AlertRecordApi {
|
||||
/** IoT 告警记录 VO */
|
||||
export interface AlertRecord {
|
||||
id?: number;
|
||||
configId?: number;
|
||||
configName?: string;
|
||||
configLevel?: number;
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
deviceMessage?: string;
|
||||
processStatus?: boolean;
|
||||
processRemark?: string;
|
||||
processTime?: Date;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 告警记录 */
|
||||
export interface AlertRecord {
|
||||
id?: number;
|
||||
configId?: number;
|
||||
configName?: string;
|
||||
configLevel?: number;
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
deviceMessage?: string;
|
||||
processStatus?: boolean;
|
||||
processRemark?: string;
|
||||
processTime?: Date;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询告警记录分页 */
|
||||
export function getAlertRecordPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<AlertRecordApi.AlertRecord>>(
|
||||
'/iot/alert-record/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询告警记录详情 */
|
||||
export function getAlertRecord(id: number) {
|
||||
return requestClient.get<AlertRecordApi.AlertRecord>(
|
||||
`/iot/alert-record/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 处理告警记录 */
|
||||
export function processAlertRecord(id: number, remark?: string) {
|
||||
return requestClient.put('/iot/alert-record/process', {
|
||||
id,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量处理告警记录 */
|
||||
export function batchProcessAlertRecord(ids: number[], remark?: string) {
|
||||
return requestClient.put('/iot/alert-record/batch-process', {
|
||||
ids,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除告警记录 */
|
||||
export function deleteAlertRecord(id: number) {
|
||||
return requestClient.delete(`/iot/alert-record/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除告警记录 */
|
||||
export function deleteAlertRecordList(ids: number[]) {
|
||||
return requestClient.delete('/iot/alert-record/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
export { AlertRecordApi };
|
||||
|
||||
224
apps/web-antd/src/api/iot/device/device/index.ts
Normal file
224
apps/web-antd/src/api/iot/device/device/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotDeviceApi {
|
||||
/** IoT 设备 VO */
|
||||
export interface Device {
|
||||
id?: number; // 设备 ID,主键,自增
|
||||
deviceName: string; // 设备名称
|
||||
productId: number; // 产品编号
|
||||
productKey?: string; // 产品标识
|
||||
deviceType?: number; // 设备类型
|
||||
nickname?: string; // 设备备注名称
|
||||
gatewayId?: number; // 网关设备 ID
|
||||
state?: number; // 设备状态
|
||||
status?: number; // 设备状态(兼容字段)
|
||||
onlineTime?: Date; // 最后上线时间
|
||||
offlineTime?: Date; // 最后离线时间
|
||||
activeTime?: Date; // 设备激活时间
|
||||
createTime?: Date; // 创建时间
|
||||
ip?: string; // 设备的 IP 地址
|
||||
firmwareVersion?: string; // 设备的固件版本
|
||||
deviceSecret?: string; // 设备密钥,用于设备认证,需安全存储
|
||||
mqttClientId?: string; // MQTT 客户端 ID
|
||||
mqttUsername?: string; // MQTT 用户名
|
||||
mqttPassword?: string; // MQTT 密码
|
||||
authType?: string; // 认证类型
|
||||
locationType?: number; // 定位类型
|
||||
latitude?: number; // 设备位置的纬度
|
||||
longitude?: number; // 设备位置的经度
|
||||
areaId?: number; // 地区编码
|
||||
address?: string; // 设备详细地址
|
||||
serialNumber?: string; // 设备序列号
|
||||
config?: string; // 设备配置
|
||||
groupIds?: number[]; // 添加分组 ID
|
||||
picUrl?: string; // 设备图片
|
||||
location?: string; // 位置信息(格式:经度,纬度)
|
||||
}
|
||||
|
||||
/** IoT 设备属性详细 VO */
|
||||
export interface DevicePropertyDetail {
|
||||
identifier: string; // 属性标识符
|
||||
value: string; // 最新值
|
||||
updateTime: Date; // 更新时间
|
||||
name: string; // 属性名称
|
||||
dataType: string; // 数据类型
|
||||
dataSpecs: any; // 数据定义
|
||||
dataSpecsList: any[]; // 数据定义列表
|
||||
}
|
||||
|
||||
/** IoT 设备属性 VO */
|
||||
export interface DeviceProperty {
|
||||
identifier: string; // 属性标识符
|
||||
value: string; // 最新值
|
||||
updateTime: Date; // 更新时间
|
||||
}
|
||||
|
||||
/** 设备认证参数 VO */
|
||||
export interface DeviceAuthInfo {
|
||||
clientId: string; // 客户端 ID
|
||||
username: string; // 用户名
|
||||
password: string; // 密码
|
||||
}
|
||||
|
||||
/** IoT 设备发送消息 Request VO */
|
||||
export interface DeviceMessageSendReq {
|
||||
deviceId: number; // 设备编号
|
||||
method: string; // 请求方法
|
||||
params?: any; // 请求参数
|
||||
}
|
||||
|
||||
/** 设备分组更新请求 */
|
||||
export interface DeviceGroupUpdateReq {
|
||||
ids: number[]; // 设备 ID 列表
|
||||
groupIds: number[]; // 分组 ID 列表
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 设备状态枚举 */
|
||||
export enum DeviceStateEnum {
|
||||
INACTIVE = 0, // 未激活
|
||||
ONLINE = 1, // 在线
|
||||
OFFLINE = 2, // 离线
|
||||
}
|
||||
|
||||
/** 查询设备分页 */
|
||||
export function getDevicePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
||||
'/iot/device/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询设备详情 */
|
||||
export function getDevice(id: number) {
|
||||
return requestClient.get<IotDeviceApi.Device>(`/iot/device/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 新增设备 */
|
||||
export function createDevice(data: IotDeviceApi.Device) {
|
||||
return requestClient.post('/iot/device/create', data);
|
||||
}
|
||||
|
||||
/** 修改设备 */
|
||||
export function updateDevice(data: IotDeviceApi.Device) {
|
||||
return requestClient.put('/iot/device/update', data);
|
||||
}
|
||||
|
||||
/** 修改设备分组 */
|
||||
export function updateDeviceGroup(data: IotDeviceApi.DeviceGroupUpdateReq) {
|
||||
return requestClient.put('/iot/device/update-group', data);
|
||||
}
|
||||
|
||||
/** 删除单个设备 */
|
||||
export function deleteDevice(id: number) {
|
||||
return requestClient.delete(`/iot/device/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 删除多个设备 */
|
||||
export function deleteDeviceList(ids: number[]) {
|
||||
return requestClient.delete('/iot/device/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出设备 */
|
||||
export function exportDeviceExcel(params: any) {
|
||||
return requestClient.download('/iot/device/export-excel', { params });
|
||||
}
|
||||
|
||||
/** 获取设备数量 */
|
||||
export function getDeviceCount(productId: number) {
|
||||
return requestClient.get<number>(`/iot/device/count?productId=${productId}`);
|
||||
}
|
||||
|
||||
/** 获取设备的精简信息列表 */
|
||||
export function getSimpleDeviceList(deviceType?: number, productId?: number) {
|
||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
||||
params: { deviceType, productId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 根据产品编号,获取设备的精简信息列表 */
|
||||
export function getDeviceListByProductId(productId: number) {
|
||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
||||
params: { productId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取导入模板 */
|
||||
export function importDeviceTemplate() {
|
||||
return requestClient.download('/iot/device/get-import-template');
|
||||
}
|
||||
|
||||
/** 获取设备属性最新数据 */
|
||||
export function getLatestDeviceProperties(params: any) {
|
||||
return requestClient.get<IotDeviceApi.DevicePropertyDetail[]>(
|
||||
'/iot/device/property/get-latest',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备属性历史数据 */
|
||||
export function getHistoryDevicePropertyList(params: any) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.DeviceProperty>>(
|
||||
'/iot/device/property/history-list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备认证信息 */
|
||||
export function getDeviceAuthInfo(id: number) {
|
||||
return requestClient.get<IotDeviceApi.DeviceAuthInfo>(
|
||||
'/iot/device/get-auth-info',
|
||||
{ params: { id } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询设备消息分页 */
|
||||
export function getDeviceMessagePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<any>>('/iot/device/message/page', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询设备消息配对分页 */
|
||||
export function getDeviceMessagePairPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<any>>('/iot/device/message/pair-page', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 发送设备消息 */
|
||||
export function sendDeviceMessage(params: IotDeviceApi.DeviceMessageSendReq) {
|
||||
return requestClient.post('/iot/device/message/send', params);
|
||||
}
|
||||
|
||||
// Export aliases for compatibility
|
||||
export const DeviceApi = {
|
||||
getDevicePage,
|
||||
getDevice,
|
||||
createDevice,
|
||||
updateDevice,
|
||||
updateDeviceGroup,
|
||||
deleteDevice,
|
||||
deleteDeviceList,
|
||||
exportDeviceExcel,
|
||||
getDeviceCount,
|
||||
getSimpleDeviceList,
|
||||
getDeviceListByProductId,
|
||||
importDeviceTemplate,
|
||||
getLatestDeviceProperties,
|
||||
getHistoryDevicePropertyList,
|
||||
getDeviceAuthInfo,
|
||||
getDeviceMessagePage,
|
||||
getDeviceMessagePairPage,
|
||||
sendDeviceMessage,
|
||||
};
|
||||
|
||||
export type DeviceVO = IotDeviceApi.Device;
|
||||
export type IotDeviceAuthInfoVO = IotDeviceApi.DeviceAuthInfo;
|
||||
export type IotDevicePropertyDetailRespVO = IotDeviceApi.DevicePropertyDetail;
|
||||
export type IotDevicePropertyRespVO = IotDeviceApi.DeviceProperty;
|
||||
|
||||
52
apps/web-antd/src/api/iot/device/group/index.ts
Normal file
52
apps/web-antd/src/api/iot/device/group/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotDeviceGroupApi {
|
||||
/** IoT 设备分组 VO */
|
||||
export interface DeviceGroup {
|
||||
id?: number; // 分组 ID
|
||||
name: string; // 分组名字
|
||||
status?: number; // 分组状态
|
||||
description?: string; // 分组描述
|
||||
deviceCount?: number; // 设备数量
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询设备分组分页 */
|
||||
export function getDeviceGroupPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotDeviceGroupApi.DeviceGroup>>(
|
||||
'/iot/device-group/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询设备分组详情 */
|
||||
export function getDeviceGroup(id: number) {
|
||||
return requestClient.get<IotDeviceGroupApi.DeviceGroup>(
|
||||
`/iot/device-group/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增设备分组 */
|
||||
export function createDeviceGroup(data: IotDeviceGroupApi.DeviceGroup) {
|
||||
return requestClient.post('/iot/device-group/create', data);
|
||||
}
|
||||
|
||||
/** 修改设备分组 */
|
||||
export function updateDeviceGroup(data: IotDeviceGroupApi.DeviceGroup) {
|
||||
return requestClient.put('/iot/device-group/update', data);
|
||||
}
|
||||
|
||||
/** 删除设备分组 */
|
||||
export function deleteDeviceGroup(id: number) {
|
||||
return requestClient.delete(`/iot/device-group/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 获取设备分组的精简信息列表 */
|
||||
export function getSimpleDeviceGroupList() {
|
||||
return requestClient.get<IotDeviceGroupApi.DeviceGroup[]>(
|
||||
'/iot/device-group/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
90
apps/web-antd/src/api/iot/ota/firmware/index.ts
Normal file
90
apps/web-antd/src/api/iot/ota/firmware/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaFirmwareApi {
|
||||
/** IoT OTA 固件 VO */
|
||||
export interface Firmware {
|
||||
id?: number;
|
||||
name: string;
|
||||
version: string;
|
||||
productId: number;
|
||||
productName?: string;
|
||||
description?: string;
|
||||
fileUrl?: string;
|
||||
fileMd5?: string;
|
||||
fileSize?: number;
|
||||
status?: number;
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT OTA 固件 */
|
||||
export interface IoTOtaFirmware {
|
||||
id?: number;
|
||||
name?: string;
|
||||
version?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
description?: string;
|
||||
fileUrl?: string;
|
||||
fileMd5?: string;
|
||||
fileSize?: number;
|
||||
status?: number;
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询 OTA 固件分页 */
|
||||
export function getOtaFirmwarePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IoTOtaFirmwareApi.Firmware>>(
|
||||
'/iot/ota-firmware/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询 OTA 固件详情 */
|
||||
export function getOtaFirmware(id: number) {
|
||||
return requestClient.get<IoTOtaFirmwareApi.Firmware>(
|
||||
`/iot/ota-firmware/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增 OTA 固件 */
|
||||
export function createOtaFirmware(data: IoTOtaFirmware) {
|
||||
return requestClient.post('/iot/ota-firmware/create', data);
|
||||
}
|
||||
|
||||
/** 修改 OTA 固件 */
|
||||
export function updateOtaFirmware(data: IoTOtaFirmware) {
|
||||
return requestClient.put('/iot/ota-firmware/update', data);
|
||||
}
|
||||
|
||||
/** 删除 OTA 固件 */
|
||||
export function deleteOtaFirmware(id: number) {
|
||||
return requestClient.delete(`/iot/ota-firmware/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除 OTA 固件 */
|
||||
export function deleteOtaFirmwareList(ids: number[]) {
|
||||
return requestClient.delete('/iot/ota-firmware/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新 OTA 固件状态 */
|
||||
export function updateOtaFirmwareStatus(id: number, status: number) {
|
||||
return requestClient.put(`/iot/ota-firmware/update-status`, {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/** 根据产品 ID 查询固件列表 */
|
||||
export function getOtaFirmwareListByProductId(productId: number) {
|
||||
return requestClient.get<IoTOtaFirmwareApi.Firmware[]>(
|
||||
'/iot/ota-firmware/list-by-product-id',
|
||||
{ params: { productId } },
|
||||
);
|
||||
}
|
||||
101
apps/web-antd/src/api/iot/ota/task/index.ts
Normal file
101
apps/web-antd/src/api/iot/ota/task/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaTaskApi {
|
||||
/** IoT OTA 升级任务 VO */
|
||||
export interface Task {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
firmwareId: number;
|
||||
firmwareName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
deviceScope?: number;
|
||||
deviceIds?: number[];
|
||||
status?: number;
|
||||
successCount?: number;
|
||||
failureCount?: number;
|
||||
pendingCount?: number;
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT OTA 升级任务 */
|
||||
export interface OtaTask {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
firmwareId?: number;
|
||||
firmwareName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
deviceScope?: number;
|
||||
deviceIds?: number[];
|
||||
status?: number;
|
||||
successCount?: number;
|
||||
failureCount?: number;
|
||||
pendingCount?: number;
|
||||
createTime?: Date;
|
||||
updateTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询 OTA 升级任务分页 */
|
||||
export function getOtaTaskPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IoTOtaTaskApi.Task>>(
|
||||
'/iot/ota-task/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询 OTA 升级任务详情 */
|
||||
export function getOtaTask(id: number) {
|
||||
return requestClient.get<IoTOtaTaskApi.Task>(`/iot/ota-task/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 新增 OTA 升级任务 */
|
||||
export function createOtaTask(data: OtaTask) {
|
||||
return requestClient.post('/iot/ota-task/create', data);
|
||||
}
|
||||
|
||||
/** 修改 OTA 升级任务 */
|
||||
export function updateOtaTask(data: OtaTask) {
|
||||
return requestClient.put('/iot/ota-task/update', data);
|
||||
}
|
||||
|
||||
/** 删除 OTA 升级任务 */
|
||||
export function deleteOtaTask(id: number) {
|
||||
return requestClient.delete(`/iot/ota-task/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除 OTA 升级任务 */
|
||||
export function deleteOtaTaskList(ids: number[]) {
|
||||
return requestClient.delete('/iot/ota-task/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消 OTA 升级任务 */
|
||||
export function cancelOtaTask(id: number) {
|
||||
return requestClient.put(`/iot/ota-task/cancel?id=${id}`);
|
||||
}
|
||||
|
||||
/** 启动 OTA 升级任务 */
|
||||
export function startOtaTask(id: number) {
|
||||
return requestClient.put(`/iot/ota-task/start?id=${id}`);
|
||||
}
|
||||
|
||||
/** 暂停 OTA 升级任务 */
|
||||
export function pauseOtaTask(id: number) {
|
||||
return requestClient.put(`/iot/ota-task/pause?id=${id}`);
|
||||
}
|
||||
|
||||
/** 恢复 OTA 升级任务 */
|
||||
export function resumeOtaTask(id: number) {
|
||||
return requestClient.put(`/iot/ota-task/resume?id=${id}`);
|
||||
}
|
||||
|
||||
export { IoTOtaTaskApi };
|
||||
|
||||
104
apps/web-antd/src/api/iot/ota/task/record/index.ts
Normal file
104
apps/web-antd/src/api/iot/ota/task/record/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaTaskRecordApi {
|
||||
/** IoT OTA 升级任务记录 VO */
|
||||
export interface TaskRecord {
|
||||
id?: number;
|
||||
taskId: number;
|
||||
taskName?: string;
|
||||
deviceId: number;
|
||||
deviceName?: string;
|
||||
firmwareId?: number;
|
||||
firmwareName?: string;
|
||||
firmwareVersion?: string;
|
||||
status?: number;
|
||||
progress?: number;
|
||||
errorMessage?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT OTA 升级任务记录 */
|
||||
export interface OtaTaskRecord {
|
||||
id?: number;
|
||||
taskId?: number;
|
||||
taskName?: string;
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
firmwareId?: number;
|
||||
firmwareName?: string;
|
||||
firmwareVersion?: string;
|
||||
status?: number;
|
||||
progress?: number;
|
||||
errorMessage?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询 OTA 升级任务记录分页 */
|
||||
export function getOtaTaskRecordPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IoTOtaTaskRecordApi.TaskRecord>>(
|
||||
'/iot/ota-task-record/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询 OTA 升级任务记录详情 */
|
||||
export function getOtaTaskRecord(id: number) {
|
||||
return requestClient.get<IoTOtaTaskRecordApi.TaskRecord>(
|
||||
`/iot/ota-task-record/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据任务 ID 查询记录列表 */
|
||||
export function getOtaTaskRecordListByTaskId(taskId: number) {
|
||||
return requestClient.get<IoTOtaTaskRecordApi.TaskRecord[]>(
|
||||
'/iot/ota-task-record/list-by-task-id',
|
||||
{ params: { taskId } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据设备 ID 查询记录列表 */
|
||||
export function getOtaTaskRecordListByDeviceId(deviceId: number) {
|
||||
return requestClient.get<IoTOtaTaskRecordApi.TaskRecord[]>(
|
||||
'/iot/ota-task-record/list-by-device-id',
|
||||
{ params: { deviceId } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据固件 ID 查询记录列表 */
|
||||
export function getOtaTaskRecordListByFirmwareId(firmwareId: number) {
|
||||
return requestClient.get<IoTOtaTaskRecordApi.TaskRecord[]>(
|
||||
'/iot/ota-task-record/list-by-firmware-id',
|
||||
{ params: { firmwareId } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 重试升级任务记录 */
|
||||
export function retryOtaTaskRecord(id: number) {
|
||||
return requestClient.put(`/iot/ota-task-record/retry?id=${id}`);
|
||||
}
|
||||
|
||||
/** 取消升级任务记录 */
|
||||
export function cancelOtaTaskRecord(id: number) {
|
||||
return requestClient.put(`/iot/ota-task-record/cancel?id=${id}`);
|
||||
}
|
||||
|
||||
/** 获取升级任务记录状态统计 */
|
||||
export function getOtaTaskRecordStatusStatistics(
|
||||
firmwareId?: number,
|
||||
taskId?: number,
|
||||
) {
|
||||
return requestClient.get<Record<string, number>>(
|
||||
'/iot/ota-task-record/status-statistics',
|
||||
{ params: { firmwareId, taskId } },
|
||||
);
|
||||
}
|
||||
|
||||
export { IoTOtaTaskRecordApi };
|
||||
|
||||
58
apps/web-antd/src/api/iot/product/category/index.ts
Normal file
58
apps/web-antd/src/api/iot/product/category/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotProductCategoryApi {
|
||||
/** IoT 產品分類 VO */
|
||||
export interface ProductCategory {
|
||||
id?: number; // 分類 ID
|
||||
name: string; // 分類名稱
|
||||
parentId?: number; // 父级分類 ID
|
||||
sort?: number; // 分類排序
|
||||
status?: number; // 分類狀態
|
||||
description?: string; // 分類描述
|
||||
createTime?: string; // 創建時間
|
||||
}
|
||||
}
|
||||
|
||||
/** 查詢產品分類分頁 */
|
||||
export function getProductCategoryPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotProductCategoryApi.ProductCategory>>(
|
||||
'/iot/product-category/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查詢產品分類詳情 */
|
||||
export function getProductCategory(id: number) {
|
||||
return requestClient.get<IotProductCategoryApi.ProductCategory>(
|
||||
`/iot/product-category/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增產品分類 */
|
||||
export function createProductCategory(
|
||||
data: IotProductCategoryApi.ProductCategory,
|
||||
) {
|
||||
return requestClient.post('/iot/product-category/create', data);
|
||||
}
|
||||
|
||||
/** 修改產品分類 */
|
||||
export function updateProductCategory(
|
||||
data: IotProductCategoryApi.ProductCategory,
|
||||
) {
|
||||
return requestClient.put('/iot/product-category/update', data);
|
||||
}
|
||||
|
||||
/** 刪除產品分類 */
|
||||
export function deleteProductCategory(id: number) {
|
||||
return requestClient.delete(`/iot/product-category/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 獲取產品分類精簡列表 */
|
||||
export function getSimpleProductCategoryList() {
|
||||
return requestClient.get<IotProductCategoryApi.ProductCategory[]>(
|
||||
'/iot/product-category/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
114
apps/web-antd/src/api/iot/product/product/index.ts
Normal file
114
apps/web-antd/src/api/iot/product/product/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotProductApi {
|
||||
/** IoT 产品 VO */
|
||||
export interface Product {
|
||||
id?: number; // 产品编号
|
||||
name: string; // 产品名称
|
||||
productKey?: string; // 产品标识
|
||||
protocolId?: number; // 协议编号
|
||||
protocolType?: number; // 接入协议类型
|
||||
categoryId?: number; // 产品所属品类标识符
|
||||
categoryName?: string; // 产品所属品类名称
|
||||
icon?: string; // 产品图标
|
||||
picUrl?: string; // 产品图片
|
||||
description?: string; // 产品描述
|
||||
status?: number; // 产品状态
|
||||
deviceType?: number; // 设备类型
|
||||
locationType?: number; // 定位类型
|
||||
netType?: number; // 联网方式
|
||||
codecType?: string; // 数据格式(编解码器类型)
|
||||
dataFormat?: number; // 数据格式
|
||||
validateType?: number; // 认证方式
|
||||
deviceCount?: number; // 设备数量
|
||||
createTime?: Date; // 创建时间
|
||||
}
|
||||
}
|
||||
|
||||
/** IOT 产品设备类型枚举类 */
|
||||
export enum DeviceTypeEnum {
|
||||
DEVICE = 0, // 直连设备
|
||||
GATEWAY_SUB = 1, // 网关子设备
|
||||
GATEWAY = 2, // 网关设备
|
||||
}
|
||||
|
||||
/** IOT 产品定位类型枚举类 */
|
||||
export enum LocationTypeEnum {
|
||||
IP = 1, // IP 定位
|
||||
MODULE = 2, // 设备定位
|
||||
MANUAL = 3, // 手动定位
|
||||
}
|
||||
|
||||
/** IOT 数据格式(编解码器类型)枚举类 */
|
||||
export enum CodecTypeEnum {
|
||||
ALINK = 'Alink', // 阿里云 Alink 协议
|
||||
}
|
||||
|
||||
/** 查询产品分页 */
|
||||
export function getProductPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotProductApi.Product>>(
|
||||
'/iot/product/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询产品详情 */
|
||||
export function getProduct(id: number) {
|
||||
return requestClient.get<IotProductApi.Product>(`/iot/product/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 新增产品 */
|
||||
export function createProduct(data: IotProductApi.Product) {
|
||||
return requestClient.post('/iot/product/create', data);
|
||||
}
|
||||
|
||||
/** 修改产品 */
|
||||
export function updateProduct(data: IotProductApi.Product) {
|
||||
return requestClient.put('/iot/product/update', data);
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
export function deleteProduct(id: number) {
|
||||
return requestClient.delete(`/iot/product/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 导出产品 Excel */
|
||||
export function exportProduct(params: any) {
|
||||
return requestClient.download('/iot/product/export-excel', { params });
|
||||
}
|
||||
|
||||
/** 更新产品状态 */
|
||||
export function updateProductStatus(id: number, status: number) {
|
||||
return requestClient.put(
|
||||
`/iot/product/update-status?id=${id}&status=${status}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询产品(精简)列表 */
|
||||
export function getSimpleProductList() {
|
||||
return requestClient.get<IotProductApi.Product[]>('/iot/product/simple-list');
|
||||
}
|
||||
|
||||
/** 根据 ProductKey 获取产品信息 */
|
||||
export function getProductByKey(productKey: string) {
|
||||
return requestClient.get<IotProductApi.Product>('/iot/product/get-by-key', {
|
||||
params: { productKey },
|
||||
});
|
||||
}
|
||||
|
||||
// Export aliases for compatibility
|
||||
export const ProductApi = {
|
||||
getProductPage,
|
||||
getProduct,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
exportProduct,
|
||||
updateProductStatus,
|
||||
getSimpleProductList,
|
||||
getProductByKey,
|
||||
};
|
||||
|
||||
export type ProductVO = IotProductApi.Product;
|
||||
84
apps/web-antd/src/api/iot/rule/data/rule/index.ts
Normal file
84
apps/web-antd/src/api/iot/rule/data/rule/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace DataRuleApi {
|
||||
/** IoT 数据流转规则 VO */
|
||||
export interface Rule {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
sourceConfigs?: SourceConfig[];
|
||||
sinkIds?: number[];
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** IoT 数据源配置 */
|
||||
export interface SourceConfig {
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
deviceId?: number;
|
||||
type?: string;
|
||||
topic?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 数据流转规则 */
|
||||
export interface DataRule {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
sourceConfigs?: any[];
|
||||
sinkIds?: number[];
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 查询数据流转规则分页 */
|
||||
export function getDataRulePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<DataRuleApi.Rule>>(
|
||||
'/iot/data-rule/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询数据流转规则详情 */
|
||||
export function getDataRule(id: number) {
|
||||
return requestClient.get<DataRuleApi.Rule>(`/iot/data-rule/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 新增数据流转规则 */
|
||||
export function createDataRule(data: DataRule) {
|
||||
return requestClient.post('/iot/data-rule/create', data);
|
||||
}
|
||||
|
||||
/** 修改数据流转规则 */
|
||||
export function updateDataRule(data: DataRule) {
|
||||
return requestClient.put('/iot/data-rule/update', data);
|
||||
}
|
||||
|
||||
/** 删除数据流转规则 */
|
||||
export function deleteDataRule(id: number) {
|
||||
return requestClient.delete(`/iot/data-rule/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除数据流转规则 */
|
||||
export function deleteDataRuleList(ids: number[]) {
|
||||
return requestClient.delete('/iot/data-rule/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新数据流转规则状态 */
|
||||
export function updateDataRuleStatus(id: number, status: number) {
|
||||
return requestClient.put(`/iot/data-rule/update-status`, {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
151
apps/web-antd/src/api/iot/rule/data/sink/index.ts
Normal file
151
apps/web-antd/src/api/iot/rule/data/sink/index.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace DataSinkApi {
|
||||
/** IoT 数据流转目的 VO */
|
||||
export interface Sink {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
type: string;
|
||||
config?: any;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 数据流转目的 */
|
||||
export interface DataSinkVO {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
type?: string;
|
||||
config?: any;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** IoT 数据目的类型枚举 */
|
||||
export enum IotDataSinkTypeEnum {
|
||||
HTTP = 'HTTP',
|
||||
MQTT = 'MQTT',
|
||||
KAFKA = 'KAFKA',
|
||||
RABBITMQ = 'RABBITMQ',
|
||||
ROCKETMQ = 'ROCKETMQ',
|
||||
REDIS_STREAM = 'REDIS_STREAM',
|
||||
}
|
||||
|
||||
/** HTTP 配置 */
|
||||
export interface HttpConfig {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** MQTT 配置 */
|
||||
export interface MqttConfig {
|
||||
broker?: string;
|
||||
port?: number;
|
||||
topic?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
clientId?: string;
|
||||
qos?: number;
|
||||
}
|
||||
|
||||
/** Kafka 配置 */
|
||||
export interface KafkaMQConfig {
|
||||
bootstrapServers?: string;
|
||||
topic?: string;
|
||||
acks?: string;
|
||||
retries?: number;
|
||||
batchSize?: number;
|
||||
}
|
||||
|
||||
/** RabbitMQ 配置 */
|
||||
export interface RabbitMQConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
virtualHost?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
exchange?: string;
|
||||
routingKey?: string;
|
||||
queue?: string;
|
||||
}
|
||||
|
||||
/** RocketMQ 配置 */
|
||||
export interface RocketMQConfig {
|
||||
nameServer?: string;
|
||||
topic?: string;
|
||||
tag?: string;
|
||||
producerGroup?: string;
|
||||
}
|
||||
|
||||
/** Redis Stream 配置 */
|
||||
export interface RedisStreamMQConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
database?: number;
|
||||
streamKey?: string;
|
||||
maxLen?: number;
|
||||
}
|
||||
|
||||
/** 查询数据流转目的分页 */
|
||||
export function getDataSinkPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<DataSinkApi.Sink>>(
|
||||
'/iot/data-sink/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询数据流转目的详情 */
|
||||
export function getDataSink(id: number) {
|
||||
return requestClient.get<DataSinkApi.Sink>(`/iot/data-sink/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 查询所有数据流转目的列表 */
|
||||
export function getDataSinkList() {
|
||||
return requestClient.get<DataSinkApi.Sink[]>('/iot/data-sink/list');
|
||||
}
|
||||
|
||||
/** 查询数据流转目的简单列表 */
|
||||
export function getDataSinkSimpleList() {
|
||||
return requestClient.get<DataSinkApi.Sink[]>('/iot/data-sink/simple-list');
|
||||
}
|
||||
|
||||
/** 新增数据流转目的 */
|
||||
export function createDataSink(data: DataSinkVO) {
|
||||
return requestClient.post('/iot/data-sink/create', data);
|
||||
}
|
||||
|
||||
/** 修改数据流转目的 */
|
||||
export function updateDataSink(data: DataSinkVO) {
|
||||
return requestClient.put('/iot/data-sink/update', data);
|
||||
}
|
||||
|
||||
/** 删除数据流转目的 */
|
||||
export function deleteDataSink(id: number) {
|
||||
return requestClient.delete(`/iot/data-sink/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除数据流转目的 */
|
||||
export function deleteDataSinkList(ids: number[]) {
|
||||
return requestClient.delete('/iot/data-sink/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新数据流转目的状态 */
|
||||
export function updateDataSinkStatus(id: number, status: number) {
|
||||
return requestClient.put(`/iot/data-sink/update-status`, {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export { DataSinkApi };
|
||||
|
||||
163
apps/web-antd/src/api/iot/rule/scene/index.ts
Normal file
163
apps/web-antd/src/api/iot/rule/scene/index.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace RuleSceneApi {
|
||||
/** IoT 场景联动规则 VO */
|
||||
export interface SceneRule {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
triggers?: Trigger[];
|
||||
actions?: Action[];
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发器 */
|
||||
export interface Trigger {
|
||||
type?: string;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
cronExpression?: string;
|
||||
conditionGroups?: TriggerConditionGroup[];
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发条件组 */
|
||||
export interface TriggerConditionGroup {
|
||||
conditions?: TriggerCondition[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发条件 */
|
||||
export interface TriggerCondition {
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则动作 */
|
||||
export interface Action {
|
||||
type?: string;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
value?: any;
|
||||
alertConfigId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则 */
|
||||
export interface IotSceneRule {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
triggers?: Trigger[];
|
||||
actions?: Action[];
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发器 */
|
||||
export interface Trigger {
|
||||
type?: string;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
cronExpression?: string;
|
||||
conditionGroups?: TriggerConditionGroup[];
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发条件组 */
|
||||
export interface TriggerConditionGroup {
|
||||
conditions?: TriggerCondition[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则触发条件 */
|
||||
export interface TriggerCondition {
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则动作 */
|
||||
export interface Action {
|
||||
type?: string;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
value?: any;
|
||||
alertConfigId?: number;
|
||||
}
|
||||
|
||||
/** 查询场景联动规则分页 */
|
||||
export function getSceneRulePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<RuleSceneApi.SceneRule>>(
|
||||
'/iot/scene-rule/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询场景联动规则详情 */
|
||||
export function getSceneRule(id: number) {
|
||||
return requestClient.get<RuleSceneApi.SceneRule>(
|
||||
`/iot/scene-rule/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增场景联动规则 */
|
||||
export function createSceneRule(data: IotSceneRule) {
|
||||
return requestClient.post('/iot/scene-rule/create', data);
|
||||
}
|
||||
|
||||
/** 修改场景联动规则 */
|
||||
export function updateSceneRule(data: IotSceneRule) {
|
||||
return requestClient.put('/iot/scene-rule/update', data);
|
||||
}
|
||||
|
||||
/** 删除场景联动规则 */
|
||||
export function deleteSceneRule(id: number) {
|
||||
return requestClient.delete(`/iot/scene-rule/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除场景联动规则 */
|
||||
export function deleteSceneRuleList(ids: number[]) {
|
||||
return requestClient.delete('/iot/scene-rule/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新场景联动规则状态 */
|
||||
export function updateSceneRuleStatus(id: number, status: number) {
|
||||
return requestClient.put(`/iot/scene-rule/update-status`, {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取场景联动规则简单列表 */
|
||||
export function getSimpleRuleSceneList() {
|
||||
return requestClient.get<RuleSceneApi.SceneRule[]>(
|
||||
'/iot/scene-rule/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
// 别名导出(兼容旧代码)
|
||||
export {
|
||||
getSceneRulePage as getRuleScenePage,
|
||||
deleteSceneRule as deleteRuleScene,
|
||||
updateSceneRuleStatus as updateRuleSceneStatus,
|
||||
};
|
||||
|
||||
84
apps/web-antd/src/api/iot/statistics/index.ts
Normal file
84
apps/web-antd/src/api/iot/statistics/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotStatisticsApi {
|
||||
/** IoT 统计摘要数据 */
|
||||
export interface StatisticsSummary {
|
||||
productCategoryCount: number;
|
||||
productCount: number;
|
||||
deviceCount: number;
|
||||
deviceMessageCount: number;
|
||||
productCategoryTodayCount: number;
|
||||
productTodayCount: number;
|
||||
deviceTodayCount: number;
|
||||
deviceMessageTodayCount: number;
|
||||
deviceOnlineCount: number;
|
||||
deviceOfflineCount: number;
|
||||
deviceInactiveCount: number;
|
||||
productCategoryDeviceCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 时间戳-数值的键值对类型 */
|
||||
export interface TimeValueItem {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/** IoT 消息统计数据类型 */
|
||||
export interface DeviceMessageSummary {
|
||||
statType: number;
|
||||
upstreamCounts: TimeValueItem[];
|
||||
downstreamCounts: TimeValueItem[];
|
||||
}
|
||||
|
||||
/** 消息统计数据项(按日期) */
|
||||
export interface DeviceMessageSummaryByDate {
|
||||
time: string;
|
||||
upstreamCount: number;
|
||||
downstreamCount: number;
|
||||
}
|
||||
|
||||
/** 消息统计接口参数 */
|
||||
export interface DeviceMessageReq {
|
||||
interval: number;
|
||||
times?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 IoT 统计摘要数据 */
|
||||
export function getStatisticsSummary() {
|
||||
return requestClient.get<IotStatisticsApi.StatisticsSummary>(
|
||||
'/iot/statistics/get-summary',
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备消息的数据统计(按日期) */
|
||||
export function getDeviceMessageSummaryByDate(
|
||||
params: IotStatisticsApi.DeviceMessageReq,
|
||||
) {
|
||||
return requestClient.get<IotStatisticsApi.DeviceMessageSummaryByDate[]>(
|
||||
'/iot/statistics/get-device-message-summary-by-date',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备消息统计摘要 */
|
||||
export function getDeviceMessageSummary(statType: number) {
|
||||
return requestClient.get<IotStatisticsApi.DeviceMessageSummary>(
|
||||
'/iot/statistics/get-device-message-summary',
|
||||
{ params: { statType } },
|
||||
);
|
||||
}
|
||||
|
||||
// 导出 API 对象(兼容旧代码)
|
||||
export const StatisticsApi = {
|
||||
getStatisticsSummary,
|
||||
getDeviceMessageSummaryByDate,
|
||||
getDeviceMessageSummary,
|
||||
};
|
||||
|
||||
// 导出类型别名(兼容旧代码)
|
||||
export type IotStatisticsSummaryRespVO = IotStatisticsApi.StatisticsSummary;
|
||||
export type IotStatisticsDeviceMessageSummaryRespVO =
|
||||
IotStatisticsApi.DeviceMessageSummary;
|
||||
export type IotStatisticsDeviceMessageSummaryByDateRespVO =
|
||||
IotStatisticsApi.DeviceMessageSummaryByDate;
|
||||
export type IotStatisticsDeviceMessageReqVO = IotStatisticsApi.DeviceMessageReq;
|
||||
206
apps/web-antd/src/api/iot/thingmodel/index.ts
Normal file
206
apps/web-antd/src/api/iot/thingmodel/index.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace ThingModelApi {
|
||||
/** IoT 物模型数据 VO */
|
||||
export interface ThingModel {
|
||||
id?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
type: string;
|
||||
property?: ThingModelProperty;
|
||||
event?: ThingModelEvent;
|
||||
service?: ThingModelService;
|
||||
}
|
||||
|
||||
/** IoT 物模型属性 */
|
||||
export interface Property {
|
||||
identifier: string;
|
||||
name: string;
|
||||
accessMode: string;
|
||||
dataType: string;
|
||||
dataSpecs?: any;
|
||||
dataSpecsList?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型服务 */
|
||||
export interface Service {
|
||||
identifier: string;
|
||||
name: string;
|
||||
callType: string;
|
||||
inputData?: any[];
|
||||
outputData?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型事件 */
|
||||
export interface Event {
|
||||
identifier: string;
|
||||
name: string;
|
||||
type: string;
|
||||
outputData?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 物模型数据 */
|
||||
export interface ThingModelData {
|
||||
id?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
type?: string;
|
||||
dataType?: string;
|
||||
property?: ThingModelProperty;
|
||||
event?: ThingModelEvent;
|
||||
service?: ThingModelService;
|
||||
}
|
||||
|
||||
/** IoT 物模型属性 */
|
||||
export interface ThingModelProperty {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
accessMode?: string;
|
||||
dataType?: string;
|
||||
dataSpecs?: any;
|
||||
dataSpecsList?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型服务 */
|
||||
export interface ThingModelService {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
callType?: string;
|
||||
inputData?: any[];
|
||||
outputData?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型事件 */
|
||||
export interface ThingModelEvent {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
outputData?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 数据定义(数值型) */
|
||||
export interface DataSpecsNumberData {
|
||||
min?: number | string;
|
||||
max?: number | string;
|
||||
step?: number | string;
|
||||
unit?: string;
|
||||
unitName?: string;
|
||||
}
|
||||
|
||||
/** IoT 数据定义(枚举/布尔型) */
|
||||
export interface DataSpecsEnumOrBoolData {
|
||||
value: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型表单校验规则 */
|
||||
export interface ThingModelFormRules {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 验证布尔型名称 */
|
||||
export const validateBoolName = (_rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('枚举描述不能为空'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询产品物模型分页 */
|
||||
export function getThingModelPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<ThingModelApi.ThingModel>>(
|
||||
'/iot/thing-model/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询产品物模型详情 */
|
||||
export function getThingModel(id: number) {
|
||||
return requestClient.get<ThingModelApi.ThingModel>(
|
||||
`/iot/thing-model/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据产品 ID 查询物模型列表 */
|
||||
export function getThingModelListByProductId(productId: number) {
|
||||
return requestClient.get<ThingModelApi.ThingModel[]>(
|
||||
'/iot/thing-model/list-by-product-id',
|
||||
{ params: { productId } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据产品标识查询物模型列表 */
|
||||
export function getThingModelListByProductKey(productKey: string) {
|
||||
return requestClient.get<ThingModelApi.ThingModel[]>(
|
||||
'/iot/thing-model/list-by-product-key',
|
||||
{ params: { productKey } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增物模型 */
|
||||
export function createThingModel(data: ThingModelData) {
|
||||
return requestClient.post('/iot/thing-model/create', data);
|
||||
}
|
||||
|
||||
/** 修改物模型 */
|
||||
export function updateThingModel(data: ThingModelData) {
|
||||
return requestClient.put('/iot/thing-model/update', data);
|
||||
}
|
||||
|
||||
/** 删除物模型 */
|
||||
export function deleteThingModel(id: number) {
|
||||
return requestClient.delete(`/iot/thing-model/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除物模型 */
|
||||
export function deleteThingModelList(ids: number[]) {
|
||||
return requestClient.delete('/iot/thing-model/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 导入物模型 TSL */
|
||||
export function importThingModelTSL(productId: number, tslData: any) {
|
||||
return requestClient.post('/iot/thing-model/import-tsl', {
|
||||
productId,
|
||||
tslData,
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出物模型 TSL */
|
||||
export function exportThingModelTSL(productId: number) {
|
||||
return requestClient.get<any>('/iot/thing-model/export-tsl', {
|
||||
params: { productId },
|
||||
});
|
||||
}
|
||||
|
||||
// Add a consolidated API object and getThingModelList alias
|
||||
export const ThingModelApi = {
|
||||
getThingModelPage,
|
||||
getThingModel,
|
||||
getThingModelList: getThingModelListByProductId, // alias for compatibility
|
||||
getThingModelListByProductId,
|
||||
getThingModelListByProductKey,
|
||||
createThingModel,
|
||||
updateThingModel,
|
||||
deleteThingModel,
|
||||
deleteThingModelList,
|
||||
importThingModelTSL,
|
||||
exportThingModelTSL,
|
||||
};
|
||||
37
apps/web-antd/src/router/routes/modules/iot.ts
Normal file
37
apps/web-antd/src/router/routes/modules/iot.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/iot',
|
||||
name: 'IoTCenter',
|
||||
meta: {
|
||||
title: 'IoT 物联网',
|
||||
icon: 'lucide:cpu',
|
||||
keepAlive: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'product/detail/:id',
|
||||
name: 'IoTProductDetail',
|
||||
meta: {
|
||||
title: '产品详情',
|
||||
activePath: '/iot/device/product',
|
||||
},
|
||||
component: () => import('#/views/iot/product/product/modules/detail/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'device/detail/:id',
|
||||
name: 'IoTDeviceDetail',
|
||||
meta: {
|
||||
title: '设备详情',
|
||||
activePath: '/iot/device/device',
|
||||
},
|
||||
component: () => import('#/views/iot/device/device/modules/detail/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
196
apps/web-antd/src/views/iot/alert/config/data.ts
Normal file
196
apps/web-antd/src/views/iot/alert/config/data.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getSimpleRuleSceneList } from '#/api/iot/rule/scene';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 新增/修改告警配置的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '配置名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '配置描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'level',
|
||||
label: '告警级别',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_ALERT_LEVEL, 'number'),
|
||||
placeholder: '请选择告警级别',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '配置状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'sceneRuleIds',
|
||||
label: '关联场景联动规则',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleRuleSceneList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择关联的场景联动规则',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'receiveUserIds',
|
||||
label: '接收的用户',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择接收的用户',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'receiveTypes',
|
||||
label: '接收类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_ALERT_RECEIVE_TYPE, 'number'),
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择接收类型',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '配置名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '配置状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择配置状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '配置编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '配置名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '配置描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'level',
|
||||
title: '告警级别',
|
||||
minWidth: 100,
|
||||
slots: { default: 'level' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '配置状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sceneRuleIds',
|
||||
title: '关联场景联动规则',
|
||||
minWidth: 150,
|
||||
slots: { default: 'sceneRules' },
|
||||
},
|
||||
{
|
||||
field: 'receiveUserNames',
|
||||
title: '接收人',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'receiveTypes',
|
||||
title: '接收类型',
|
||||
minWidth: 150,
|
||||
slots: { default: 'receiveTypes' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
187
apps/web-antd/src/views/iot/alert/config/index.vue
Normal file
187
apps/web-antd/src/views/iot/alert/config/index.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AlertConfigApi } from '#/api/iot/alert/config';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteAlertConfig, getAlertConfigPage } from '#/api/iot/alert/config';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import AlertConfigForm from '../modules/AlertConfigForm.vue';
|
||||
|
||||
defineOptions({ name: 'IoTAlertConfig' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: AlertConfigForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 获取告警级别文本
|
||||
const getLevelText = (level?: number) => {
|
||||
const levelMap: Record<number, string> = {
|
||||
1: '提示',
|
||||
2: '一般',
|
||||
3: '警告',
|
||||
4: '严重',
|
||||
5: '紧急',
|
||||
};
|
||||
return level ? levelMap[level] || `级别${level}` : '-';
|
||||
};
|
||||
|
||||
// 获取告警级别颜色
|
||||
const getLevelColor = (level?: number) => {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange',
|
||||
4: 'red',
|
||||
5: 'purple',
|
||||
};
|
||||
return level ? colorMap[level] || 'default' : 'default';
|
||||
};
|
||||
|
||||
// 获取接收类型文本
|
||||
const getReceiveTypeText = (type?: number) => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '站内信',
|
||||
2: '邮箱',
|
||||
3: '短信',
|
||||
4: '微信',
|
||||
5: '钉钉',
|
||||
};
|
||||
return type ? typeMap[type] || `类型${type}` : '-';
|
||||
};
|
||||
|
||||
/** 创建告警配置 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑告警配置 */
|
||||
function handleEdit(row: AlertConfigApi.AlertConfig) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除告警配置 */
|
||||
async function handleDelete(row: AlertConfigApi.AlertConfig) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteAlertConfig(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getAlertConfigPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AlertConfigApi.AlertConfig>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="告警配置列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['告警配置']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 告警级别列 -->
|
||||
<template #level="{ row }">
|
||||
<a-tag :color="getLevelColor(row.level)">
|
||||
{{ getLevelText(row.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 关联场景联动规则列 -->
|
||||
<template #sceneRules="{ row }">
|
||||
<span>{{ row.sceneRuleIds?.length || 0 }} 条</span>
|
||||
</template>
|
||||
|
||||
<!-- 接收类型列 -->
|
||||
<template #receiveTypes="{ row }">
|
||||
<a-tag
|
||||
v-for="(type, index) in row.receiveTypes"
|
||||
:key="index"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ getReceiveTypeText(type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertConfigApi } from '#/api/iot/alert/config';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createAlertConfig,
|
||||
getAlertConfig,
|
||||
updateAlertConfig,
|
||||
} from '#/api/iot/alert/config';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../config/data';
|
||||
|
||||
defineOptions({ name: 'IoTAlertConfigForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<AlertConfigApi.AlertConfig>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['告警配置'])
|
||||
: $t('ui.actionTitle.create', ['告警配置']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as AlertConfigApi.AlertConfig;
|
||||
try {
|
||||
await (formData.value?.id ? updateAlertConfig(data) : createAlertConfig(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<AlertConfigApi.AlertConfig>();
|
||||
if (!data || !data.id) {
|
||||
// 新增时设置默认值
|
||||
await formApi.setValues({
|
||||
status: 0,
|
||||
sceneRuleIds: [],
|
||||
receiveUserIds: [],
|
||||
receiveTypes: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getAlertConfig(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
148
apps/web-antd/src/views/iot/alert/record/data.ts
Normal file
148
apps/web-antd/src/views/iot/alert/record/data.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getSimpleAlertConfigList } from '#/api/iot/alert/config';
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'configId',
|
||||
label: '告警配置',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleAlertConfigList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择告警配置',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'configLevel',
|
||||
label: '告警级别',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_ALERT_LEVEL, 'number'),
|
||||
placeholder: '请选择告警级别',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceId',
|
||||
label: '设备',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceList,
|
||||
labelField: 'deviceName',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择设备',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'processStatus',
|
||||
label: '是否处理',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING),
|
||||
placeholder: '请选择是否处理',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '记录编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'configName',
|
||||
title: '告警名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'configLevel',
|
||||
title: '告警级别',
|
||||
minWidth: 100,
|
||||
slots: { default: 'configLevel' },
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '产品名称',
|
||||
minWidth: 120,
|
||||
slots: { default: 'product' },
|
||||
},
|
||||
{
|
||||
field: 'deviceId',
|
||||
title: '设备名称',
|
||||
minWidth: 120,
|
||||
slots: { default: 'device' },
|
||||
},
|
||||
{
|
||||
field: 'deviceMessage',
|
||||
title: '触发的设备消息',
|
||||
minWidth: 150,
|
||||
slots: { default: 'deviceMessage' },
|
||||
},
|
||||
{
|
||||
field: 'processStatus',
|
||||
title: '是否处理',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'processRemark',
|
||||
title: '处理结果',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
239
apps/web-antd/src/views/iot/alert/record/index.vue
Normal file
239
apps/web-antd/src/views/iot/alert/record/index.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import type { AlertRecord } from '#/api/iot/alert/record';
|
||||
import { getAlertRecordPage, processAlertRecord } from '#/api/iot/alert/record';
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'IoTAlertRecord' });
|
||||
|
||||
const productList = ref<any[]>([]);
|
||||
const deviceList = ref<any[]>([]);
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 加载产品和设备列表
|
||||
const loadData = async () => {
|
||||
productList.value = await getSimpleProductList();
|
||||
deviceList.value = await getSimpleDeviceList();
|
||||
};
|
||||
|
||||
// 获取告警级别文本
|
||||
const getLevelText = (level?: number) => {
|
||||
const levelMap: Record<number, string> = {
|
||||
1: '提示',
|
||||
2: '一般',
|
||||
3: '警告',
|
||||
4: '严重',
|
||||
5: '紧急',
|
||||
};
|
||||
return level ? levelMap[level] || `级别${level}` : '-';
|
||||
};
|
||||
|
||||
// 获取告警级别颜色
|
||||
const getLevelColor = (level?: number) => {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange',
|
||||
4: 'red',
|
||||
5: 'purple',
|
||||
};
|
||||
return level ? colorMap[level] || 'default' : 'default';
|
||||
};
|
||||
|
||||
// 获取产品名称
|
||||
const getProductName = (productId?: number) => {
|
||||
if (!productId) return '-';
|
||||
const product = productList.value.find((p: any) => p.id === productId);
|
||||
return product?.name || '加载中...';
|
||||
};
|
||||
|
||||
// 获取设备名称
|
||||
const getDeviceName = (deviceId?: number) => {
|
||||
if (!deviceId) return '-';
|
||||
const device = deviceList.value.find((d: any) => d.id === deviceId);
|
||||
return device?.deviceName || '加载中...';
|
||||
};
|
||||
|
||||
// 处理告警记录
|
||||
const handleProcess = async (row: AlertRecord) => {
|
||||
Modal.confirm({
|
||||
title: '处理告警记录',
|
||||
content: h('div', [
|
||||
h('p', '请输入处理原因:'),
|
||||
h('textarea', {
|
||||
id: 'processRemark',
|
||||
class: 'ant-input',
|
||||
rows: 3,
|
||||
placeholder: '请输入处理原因',
|
||||
}),
|
||||
]),
|
||||
async onOk() {
|
||||
const textarea = document.getElementById('processRemark') as HTMLTextAreaElement;
|
||||
const processRemark = textarea?.value || '';
|
||||
|
||||
if (!processRemark) {
|
||||
message.warning('请输入处理原因');
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const hideLoading = message.loading({
|
||||
content: '正在处理...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await processAlertRecord(row.id as number, processRemark);
|
||||
message.success('处理成功');
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error('处理失败:', error);
|
||||
return Promise.reject();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 查看告警记录详情
|
||||
const handleView = (row: AlertRecord) => {
|
||||
Modal.info({
|
||||
title: '告警记录详情',
|
||||
width: 600,
|
||||
content: h('div', { class: 'space-y-2' }, [
|
||||
h('div', [
|
||||
h('span', { class: 'font-semibold' }, '告警名称:'),
|
||||
h('span', row.configName || '-'),
|
||||
]),
|
||||
h('div', [
|
||||
h('span', { class: 'font-semibold' }, '告警级别:'),
|
||||
h('span', getLevelText(row.configLevel)),
|
||||
]),
|
||||
h('div', [
|
||||
h('span', { class: 'font-semibold' }, '设备消息:'),
|
||||
h('pre', { class: 'mt-1 text-xs bg-gray-50 p-2 rounded' }, row.deviceMessage || '-'),
|
||||
]),
|
||||
h('div', [
|
||||
h('span', { class: 'font-semibold' }, '处理结果:'),
|
||||
h('span', row.processRemark || '-'),
|
||||
]),
|
||||
h('div', [
|
||||
h('span', { class: 'font-semibold' }, '处理时间:'),
|
||||
h('span', row.processTime ? new Date(row.processTime).toLocaleString('zh-CN') : '-'),
|
||||
]),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getAlertRecordPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AlertRecord>,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="告警记录列表">
|
||||
<!-- 告警级别列 -->
|
||||
<template #configLevel="{ row }">
|
||||
<a-tag :color="getLevelColor(row.configLevel)">
|
||||
{{ getLevelText(row.configLevel) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 产品名称列 -->
|
||||
<template #product="{ row }">
|
||||
<span>{{ getProductName(row.productId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备名称列 -->
|
||||
<template #device="{ row }">
|
||||
<span>{{ getDeviceName(row.deviceId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备消息列 -->
|
||||
<template #deviceMessage="{ row }">
|
||||
<a-popover
|
||||
v-if="row.deviceMessage"
|
||||
placement="topLeft"
|
||||
trigger="hover"
|
||||
:overlayStyle="{ maxWidth: '600px' }"
|
||||
>
|
||||
<template #content>
|
||||
<pre class="text-xs">{{ row.deviceMessage }}</pre>
|
||||
</template>
|
||||
<VbenButton size="small" type="link">
|
||||
<Icon icon="ant-design:eye-outlined" class="mr-1" />
|
||||
查看消息
|
||||
</VbenButton>
|
||||
</a-popover>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '处理',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleProcess.bind(null, row),
|
||||
ifShow: !row.processStatus,
|
||||
},
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleView.bind(null, row),
|
||||
ifShow: row.processStatus,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
321
apps/web-antd/src/views/iot/device/device/data.ts
Normal file
321
apps/web-antd/src/views/iot/device/device/data.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import { DeviceTypeEnum, getSimpleProductList } from '#/api/iot/product/product';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceName',
|
||||
label: 'DeviceName',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 DeviceName',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, 'DeviceName 长度不能少于 4 个字符')
|
||||
.max(32, 'DeviceName 长度不能超过 32 个字符')
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_.\-:@]{4,32}$/,
|
||||
'支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@',
|
||||
),
|
||||
},
|
||||
{
|
||||
fieldName: 'gatewayId',
|
||||
label: '网关设备',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: async () => {
|
||||
const { getSimpleDeviceList } = await import(
|
||||
'#/api/iot/device/device'
|
||||
);
|
||||
return getSimpleDeviceList(DeviceTypeEnum.GATEWAY);
|
||||
},
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '子设备可选择父设备',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['deviceType'],
|
||||
show: (values) => values.deviceType === 1, // GATEWAY_SUB
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '备注名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, '备注名称长度限制为 4~64 个字符')
|
||||
.max(64, '备注名称长度限制为 4~64 个字符')
|
||||
.regex(
|
||||
/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/,
|
||||
'备注名称只能包含中文、英文字母、日文、数字和下划线(_)',
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
fieldName: 'groupIds',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择设备分组',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'serialNumber',
|
||||
label: '设备序列号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备序列号',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, '序列号只能包含字母、数字、中划线和下划线')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
// {
|
||||
// fieldName: 'locationType',
|
||||
// label: '定位类型',
|
||||
// component: 'RadioGroup',
|
||||
// componentProps: {
|
||||
// options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
|
||||
// buttonStyle: 'solid',
|
||||
// optionType: 'button',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
fieldName: 'longitude',
|
||||
label: '设备经度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备经度',
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['locationType'],
|
||||
show: (values) => values.locationType === 3, // MANUAL
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'latitude',
|
||||
label: '设备维度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备维度',
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['locationType'],
|
||||
show: (values) => values.locationType === 3, // MANUAL
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 设备分组表单 */
|
||||
export function useGroupFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'groupIds',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择设备分组',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 设备导入表单 */
|
||||
export function useImportFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'file',
|
||||
label: '设备数据',
|
||||
component: 'Upload',
|
||||
rules: 'required',
|
||||
help: '仅允许导入 xls、xlsx 格式文件',
|
||||
},
|
||||
{
|
||||
fieldName: 'updateSupport',
|
||||
label: '是否覆盖',
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedChildren: '是',
|
||||
unCheckedChildren: '否',
|
||||
},
|
||||
rules: z.boolean().default(false),
|
||||
help: '是否更新已经存在的设备数据',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceName',
|
||||
label: 'DeviceName',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 DeviceName',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '备注名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceType',
|
||||
label: '设备类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
|
||||
placeholder: '请选择设备类型',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '设备状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATUS, 'number'),
|
||||
placeholder: '请选择设备状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'groupId',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择设备分组',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: 'DeviceName',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '备注名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '所属产品',
|
||||
minWidth: 120,
|
||||
slots: { default: 'product' },
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'groupIds',
|
||||
title: '所属分组',
|
||||
minWidth: 150,
|
||||
slots: { default: 'groups' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '设备状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_DEVICE_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'onlineTime',
|
||||
title: '最后上线时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,28 +1,469 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Button, Card, Input, message, Select, Space, Tag } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteDevice,
|
||||
deleteDeviceList,
|
||||
exportDeviceExcel,
|
||||
getDevicePage
|
||||
} from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import DeviceForm from './modules/DeviceForm.vue';
|
||||
import DeviceGroupForm from './modules/DeviceGroupForm.vue';
|
||||
import DeviceImportForm from './modules/DeviceImportForm.vue';
|
||||
// @ts-ignore
|
||||
import DeviceCardView from './modules/DeviceCardView.vue';
|
||||
import { useGridColumns } from './data';
|
||||
|
||||
/** IoT 设备列表 */
|
||||
defineOptions({ name: 'IoTDevice' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const products = ref<any[]>([]);
|
||||
const deviceGroups = ref<any[]>([]);
|
||||
const viewMode = ref<'list' | 'card'>('card');
|
||||
const cardViewRef = ref();
|
||||
|
||||
// Modal instances
|
||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeviceGroupFormModal, deviceGroupFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceGroupForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceImportForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// 获取字典选项
|
||||
const getIntDictOptions = (dictType: string) => {
|
||||
return getDictOptions(dictType, 'number');
|
||||
};
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
searchParams.value = {
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined,
|
||||
deviceType: undefined,
|
||||
status: undefined,
|
||||
groupId: undefined,
|
||||
};
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportDeviceExcel(searchParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
|
||||
}
|
||||
|
||||
/** 打开设备详情 */
|
||||
function openDetail(id: number) {
|
||||
router.push({ name: 'IoTDeviceDetail', params: { id } });
|
||||
}
|
||||
|
||||
/** 跳转到产品详情页面 */
|
||||
function openProductDetail(productId: number) {
|
||||
router.push({ name: 'IoTProductDetail', params: { id: productId } });
|
||||
}
|
||||
|
||||
/** 打开物模型数据 */
|
||||
function openModel(id: number) {
|
||||
router.push({ name: 'IoTDeviceDetail', params: { id }, query: { tab: 'model' } });
|
||||
}
|
||||
|
||||
/** 新增设备 */
|
||||
function handleCreate() {
|
||||
deviceFormModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑设备 */
|
||||
function handleEdit(row: any) {
|
||||
deviceFormModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备 */
|
||||
async function handleDelete(row: any) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除设备...`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDevice(row.id);
|
||||
message.success($t('common.delSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除设备 */
|
||||
async function handleDeleteBatch() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要删除的设备');
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: '正在批量删除...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
await deleteDeviceList(ids);
|
||||
message.success($t('common.delSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加到分组 */
|
||||
function handleAddToGroup() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要添加到分组的设备');
|
||||
return;
|
||||
}
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
deviceGroupFormModalApi.setData(ids).open();
|
||||
}
|
||||
|
||||
/** 设备导入 */
|
||||
function handleImport() {
|
||||
deviceImportFormModalApi.open();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [],
|
||||
},
|
||||
gridOptions: {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
reserve: true,
|
||||
},
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList();
|
||||
// 获取分组列表
|
||||
deviceGroups.value = await getSimpleDeviceGroupList();
|
||||
|
||||
// 处理 productId 参数
|
||||
const { productId } = route.query;
|
||||
if (productId) {
|
||||
searchParams.value.productId = Number(productId);
|
||||
// 自动触发搜索
|
||||
handleSearch();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/device/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/device/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<DeviceFormModal @success="handleRefresh" />
|
||||
<DeviceGroupFormModal @success="handleRefresh" />
|
||||
<DeviceImportFormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 统一搜索工具栏 -->
|
||||
<Card :body-style="{ padding: '16px' }" class="mb-4">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<Select
|
||||
v-model:value="searchParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Input
|
||||
v-model:value="searchParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="searchParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="searchParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Space :size="12">
|
||||
<Button type="primary" @click="handleCreate" v-hasPermi="['iot:device:create']">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</Button>
|
||||
<Button type="primary" @click="handleExport" v-hasPermi="['iot:device:export']">
|
||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
<Button @click="handleImport" v-hasPermi="['iot:device:import']">
|
||||
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
|
||||
导入
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
@click="handleAddToGroup"
|
||||
v-hasPermi="['iot:device:update']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
|
||||
添加到分组
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
danger
|
||||
@click="handleDeleteBatch"
|
||||
v-hasPermi="['iot:device:delete']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
<Button
|
||||
:type="viewMode === 'card' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'card'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:appstore-outlined" />
|
||||
</Button>
|
||||
<Button
|
||||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:unordered-list-outlined" />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<template #toolbar-tools>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<!-- 所属产品列 -->
|
||||
<template #product="{ row }">
|
||||
<a class="cursor-pointer text-primary" @click="openProductDetail(row.productId)">
|
||||
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- 所属分组列 -->
|
||||
<template #groups="{ row }">
|
||||
<template v-if="row.groupIds?.length">
|
||||
<Tag
|
||||
v-for="groupId in row.groupIds"
|
||||
:key="groupId"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ deviceGroups.find((g: any) => g.id === groupId)?.name }}
|
||||
</Tag>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
onClick: openDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '日志',
|
||||
type: 'link',
|
||||
onClick: openModel.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: `确认删除设备吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<DeviceCardView
|
||||
v-show="viewMode === 'card'"
|
||||
ref="cardViewRef"
|
||||
:products="products"
|
||||
:device-groups="deviceGroups"
|
||||
:search-params="searchParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@detail="openDetail"
|
||||
@model="openModel"
|
||||
@product-detail="openProductDetail"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vxe-toolbar div) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 隐藏 VxeGrid 自带的搜索表单区域 */
|
||||
:deep(.vxe-grid--form-wrapper) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
|
||||
|
||||
defineOptions({ name: 'DeviceCardView' });
|
||||
|
||||
interface Props {
|
||||
products: any[];
|
||||
deviceGroups: any[];
|
||||
searchParams?: {
|
||||
deviceName: string;
|
||||
nickname: string;
|
||||
productId?: number;
|
||||
deviceType?: number;
|
||||
status?: number;
|
||||
groupId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [row: any];
|
||||
delete: [row: any];
|
||||
detail: [id: number];
|
||||
model: [id: number];
|
||||
productDetail: [productId: number];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
// 获取产品名称
|
||||
const getProductName = (productId: number) => {
|
||||
const product = props.products.find((p: any) => p.id === productId);
|
||||
return product?.name || '-';
|
||||
};
|
||||
|
||||
// 获取设备列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDevicePage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
};
|
||||
|
||||
// 获取设备类型颜色
|
||||
const getDeviceTypeColor = (deviceType: number) => {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'cyan',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
};
|
||||
|
||||
// 获取设备状态信息
|
||||
const getStatusInfo = (state: number) => {
|
||||
if (state === DeviceStateEnum.ONLINE) {
|
||||
return {
|
||||
text: '在线',
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: '未激活',
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-card-view">
|
||||
<!-- 设备卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: 0 }"
|
||||
class="device-card"
|
||||
:bordered="false"
|
||||
>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 头部:图标和状态 -->
|
||||
<div class="card-header">
|
||||
<div class="device-icon">
|
||||
<IconifyIcon icon="mdi:chip" />
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: getStatusInfo(item.state).color,
|
||||
backgroundColor: getStatusInfo(item.state).bgColor,
|
||||
borderColor: getStatusInfo(item.state).borderColor,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
{{ getStatusInfo(item.state).text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备名称 -->
|
||||
<div class="device-name" :title="item.deviceName">
|
||||
{{ item.deviceName }}
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="label">所属产品</span>
|
||||
<a
|
||||
class="value link"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('productDetail', item.productId); }"
|
||||
>
|
||||
{{ getProductName(item.productId) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备类型</span>
|
||||
<Tag :color="getDeviceTypeColor(item.deviceType)" size="small">
|
||||
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Deviceid</span>
|
||||
<span class="value code" :title="item.Deviceid || item.id">
|
||||
{{ item.Deviceid || item.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-edit"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('edit', item); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:note-pencil" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-view"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('detail', item.id); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:eye" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-data"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('model', item.id); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:database" />
|
||||
数据
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该设备吗?"
|
||||
@confirm="() => emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-delete"
|
||||
@click="(e: MouseEvent) => e.stopPropagation()"
|
||||
>
|
||||
<IconifyIcon icon="ph:trash" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无设备数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 1px 2px -2px rgba(0, 0, 0, 0.16), 0 3px 6px 0 rgba(0, 0, 0, 0.12), 0 5px 12px 4px rgba(0, 0, 0, 0.09);
|
||||
transform: translateY(-4px);
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 头部区域
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.device-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid;
|
||||
line-height: 18px;
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设备名称
|
||||
.device-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
// 信息区域
|
||||
.info-section {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 13px;
|
||||
color: #262626;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&.link {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮栏
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 400;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.btn-edit {
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-view {
|
||||
color: #faad14;
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #faad14;
|
||||
border-color: #faad14;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-data {
|
||||
color: #722ed1;
|
||||
background: #f9f0ff;
|
||||
border-color: #d3adf7;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #722ed1;
|
||||
border-color: #722ed1;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 4px;
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
border-color: #ffa39e;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
createDevice,
|
||||
getDevice,
|
||||
updateDevice,
|
||||
type IotDeviceApi
|
||||
} from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑设备' : '新增设备';
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IotDeviceApi.Device;
|
||||
try {
|
||||
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDevice(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { updateDeviceGroup } from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGroupFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroupForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const deviceIds = ref<number[]>([]);
|
||||
const getTitle = computed(() => '添加设备到分组');
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useGroupFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = await formApi.getValues();
|
||||
try {
|
||||
await updateDeviceGroup({
|
||||
ids: deviceIds.value,
|
||||
groupIds: data.groupIds as number[],
|
||||
});
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('common.updateSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
deviceIds.value = [];
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const ids = modalApi.getData<number[]>();
|
||||
if (ids) {
|
||||
deviceIds.value = ids;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { importDeviceTemplate } from '#/api/iot/device/device';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { useImportFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const getTitle = computed(() => '设备导入');
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useImportFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const file = values.file;
|
||||
|
||||
if (!file || !file.length) {
|
||||
message.error('请上传文件');
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
// 构建表单数据
|
||||
const formData = new FormData();
|
||||
formData.append('file', file[0].originFileObj);
|
||||
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
|
||||
|
||||
// 使用 fetch 上传文件
|
||||
const accessToken = localStorage.getItem('accessToken') || '';
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_GLOB_API_URL}/iot/device/import?updateSupport=${values.updateSupport}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code !== 0) {
|
||||
message.error(result.msg || '导入失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拼接提示语
|
||||
const data = result.data;
|
||||
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
|
||||
if (data.createDeviceNames) {
|
||||
for (let deviceName of data.createDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新成功数量:${data.updateDeviceNames?.length || 0};`;
|
||||
if (data.updateDeviceNames) {
|
||||
for (const deviceName of data.updateDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新失败数量:${Object.keys(data.failureDeviceNames || {}).length};`;
|
||||
if (data.failureDeviceNames) {
|
||||
for (const deviceName in data.failureDeviceNames) {
|
||||
text += `< ${deviceName}: ${data.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入失败');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
// 重置表单
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues({
|
||||
updateSupport: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 下载模板 */
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const res = await importDeviceTemplate();
|
||||
downloadFileFromBlobPart({ fileName: '设备导入模版.xls', source: res });
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '下载失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
<div class="mx-4 mt-4 text-center">
|
||||
<a class="text-primary cursor-pointer" @click="handleDownloadTemplate">
|
||||
下载导入模板
|
||||
</a>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<!-- IoT 设备选择,使用弹窗展示 -->
|
||||
<template>
|
||||
<a-modal
|
||||
:title="dialogTitle"
|
||||
v-model:open="dialogVisible"
|
||||
width="60%"
|
||||
:footer="null"
|
||||
>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
:model="queryParams"
|
||||
class="-mb-15px"
|
||||
>
|
||||
<a-form-item v-if="!props.productId" label="产品" name="productId">
|
||||
<a-select
|
||||
v-model:value="queryParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="DeviceName" name="deviceName">
|
||||
<a-input
|
||||
v-model:value="queryParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
@pressEnter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注名称" name="nickname">
|
||||
<a-input
|
||||
v-model:value="queryParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
@pressEnter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="deviceType">
|
||||
<a-select
|
||||
v-model:value="queryParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备状态" name="status">
|
||||
<a-select
|
||||
v-model:value="queryParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备分组" name="groupId">
|
||||
<a-select
|
||||
v-model:value="queryParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleQuery">
|
||||
<Icon class="mr-5px" icon="ep:search" />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery">
|
||||
<Icon class="mr-5px" icon="ep:refresh" />
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<a-table
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:dataSource="list"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
:row-selection="multiple ? rowSelection : undefined"
|
||||
@row-click="handleRowClick"
|
||||
:row-key="(record: IotDeviceApi.Device) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'radio'">
|
||||
<a-radio
|
||||
:checked="selectedId === record.id"
|
||||
@click="() => handleRadioChange(record)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'productId'">
|
||||
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'deviceType'">
|
||||
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="record.deviceType" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'groupIds'">
|
||||
<template v-if="record.groupIds?.length">
|
||||
<a-tag v-for="id in record.groupIds" :key="id" class="ml-5px" size="small">
|
||||
{{ deviceGroups.find((g) => g.id === id)?.name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="record.status" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'onlineTime'">
|
||||
{{ dateFormatter(null, null, record.onlineTime) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNo"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="submitForm" type="primary" :disabled="formLoading">确 定</a-button>
|
||||
<a-button @click="dialogVisible = false">取 消</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { getDictOptions } from '@vben/hooks'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device'
|
||||
import { getDevicePage } from '#/api/iot/device/device'
|
||||
import type { IotProductApi } from '#/api/iot/product/product'
|
||||
import { getSimpleProductList } from '#/api/iot/product/product'
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group'
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
defineOptions({ name: 'IoTDeviceTableSelect' })
|
||||
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
productId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// 获取字典选项
|
||||
const getIntDictOptions = (dictType: string) => {
|
||||
return getDictOptions(dictType, 'number')
|
||||
}
|
||||
|
||||
// 日期格式化
|
||||
const dateFormatter = (_row: any, _column: any, cellValue: any) => {
|
||||
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : ''
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('设备选择器')
|
||||
const formLoading = ref(false)
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<IotDeviceApi.Device[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const selectedDevices = ref<IotDeviceApi.Device[]>([]) // 选中的设备列表
|
||||
const selectedId = ref<number>() // 单选模式下选中的ID
|
||||
const products = ref<IotProductApi.Product[]>([]) // 产品列表
|
||||
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]) // 设备分组列表
|
||||
const selectedRowKeys = ref<number[]>([]) // 多选模式下选中的keys
|
||||
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
deviceName: undefined as string | undefined,
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
nickname: undefined as string | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
// 表格列定义
|
||||
const columns = computed(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
title: 'DeviceName',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '备注名称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '所属产品',
|
||||
key: 'productId',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
key: 'deviceType',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '所属分组',
|
||||
key: 'groupIds',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '设备状态',
|
||||
key: 'status',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '最后上线时间',
|
||||
key: 'onlineTime',
|
||||
align: 'center',
|
||||
width: 180
|
||||
}
|
||||
]
|
||||
|
||||
// 单选模式添加单选列
|
||||
if (!props.multiple) {
|
||||
baseColumns.unshift({
|
||||
title: '',
|
||||
key: 'radio',
|
||||
width: 55,
|
||||
align: 'center'
|
||||
} as any)
|
||||
}
|
||||
|
||||
return baseColumns
|
||||
})
|
||||
|
||||
// 多选配置
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys: number[], rows: IotDeviceApi.Device[]) => {
|
||||
selectedRowKeys.value = keys
|
||||
selectedDevices.value = rows
|
||||
}
|
||||
}))
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.productId) {
|
||||
queryParams.productId = props.productId
|
||||
}
|
||||
const data = await getDevicePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async () => {
|
||||
dialogVisible.value = true
|
||||
// 重置选择状态
|
||||
selectedDevices.value = []
|
||||
selectedId.value = undefined
|
||||
selectedRowKeys.value = []
|
||||
if (!props.productId) {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList()
|
||||
}
|
||||
// 获取设备列表
|
||||
await getList()
|
||||
}
|
||||
defineExpose({ open })
|
||||
|
||||
/** 处理行点击事件 */
|
||||
const tableRef = ref()
|
||||
const handleRowClick = (row: IotDeviceApi.Device) => {
|
||||
if (!props.multiple) {
|
||||
selectedId.value = row.id
|
||||
selectedDevices.value = [row]
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理单选变更事件 */
|
||||
const handleRadioChange = (row: IotDeviceApi.Device) => {
|
||||
selectedId.value = row.id
|
||||
selectedDevices.value = [row]
|
||||
}
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success'])
|
||||
const submitForm = async () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
message.warning({ content: props.multiple ? '请至少选择一个设备' : '请选择一个设备' })
|
||||
return
|
||||
}
|
||||
emit('success', props.multiple ? selectedDevices.value : selectedDevices.value[0])
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList()
|
||||
// 获取分组列表
|
||||
deviceGroups.value = await getSimpleDeviceGroupList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- 设备配置 -->
|
||||
<template>
|
||||
<div>
|
||||
<a-alert
|
||||
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
|
||||
type="info"
|
||||
show-icon
|
||||
class="my-4"
|
||||
description="如需编辑文件,请点击下方编辑按钮"
|
||||
/>
|
||||
<JsonEditor
|
||||
v-model="config"
|
||||
:mode="isEditing ? 'code' : 'view'"
|
||||
height="600px"
|
||||
@error="onError"
|
||||
/>
|
||||
<div class="mt-5 text-center">
|
||||
<a-button v-if="isEditing" @click="cancelEdit">取消</a-button>
|
||||
<a-button v-if="isEditing" type="primary" @click="saveConfig" :disabled="hasJsonError">
|
||||
保存
|
||||
</a-button>
|
||||
<a-button v-else @click="enableEdit">编辑</a-button>
|
||||
<a-button v-if="!isEditing" type="primary" @click="handleConfigPush" :loading="pushLoading">
|
||||
配置推送
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import type { DeviceVO } from '#/api/iot/device/device'
|
||||
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
|
||||
|
||||
defineOptions({ name: 'DeviceDetailConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
device: DeviceVO
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void // 定义 success 事件,不需要参数
|
||||
}>()
|
||||
|
||||
|
||||
const loading = ref(false) // 加载中
|
||||
const pushLoading = ref(false) // 推送加载中
|
||||
const config = ref<any>({}) // 只存储 config 字段
|
||||
const hasJsonError = ref(false) // 是否有 JSON 格式错误
|
||||
|
||||
/** 监听 props.device 的变化,只更新 config 字段 */
|
||||
watchEffect(() => {
|
||||
try {
|
||||
config.value = props.device.config ? JSON.parse(props.device.config) : {}
|
||||
} catch (e) {
|
||||
config.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
const isEditing = ref(false) // 编辑状态
|
||||
/** 启用编辑模式的函数 */
|
||||
const enableEdit = () => {
|
||||
isEditing.value = true
|
||||
hasJsonError.value = false // 重置错误状态
|
||||
}
|
||||
|
||||
/** 取消编辑的函数 */
|
||||
const cancelEdit = () => {
|
||||
try {
|
||||
config.value = props.device.config ? JSON.parse(props.device.config) : {}
|
||||
} catch (e) {
|
||||
config.value = {}
|
||||
}
|
||||
isEditing.value = false
|
||||
hasJsonError.value = false // 重置错误状态
|
||||
}
|
||||
|
||||
/** 保存配置的函数 */
|
||||
const saveConfig = async () => {
|
||||
if (hasJsonError.value) {
|
||||
message.error({ content: 'JSON格式错误,请修正后再提交!' })
|
||||
return
|
||||
}
|
||||
await updateDeviceConfig()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
/** 配置推送处理函数 */
|
||||
const handleConfigPush = async () => {
|
||||
try {
|
||||
pushLoading.value = true
|
||||
|
||||
// 调用配置推送接口
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
|
||||
params: config.value
|
||||
})
|
||||
|
||||
message.success({ content: '配置推送成功!' })
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
message.error({ content: '配置推送失败!' })
|
||||
console.error('配置推送错误:', error)
|
||||
}
|
||||
} finally {
|
||||
pushLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新设备配置 */
|
||||
const updateDeviceConfig = async () => {
|
||||
try {
|
||||
// 提交请求
|
||||
loading.value = true
|
||||
await DeviceApi.updateDevice({
|
||||
id: props.device.id,
|
||||
config: JSON.stringify(config.value)
|
||||
} as DeviceVO)
|
||||
message.success({ content: '更新成功!' })
|
||||
// 触发 success 事件
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 JSON 编辑器错误的函数 */
|
||||
const onError = (errors: any) => {
|
||||
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
|
||||
hasJsonError.value = false
|
||||
return
|
||||
}
|
||||
hasJsonError.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!-- 设备信息(头部) -->
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<!-- 右上:按钮 -->
|
||||
<a-button
|
||||
v-if="product.status === 0"
|
||||
v-hasPermi="['iot:device:update']"
|
||||
@click="openForm('update', device.id)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="mt-4">
|
||||
<a-descriptions :column="1">
|
||||
<a-descriptions-item label="产品">
|
||||
<a @click="goToProductDetail(product.id)" class="cursor-pointer text-blue-600">
|
||||
{{ product.name }}
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
<a-button size="small" class="ml-2" @click="copyToClipboard(product.productKey)">
|
||||
复制
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<DeviceForm ref="formRef" @success="emit('refresh')" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import DeviceForm from '../DeviceForm.vue'
|
||||
import type { ProductVO } from '#/api/iot/product/product'
|
||||
import type { DeviceVO } from '#/api/iot/device/device'
|
||||
|
||||
interface Props {
|
||||
product: ProductVO
|
||||
device: DeviceVO
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 操作修改 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 复制到剪贴板方法 */
|
||||
const copyToClipboard = async (text: string | undefined) => {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success({ content: '复制成功' })
|
||||
} catch (error) {
|
||||
message.error({ content: '复制失败' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到产品详情页面 */
|
||||
const goToProductDetail = (productId: number | undefined) => {
|
||||
if (productId) {
|
||||
router.push({ name: 'IoTProductDetail', params: { id: productId } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!-- 设备信息 -->
|
||||
<template>
|
||||
<div>
|
||||
<a-row :gutter="16">
|
||||
<!-- 左侧设备信息 -->
|
||||
<a-col :span="12">
|
||||
<a-card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:info-filled" class="mr-2 text-primary" />
|
||||
<span>设备信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="产品名称">
|
||||
{{ product.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="DeviceName">
|
||||
{{ device.deviceName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="备注名称">
|
||||
{{ device.nickname || '--' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前状态">
|
||||
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.state" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(device.createTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="激活时间">
|
||||
{{ formatDate(device.activeTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后上线时间">
|
||||
{{ formatDate(device.onlineTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后离线时间">
|
||||
{{ formatDate(device.offlineTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="MQTT 连接参数">
|
||||
<a-button type="link" @click="handleAuthInfoDialogOpen" size="small">
|
||||
查看
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧地图 -->
|
||||
<a-col :span="12">
|
||||
<a-card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:location" class="mr-2 text-primary" />
|
||||
<span>设备位置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="h-[500px] w-full">
|
||||
<div v-if="showMap" class="h-full w-full bg-gray-100 flex items-center justify-center rounded">
|
||||
<span class="text-gray-400">地图组件</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full w-full bg-gray-50 text-gray-400 rounded"
|
||||
>
|
||||
<Icon icon="ep:warning" class="mr-2" />
|
||||
<span>暂无位置信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 认证信息弹框 -->
|
||||
<a-modal
|
||||
v-model:open="authDialogVisible"
|
||||
title="MQTT 连接参数"
|
||||
width="640px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-form :label-col="{ span: 6 }">
|
||||
<a-form-item label="clientId">
|
||||
<a-input-group compact>
|
||||
<a-input v-model:value="authInfo.clientId" readonly style="width: calc(100% - 80px)" />
|
||||
<a-button @click="copyToClipboard(authInfo.clientId)" type="primary">
|
||||
<Icon icon="ph:copy" />
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="username">
|
||||
<a-input-group compact>
|
||||
<a-input v-model:value="authInfo.username" readonly style="width: calc(100% - 80px)" />
|
||||
<a-button @click="copyToClipboard(authInfo.username)" type="primary">
|
||||
<Icon icon="ph:copy" />
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="password">
|
||||
<a-input-group compact>
|
||||
<a-input
|
||||
v-model:value="authInfo.password"
|
||||
readonly
|
||||
:type="authPasswordVisible ? 'text' : 'password'"
|
||||
style="width: calc(100% - 160px)"
|
||||
/>
|
||||
<a-button @click="authPasswordVisible = !authPasswordVisible" type="primary">
|
||||
<Icon :icon="authPasswordVisible ? 'ph:eye-slash' : 'ph:eye'" />
|
||||
</a-button>
|
||||
<a-button @click="copyToClipboard(authInfo.password)" type="primary">
|
||||
<Icon icon="ph:copy" />
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="text-right mt-4">
|
||||
<a-button @click="handleAuthInfoDialogClose">关闭</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import type { ProductVO } from '#/api/iot/product/product'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import type { DeviceVO } from '#/api/iot/device/device'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import type { IotDeviceAuthInfoVO } from '#/api/iot/device/device'
|
||||
|
||||
// 消息提示
|
||||
|
||||
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
|
||||
const emit = defineEmits(['refresh']) // 定义 Emits
|
||||
|
||||
const authDialogVisible = ref(false) // 定义设备认证信息弹框的可见性
|
||||
const authPasswordVisible = ref(false) // 定义密码可见性状态
|
||||
const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO) // 定义设备认证信息对象
|
||||
|
||||
/** 控制地图显示的标志 */
|
||||
const showMap = computed(() => {
|
||||
return !!(device.longitude && device.latitude)
|
||||
})
|
||||
|
||||
/** 复制到剪贴板方法 */
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success({ content: '复制成功' })
|
||||
} catch (error) {
|
||||
message.error({ content: '复制失败' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开设备认证信息弹框的方法 */
|
||||
const handleAuthInfoDialogOpen = async () => {
|
||||
if (!device.id) return
|
||||
try {
|
||||
authInfo.value = await DeviceApi.getDeviceAuthInfo(device.id)
|
||||
// 显示设备认证信息弹框
|
||||
authDialogVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('获取设备认证信息出错:', error)
|
||||
message.error({ content: '获取设备认证信息失败,请检查网络连接或联系管理员' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭设备认证信息弹框的方法 */
|
||||
const handleAuthInfoDialogClose = () => {
|
||||
authDialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!-- 设备消息列表 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索区域 -->
|
||||
<a-form :model="queryParams" layout="inline">
|
||||
<a-form-item>
|
||||
<a-select v-model:value="queryParams.method" placeholder="所有方法" style="width: 160px" allow-clear>
|
||||
<a-select-option
|
||||
v-for="item in methodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="queryParams.upstream"
|
||||
placeholder="上行/下行"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option label="上行" value="true">上行</a-select-option>
|
||||
<a-select-option label="下行" value="false">下行</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<Icon icon="ep:search" class="mr-5px" /> 搜索
|
||||
</a-button>
|
||||
<a-switch
|
||||
v-model:checked="autoRefresh"
|
||||
class="ml-20px"
|
||||
checked-children="定时刷新"
|
||||
un-checked-children="定时刷新"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<a-table :loading="loading" :dataSource="list" :columns="columns" :pagination="false" class="whitespace-nowrap">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'ts'">
|
||||
{{ formatDate(record.ts) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'upstream'">
|
||||
<a-tag :color="record.upstream ? 'blue' : 'green'">
|
||||
{{ record.upstream ? '上行' : '下行' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reply'">
|
||||
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="record.reply" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'method'">
|
||||
{{ methodOptions.find((item) => item.value === record.method)?.label }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'params'">
|
||||
<span v-if="record.reply">
|
||||
{{ `{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}` }}
|
||||
</span>
|
||||
<span v-else>{{ record.params }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-10px flex justify-end">
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getMessageList"
|
||||
/>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number
|
||||
}>()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
deviceId: props.deviceId,
|
||||
method: undefined,
|
||||
upstream: undefined,
|
||||
pageNo: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
// 列表数据
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<any[]>([])
|
||||
const autoRefresh = ref(false) // 自动刷新开关
|
||||
let autoRefreshTimer: any = null // 自动刷新定时器
|
||||
|
||||
// 消息方法选项
|
||||
const methodOptions = computed(() => {
|
||||
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.method
|
||||
}))
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'ts',
|
||||
key: 'ts',
|
||||
align: 'center',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '上行/下行',
|
||||
dataIndex: 'upstream',
|
||||
key: 'upstream',
|
||||
align: 'center',
|
||||
width: 140
|
||||
},
|
||||
{
|
||||
title: '是否回复',
|
||||
dataIndex: 'reply',
|
||||
key: 'reply',
|
||||
align: 'center',
|
||||
width: 140
|
||||
},
|
||||
{
|
||||
title: '请求编号',
|
||||
dataIndex: 'requestId',
|
||||
key: 'requestId',
|
||||
align: 'center',
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
title: '请求方法',
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
align: 'center',
|
||||
width: 140
|
||||
},
|
||||
{
|
||||
title: '请求/响应数据',
|
||||
dataIndex: 'params',
|
||||
key: 'params',
|
||||
align: 'center',
|
||||
ellipsis: true
|
||||
}
|
||||
]
|
||||
|
||||
/** 查询消息列表 */
|
||||
const getMessageList = async () => {
|
||||
if (!props.deviceId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await DeviceApi.getDeviceMessagePage(queryParams)
|
||||
total.value = data.total
|
||||
list.value = data.list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getMessageList()
|
||||
}
|
||||
|
||||
/** 监听自动刷新 */
|
||||
watch(autoRefresh, (newValue) => {
|
||||
if (newValue) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
getMessageList()
|
||||
}, 5000)
|
||||
} else {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
/** 监听设备标识变化 */
|
||||
watch(
|
||||
() => props.deviceId,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
handleQuery()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** 组件卸载时清除定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
if (props.deviceId) {
|
||||
getMessageList()
|
||||
}
|
||||
})
|
||||
|
||||
/** 刷新消息列表 */
|
||||
const refresh = (delay = 0) => {
|
||||
if (delay > 0) {
|
||||
setTimeout(() => {
|
||||
handleQuery()
|
||||
}, delay)
|
||||
} else {
|
||||
handleQuery()
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,485 @@
|
||||
<!-- 模拟设备 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<a-row :gutter="20">
|
||||
<!-- 左侧指令调试区域 -->
|
||||
<a-col :span="12">
|
||||
<a-card>
|
||||
<a-tabs v-model:active-key="activeTab">
|
||||
<!-- 上行指令调试 -->
|
||||
<a-tab-pane key="upstream" tab="上行指令调试">
|
||||
<a-tabs v-if="activeTab === 'upstream'" v-model:active-key="upstreamTab">
|
||||
<!-- 属性上报 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_POST.method" tab="属性上报">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.property?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-input
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
设置属性值后,点击「发送属性上报」按钮
|
||||
</span>
|
||||
<a-button type="primary" @click="handlePropertyPost">发送属性上报</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 事件上报 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.EVENT_POST.method" tab="事件上报">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="eventList" :columns="eventColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.event?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-textarea
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
:rows="3"
|
||||
placeholder="输入事件参数(JSON格式)"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="primary" size="small" @click="handleEventPost(record)">
|
||||
上报事件
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 状态变更 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.STATE_UPDATE.method" tab="状态变更">
|
||||
<ContentWrap>
|
||||
<div class="flex gap-4">
|
||||
<a-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
|
||||
设备上线
|
||||
</a-button>
|
||||
<a-button danger @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
|
||||
设备下线
|
||||
</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 下行指令调试 -->
|
||||
<a-tab-pane key="downstream" tab="下行指令调试">
|
||||
<a-tabs v-if="activeTab === 'downstream'" v-model:active-key="downstreamTab">
|
||||
<!-- 属性调试 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_SET.method" tab="属性设置">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.property?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-input
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
设置属性值后,点击「发送属性设置」按钮
|
||||
</span>
|
||||
<a-button type="primary" @click="handlePropertySet">发送属性设置</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 服务调用 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method" tab="设备服务调用">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="serviceList" :columns="serviceColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-textarea
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
:rows="3"
|
||||
placeholder="输入服务参数(JSON格式)"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleServiceInvoke(record)"
|
||||
>
|
||||
服务调用
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧设备日志区域 -->
|
||||
<a-col :span="12">
|
||||
<ContentWrap title="设备消息">
|
||||
<DeviceDetailsMessage v-if="device.id" ref="deviceMessageRef" :device-id="device.id" />
|
||||
</ContentWrap>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { ProductVO } from '#/api/iot/product/product'
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel'
|
||||
import { DeviceApi, DeviceStateEnum } from '#/api/iot/device/device'
|
||||
import type { DeviceVO } from '#/api/iot/device/device'
|
||||
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
|
||||
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '#/views/iot/utils/constants'
|
||||
|
||||
const props = defineProps<{
|
||||
product: ProductVO
|
||||
device: DeviceVO
|
||||
thingModelList: ThingModelData[]
|
||||
}>()
|
||||
|
||||
// 消息弹窗
|
||||
const activeTab = ref('upstream') // 上行upstream、下行downstream
|
||||
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method) // 上行子标签
|
||||
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method) // 下行子标签
|
||||
const deviceMessageRef = ref() // 设备消息组件引用
|
||||
const deviceMessageRefreshDelay = 2000 // 延迟 N 秒,保证模拟上行的消息被处理
|
||||
|
||||
// 表单数据:存储用户输入的模拟值
|
||||
const formData = ref<Record<string, string>>({})
|
||||
|
||||
// 根据类型过滤物模型数据
|
||||
const getFilteredThingModelList = (type: number) => {
|
||||
return props.thingModelList.filter((item) => String(item.type) === String(type))
|
||||
}
|
||||
|
||||
// 计算属性:属性列表
|
||||
const propertyList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY))
|
||||
|
||||
// 计算属性:事件列表
|
||||
const eventList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.EVENT))
|
||||
|
||||
// 计算属性:服务列表
|
||||
const serviceList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE))
|
||||
|
||||
// 属性表格列定义
|
||||
const propertyColumns = [
|
||||
{
|
||||
title: '功能名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标识符',
|
||||
dataIndex: 'identifier',
|
||||
key: 'identifier',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '数据类型',
|
||||
key: 'dataType',
|
||||
width: 100,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '数据定义',
|
||||
key: 'dataDefinition',
|
||||
minWidth: 200,
|
||||
align: 'left'
|
||||
},
|
||||
{
|
||||
title: '值',
|
||||
key: 'value',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 事件表格列定义
|
||||
const eventColumns = [
|
||||
{
|
||||
title: '功能名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标识符',
|
||||
dataIndex: 'identifier',
|
||||
key: 'identifier',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '数据类型',
|
||||
key: 'dataType',
|
||||
width: 100,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '数据定义',
|
||||
key: 'dataDefinition',
|
||||
minWidth: 200,
|
||||
align: 'left'
|
||||
},
|
||||
{
|
||||
title: '值',
|
||||
key: 'value',
|
||||
width: 200,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 服务表格列定义
|
||||
const serviceColumns = [
|
||||
{
|
||||
title: '服务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标识符',
|
||||
dataIndex: 'identifier',
|
||||
key: 'identifier',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '输入参数',
|
||||
key: 'dataDefinition',
|
||||
minWidth: 200,
|
||||
align: 'left'
|
||||
},
|
||||
{
|
||||
title: '参数值',
|
||||
key: 'value',
|
||||
width: 200,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取表单值
|
||||
const getFormValue = (identifier: string) => {
|
||||
return formData.value[identifier] || ''
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
const setFormValue = (identifier: string, value: string) => {
|
||||
formData.value[identifier] = value
|
||||
}
|
||||
|
||||
// 属性上报
|
||||
const handlePropertyPost = async () => {
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!]
|
||||
if (value) {
|
||||
params[item.identifier!] = value
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
message.warning({ content: '请至少输入一个属性值' })
|
||||
return
|
||||
}
|
||||
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
|
||||
params
|
||||
})
|
||||
|
||||
message.success({ content: '属性上报成功' })
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
|
||||
} catch (error) {
|
||||
message.error({ content: '属性上报失败' })
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 事件上报
|
||||
const handleEventPost = async (row: ThingModelData) => {
|
||||
try {
|
||||
const valueStr = formData.value[row.identifier!]
|
||||
let params: any = {}
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
params = JSON.parse(valueStr)
|
||||
} catch (e) {
|
||||
message.error({ content: '事件参数格式错误,请输入有效的JSON格式' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
params: {
|
||||
identifier: row.identifier,
|
||||
params
|
||||
}
|
||||
})
|
||||
|
||||
message.success({ content: '事件上报成功' })
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
|
||||
} catch (error) {
|
||||
message.error({ content: '事件上报失败' })
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态变更
|
||||
const handleDeviceState = async (state: number) => {
|
||||
try {
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
|
||||
params: { state }
|
||||
})
|
||||
|
||||
message.success({ content: '状态变更成功' })
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
|
||||
} catch (error) {
|
||||
message.error({ content: '状态变更失败' })
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 属性设置
|
||||
const handlePropertySet = async () => {
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!]
|
||||
if (value) {
|
||||
params[item.identifier!] = value
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
message.warning({ content: '请至少输入一个属性值' })
|
||||
return
|
||||
}
|
||||
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
|
||||
params
|
||||
})
|
||||
|
||||
message.success({ content: '属性设置成功' })
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
|
||||
} catch (error) {
|
||||
message.error({ content: '属性设置失败' })
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务调用
|
||||
const handleServiceInvoke = async (row: ThingModelData) => {
|
||||
try {
|
||||
const valueStr = formData.value[row.identifier!]
|
||||
let params: any = {}
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
params = JSON.parse(valueStr)
|
||||
} catch (e) {
|
||||
message.error({ content: '服务参数格式错误,请输入有效的JSON格式' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await DeviceApi.sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
|
||||
params: {
|
||||
identifier: row.identifier,
|
||||
params
|
||||
}
|
||||
})
|
||||
|
||||
message.success({ content: '服务调用成功' })
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
|
||||
} catch (error) {
|
||||
message.error({ content: '服务调用失败' })
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<a-tabs v-model:active-key="activeTab" class="thing-model-tabs">
|
||||
<a-tab-pane key="property" tab="设备属性(运行状态)">
|
||||
<DeviceDetailsThingModelProperty :device-id="deviceId" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="event" tab="设备事件上报">
|
||||
<DeviceDetailsThingModelEvent
|
||||
:device-id="props.deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="service" tab="设备服务调用">
|
||||
<DeviceDetailsThingModelService
|
||||
:device-id="deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ContentWrap } from '@vben/common-ui'
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel'
|
||||
import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue'
|
||||
import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue'
|
||||
import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number
|
||||
thingModelList: ThingModelData[]
|
||||
}>()
|
||||
|
||||
const activeTab = ref('property') // 默认选中设备属性
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thing-model-tabs :deep(.ant-tabs-content) {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.thing-model-tabs :deep(.ant-tabs-tabpane) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!-- 设备事件管理 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
@submit.prevent
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<a-form-item label="标识符" name="identifier">
|
||||
<a-select
|
||||
v-model:value="queryParams.identifier"
|
||||
placeholder="请选择事件标识符"
|
||||
allow-clear
|
||||
style="width: 240px;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="event in eventThingModels"
|
||||
:key="event.identifier"
|
||||
:value="event.identifier!"
|
||||
>
|
||||
{{ event.name }}({{ event.identifier }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围" name="times">
|
||||
<a-range-picker
|
||||
v-model:value="queryParams.times"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 360px;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:search" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery" style="margin-left: 8px;">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-divider style="margin: 16px 0;" />
|
||||
|
||||
<!-- 事件列表 -->
|
||||
<a-table v-loading="loading" :data-source="list" :pagination="false">
|
||||
<a-table-column title="上报时间" align="center" data-index="reportTime" :width="180">
|
||||
<template #default="{ record }">
|
||||
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
|
||||
<template #default="{ record }">
|
||||
<a-tag color="blue" size="small">
|
||||
{{ record.request?.identifier }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="事件名称" align="center" data-index="eventName" :width="160">
|
||||
<template #default="{ record }">
|
||||
{{ getEventName(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="事件类型" align="center" data-index="eventType" :width="100">
|
||||
<template #default="{ record }">
|
||||
{{ getEventType(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="输入参数" align="center" data-index="params">
|
||||
<template #default="{ record }"> {{ parseParams(record.request.params) }} </template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Pagination } from 'ant-design-vue'
|
||||
import { ContentWrap } from '@vben/common-ui'
|
||||
import { IconifyIcon } from '@vben/icons'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import {
|
||||
getEventTypeLabel,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number
|
||||
thingModelList: ThingModelData[]
|
||||
}>()
|
||||
|
||||
const loading = ref(false) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref([] as any[]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
deviceId: props.deviceId,
|
||||
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
|
||||
identifier: '',
|
||||
times: [] as any[],
|
||||
pageNo: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 事件类型的物模型数据 */
|
||||
const eventThingModels = computed(() => {
|
||||
return props.thingModelList.filter(
|
||||
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.EVENT)
|
||||
)
|
||||
})
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
if (!props.deviceId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields()
|
||||
queryParams.identifier = ''
|
||||
queryParams.times = []
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 获取事件名称 */
|
||||
const getEventName = (identifier: string | undefined) => {
|
||||
if (!identifier) return '-'
|
||||
const event = eventThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier
|
||||
)
|
||||
return event?.name || identifier
|
||||
}
|
||||
|
||||
/** 获取事件类型 */
|
||||
const getEventType = (identifier: string | undefined) => {
|
||||
if (!identifier) return '-'
|
||||
const event = eventThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier
|
||||
)
|
||||
if (!event?.event?.type) return '-'
|
||||
return getEventTypeLabel(event.event.type) || '-'
|
||||
}
|
||||
|
||||
/** 解析参数 */
|
||||
const parseParams = (params: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params)
|
||||
if (parsed.params) {
|
||||
return parsed.params
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- 设备属性管理 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<div class="flex items-center justify-between" style="margin-bottom: 16px;">
|
||||
<div class="flex items-center" style="gap: 16px;">
|
||||
<a-input
|
||||
v-model:value="queryParams.keyword"
|
||||
placeholder="请输入属性名称、标识符"
|
||||
allow-clear
|
||||
style="width: 240px;"
|
||||
@pressEnter="handleQuery"
|
||||
/>
|
||||
<div class="flex items-center" style="gap: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">自动刷新</span>
|
||||
<a-switch
|
||||
v-model:checked="autoRefresh"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a-button-group>
|
||||
<a-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
|
||||
<IconifyIcon icon="ep:grid" />
|
||||
</a-button>
|
||||
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
|
||||
<IconifyIcon icon="ep:list" />
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<a-divider style="margin: 16px 0;" />
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<template v-if="viewMode === 'card'">
|
||||
<a-row :gutter="16" v-loading="loading">
|
||||
<a-col
|
||||
v-for="item in list"
|
||||
:key="item.identifier"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<a-card
|
||||
class="h-full transition-colors relative overflow-hidden"
|
||||
:body-style="{ padding: '0' }"
|
||||
>
|
||||
<!-- 添加渐变背景层 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none bg-gradient-to-b from-[#eefaff] to-transparent"
|
||||
>
|
||||
</div>
|
||||
<div class="p-4 relative">
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="mr-2.5 flex items-center">
|
||||
<IconifyIcon icon="ep:cpu" class="text-[18px] text-[#0070ff]" />
|
||||
</div>
|
||||
<div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
|
||||
<!-- 标识符 -->
|
||||
<div class="inline-flex items-center mr-2">
|
||||
<a-tag size="small" color="blue">
|
||||
{{ item.identifier }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<!-- 数据类型标签 -->
|
||||
<div class="inline-flex items-center mr-2">
|
||||
<a-tag size="small">
|
||||
{{ item.dataType }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<!-- 数据图标 - 可点击 -->
|
||||
<div
|
||||
class="cursor-pointer flex items-center justify-center w-8 h-8 rounded-full hover:bg-blue-50 transition-colors"
|
||||
@click="openHistory(props.deviceId, item.identifier, item.dataType)"
|
||||
>
|
||||
<IconifyIcon icon="ep:data-line" class="text-[18px] text-[#0070ff]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="text-[14px]">
|
||||
<div class="mb-2.5 last:mb-0">
|
||||
<span class="text-[#717c8e] mr-2.5">属性值</span>
|
||||
<span class="text-[#0b1d30] font-600">
|
||||
{{ formatValueWithUnit(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2.5 last:mb-0">
|
||||
<span class="text-[#717c8e] mr-2.5">更新时间</span>
|
||||
<span class="text-[#0b1d30] text-[12px]">
|
||||
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<a-table v-else v-loading="loading" :data-source="list" :pagination="false">
|
||||
<a-table-column title="属性标识符" align="center" data-index="identifier" />
|
||||
<a-table-column title="属性名称" align="center" data-index="name" />
|
||||
<a-table-column title="数据类型" align="center" data-index="dataType" />
|
||||
<a-table-column title="属性值" align="center" data-index="value">
|
||||
<template #default="{ record }">
|
||||
{{ formatValueWithUnit(record) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column
|
||||
title="更新时间"
|
||||
align="center"
|
||||
data-index="updateTime"
|
||||
:width="180"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
{{ record.updateTime ? formatDate(record.updateTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" align="center">
|
||||
<template #default="{ record }">
|
||||
<a-button
|
||||
type="link"
|
||||
@click="openHistory(props.deviceId, record.identifier, record.dataType)"
|
||||
>
|
||||
查看数据
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<DeviceDetailsThingModelPropertyHistory ref="historyRef" :deviceId="props.deviceId" />
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 移除 a-row 的额外边距 */
|
||||
:deep(.ant-row) {
|
||||
margin-left: -8px !important;
|
||||
margin-right: -8px !important;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ContentWrap } from '@vben/common-ui'
|
||||
import { IconifyIcon } from '@vben/icons'
|
||||
import { DeviceApi, type IotDevicePropertyDetailRespVO } from '#/api/iot/device/device'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue'
|
||||
|
||||
const props = defineProps<{ deviceId: number }>()
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<IotDevicePropertyDetailRespVO[]>([]) // 显示的列表数据
|
||||
const filterList = ref<IotDevicePropertyDetailRespVO[]>([]) // 完整的数据列表
|
||||
const queryParams = reactive({
|
||||
keyword: '' as string
|
||||
})
|
||||
const autoRefresh = ref(false) // 自动刷新开关
|
||||
let autoRefreshTimer: any = null // 定时器
|
||||
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
deviceId: props.deviceId,
|
||||
identifier: undefined as string | undefined,
|
||||
name: undefined as string | undefined
|
||||
}
|
||||
filterList.value = await DeviceApi.getLatestDeviceProperties(params)
|
||||
handleFilter()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端筛选数据 */
|
||||
const handleFilter = () => {
|
||||
if (!queryParams.keyword.trim()) {
|
||||
list.value = filterList.value
|
||||
} else {
|
||||
const keyword = queryParams.keyword.toLowerCase()
|
||||
list.value = filterList.value.filter(
|
||||
(item: IotDevicePropertyDetailRespVO) =>
|
||||
item.identifier?.toLowerCase().includes(keyword) ||
|
||||
item.name?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
handleFilter()
|
||||
}
|
||||
|
||||
/** 历史操作 */
|
||||
const historyRef = ref()
|
||||
const openHistory = (deviceId: number, identifier: string, dataType: string) => {
|
||||
historyRef.value.open(deviceId, identifier, dataType)
|
||||
}
|
||||
|
||||
/** 格式化属性值和单位 */
|
||||
const formatValueWithUnit = (item: IotDevicePropertyDetailRespVO) => {
|
||||
if (item.value === null || item.value === undefined || item.value === '') {
|
||||
return '-'
|
||||
}
|
||||
const unitName = item.dataSpecs?.unitName
|
||||
return unitName ? `${item.value} ${unitName}` : item.value
|
||||
}
|
||||
|
||||
/** 监听自动刷新 */
|
||||
watch(autoRefresh, (newValue) => {
|
||||
if (newValue) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
getList()
|
||||
}, 5000) // 每 5 秒刷新一次
|
||||
} else {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
/** 组件卸载时清除定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
}
|
||||
})
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="dialogVisible"
|
||||
title="查看数据"
|
||||
width="1200px"
|
||||
:destroy-on-close="true"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="property-history-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar-wrapper mb-4">
|
||||
<a-space :size="12" class="w-full" wrap>
|
||||
<!-- 时间选择 -->
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
class="!w-[400px]"
|
||||
@change="handleTimeChange"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<a-button @click="handleExport" :loading="exporting" :disabled="list.length === 0">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:export-outlined" />
|
||||
</template>
|
||||
导出
|
||||
</a-button>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<a-button-group class="ml-auto">
|
||||
<a-button
|
||||
:type="viewMode === 'chart' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'chart'"
|
||||
:disabled="isComplexDataType"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:line-chart-outlined" />
|
||||
</template>
|
||||
图表
|
||||
</a-button>
|
||||
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:table-outlined" />
|
||||
</template>
|
||||
列表
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</a-space>
|
||||
|
||||
<!-- 数据统计信息 -->
|
||||
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
|
||||
<a-space :size="16">
|
||||
<span>共 {{ total }} 条数据</span>
|
||||
<span v-if="viewMode === 'chart' && !isComplexDataType">
|
||||
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值: {{ avgValue }}
|
||||
</span>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<a-spin :spinning="loading" :delay="200">
|
||||
<!-- 图表模式 -->
|
||||
<div v-if="viewMode === 'chart'" class="chart-container">
|
||||
<a-empty
|
||||
v-if="list.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无数据"
|
||||
class="py-20"
|
||||
/>
|
||||
<EchartsUI v-else ref="chartRef" height="500px" />
|
||||
</div>
|
||||
|
||||
<!-- 表格模式 -->
|
||||
<div v-else class="table-container">
|
||||
<a-table
|
||||
:dataSource="list"
|
||||
:columns="tableColumns"
|
||||
:pagination="paginationConfig"
|
||||
:scroll="{ y: 500 }"
|
||||
row-key="updateTime"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'updateTime'">
|
||||
{{ formatDate(new Date(record.updateTime)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-tag v-if="isComplexDataType" color="processing">
|
||||
{{ formatComplexValue(record.value) }}
|
||||
</a-tag>
|
||||
<span v-else class="font-medium">{{ record.value }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts'
|
||||
import type { IotDevicePropertyRespVO } from '#/api/iot/device/device'
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'
|
||||
import { beginOfDay, endOfDay, formatDate } from '@vben/utils'
|
||||
|
||||
import { Empty, message, Modal } from 'ant-design-vue'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants'
|
||||
|
||||
defineProps<{ deviceId: number }>()
|
||||
|
||||
/** IoT 设备属性历史数据详情 */
|
||||
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' })
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const viewMode = ref<'chart' | 'list'>('chart') // 视图模式状态
|
||||
const list = ref<IotDevicePropertyRespVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 总数据量
|
||||
const thingModelDataType = ref<string>('') // 物模型数据类型
|
||||
const propertyIdentifier = ref<string>('') // 属性标识符
|
||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(7, 'day').startOf('day'),
|
||||
dayjs().endOf('day')
|
||||
])
|
||||
|
||||
const queryParams = reactive({
|
||||
deviceId: -1,
|
||||
identifier: '',
|
||||
times: [
|
||||
formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
|
||||
formatDate(endOfDay(new Date()))
|
||||
]
|
||||
})
|
||||
|
||||
// Echarts 相关
|
||||
const chartRef = ref<EchartsUIType>()
|
||||
const { renderEcharts } = useEcharts(chartRef)
|
||||
|
||||
// 判断是否为复杂数据类型(struct 或 array)
|
||||
const isComplexDataType = computed(() => {
|
||||
if (!thingModelDataType.value) return false
|
||||
return [IoTDataSpecsDataTypeEnum.STRUCT, IoTDataSpecsDataTypeEnum.ARRAY].includes(
|
||||
thingModelDataType.value as any
|
||||
)
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const maxValue = computed(() => {
|
||||
if (isComplexDataType.value || list.value.length === 0) return '-'
|
||||
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
|
||||
return values.length > 0 ? Math.max(...values).toFixed(2) : '-'
|
||||
})
|
||||
|
||||
const minValue = computed(() => {
|
||||
if (isComplexDataType.value || list.value.length === 0) return '-'
|
||||
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
|
||||
return values.length > 0 ? Math.min(...values).toFixed(2) : '-'
|
||||
})
|
||||
|
||||
const avgValue = computed(() => {
|
||||
if (isComplexDataType.value || list.value.length === 0) return '-'
|
||||
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
|
||||
if (values.length === 0) return '-'
|
||||
const sum = values.reduce((acc, val) => acc + val, 0)
|
||||
return (sum / values.length).toFixed(2)
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = computed(() => [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
customRender: ({ index }: { index: number }) => index + 1
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
key: 'updateTime',
|
||||
dataIndex: 'updateTime',
|
||||
width: 200,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '属性值',
|
||||
key: 'value',
|
||||
dataIndex: 'value',
|
||||
align: 'center'
|
||||
}
|
||||
])
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = computed(() => ({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total: number) => `共 ${total} 条数据`
|
||||
}))
|
||||
|
||||
/** 获得设备历史数据 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await DeviceApi.getHistoryDevicePropertyList(queryParams)
|
||||
list.value = data?.list || []
|
||||
total.value = list.value.length
|
||||
|
||||
// 如果是图表模式且不是复杂数据类型,渲染图表
|
||||
if (viewMode.value === 'chart' && !isComplexDataType.value && list.value.length > 0) {
|
||||
await nextTick()
|
||||
renderChart()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取数据失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染图表 */
|
||||
const renderChart = () => {
|
||||
if (!list.value || list.value.length === 0) return
|
||||
|
||||
const chartData = list.value.map((item) => [item.updateTime, item.value])
|
||||
|
||||
renderEcharts({
|
||||
title: {
|
||||
text: '属性值趋势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 60,
|
||||
bottom: 100,
|
||||
top: 80,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const param = params[0]
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="margin-bottom: 4px; font-weight: bold;">
|
||||
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
<div>
|
||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
|
||||
<span>属性值: <strong>${param.value[1]}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
name: '时间',
|
||||
nameTextStyle: {
|
||||
padding: [10, 0, 0, 0]
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: (value: number) => {
|
||||
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '')
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '属性值',
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 10, 0]
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '属性值',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#1890FF'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#1890FF'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(24, 144, 255, 0.3)'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(24, 144, 255, 0.05)'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
data: chartData
|
||||
}
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
start: 0,
|
||||
end: 100
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
height: 30,
|
||||
bottom: 20
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (deviceId: number, identifier: string, dataType: string) => {
|
||||
dialogVisible.value = true
|
||||
queryParams.deviceId = deviceId
|
||||
queryParams.identifier = identifier
|
||||
propertyIdentifier.value = identifier
|
||||
thingModelDataType.value = dataType
|
||||
|
||||
// 如果物模型是 struct、array,需要默认使用 list 模式
|
||||
if (isComplexDataType.value) {
|
||||
viewMode.value = 'list'
|
||||
} else {
|
||||
viewMode.value = 'chart'
|
||||
}
|
||||
|
||||
// 等待弹窗完全渲染后再获取数据
|
||||
await nextTick()
|
||||
await getList()
|
||||
}
|
||||
|
||||
/** 时间变化处理 */
|
||||
const handleTimeChange = () => {
|
||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||
return
|
||||
}
|
||||
|
||||
queryParams.times = [
|
||||
formatDate(dateRange.value[0].toDate()),
|
||||
formatDate(dateRange.value[1].toDate())
|
||||
]
|
||||
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 刷新数据 */
|
||||
const handleRefresh = () => {
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 导出数据 */
|
||||
const handleExport = async () => {
|
||||
if (list.value.length === 0) {
|
||||
message.warning('暂无数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
try {
|
||||
// 构建CSV内容
|
||||
const headers = ['序号', '时间', '属性值']
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...list.value.map((item, index) => {
|
||||
return [
|
||||
index + 1,
|
||||
formatDate(new Date(item.updateTime)),
|
||||
isComplexDataType.value ? `"${JSON.stringify(item.value)}"` : item.value
|
||||
].join(',')
|
||||
})
|
||||
].join('\n')
|
||||
|
||||
// 创建 BOM 头,解决中文乱码
|
||||
const BOM = '\uFEFF'
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
// 下载文件
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
} catch (error) {
|
||||
message.error('导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
|
||||
/** 格式化复杂数据类型 */
|
||||
const formatComplexValue = (value: any) => {
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 监听视图模式变化,重新渲染图表 */
|
||||
watch(viewMode, async (newMode) => {
|
||||
if (newMode === 'chart' && !isComplexDataType.value && list.value.length > 0) {
|
||||
await nextTick()
|
||||
renderChart()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.property-history-container {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
.toolbar-wrapper {
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-container,
|
||||
.table-container {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<!-- 设备服务调用 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
@submit.prevent
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<a-form-item label="标识符" name="identifier">
|
||||
<a-select
|
||||
v-model:value="queryParams.identifier"
|
||||
placeholder="请选择服务标识符"
|
||||
allow-clear
|
||||
style="width: 240px;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="service in serviceThingModels"
|
||||
:key="service.identifier"
|
||||
:value="service.identifier!"
|
||||
>
|
||||
{{ service.name }}({{ service.identifier }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围" name="times">
|
||||
<a-range-picker
|
||||
v-model:value="queryParams.times"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 360px;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:search" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery" style="margin-left: 8px;">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-divider style="margin: 16px 0;" />
|
||||
|
||||
<!-- 服务调用列表 -->
|
||||
<a-table v-loading="loading" :data-source="list" :pagination="false">
|
||||
<a-table-column title="调用时间" align="center" data-index="requestTime" :width="180">
|
||||
<template #default="{ record }">
|
||||
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="响应时间" align="center" data-index="responseTime" :width="180">
|
||||
<template #default="{ record }">
|
||||
{{ record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
|
||||
<template #default="{ record }">
|
||||
<a-tag color="blue" size="small">
|
||||
{{ record.request?.identifier }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="服务名称" align="center" data-index="serviceName" :width="160">
|
||||
<template #default="{ record }">
|
||||
{{ getServiceName(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="调用方式" align="center" data-index="callType" :width="100">
|
||||
<template #default="{ record }">
|
||||
{{ getCallType(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="输入参数" align="center" data-index="inputParams">
|
||||
<template #default="{ record }"> {{ parseParams(record.request?.params) }} </template>
|
||||
</a-table-column>
|
||||
<a-table-column title="输出参数" align="center" data-index="outputParams">
|
||||
<template #default="{ record }">
|
||||
<span v-if="record.reply">
|
||||
{{
|
||||
`{"code":${record.reply.code},"msg":"${record.reply.msg}","data":${record.reply.data}\}`
|
||||
}}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Pagination } from 'ant-design-vue'
|
||||
import { ContentWrap } from '@vben/common-ui'
|
||||
import { IconifyIcon } from '@vben/icons'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel'
|
||||
import { formatDate } from '@vben/utils'
|
||||
import {
|
||||
getThingModelServiceCallTypeLabel,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number
|
||||
thingModelList: ThingModelData[]
|
||||
}>()
|
||||
|
||||
const loading = ref(false) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref([] as any[]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
deviceId: props.deviceId,
|
||||
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
|
||||
identifier: '',
|
||||
times: [] as any[],
|
||||
pageNo: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 服务类型的物模型数据 */
|
||||
const serviceThingModels = computed(() => {
|
||||
return props.thingModelList.filter(
|
||||
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.SERVICE)
|
||||
)
|
||||
})
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
if (!props.deviceId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields()
|
||||
queryParams.identifier = ''
|
||||
queryParams.times = []
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 获取服务名称 */
|
||||
const getServiceName = (identifier: string | undefined) => {
|
||||
if (!identifier) return '-'
|
||||
const service = serviceThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier
|
||||
)
|
||||
return service?.name || identifier
|
||||
}
|
||||
|
||||
/** 获取调用方式 */
|
||||
const getCallType = (identifier: string | undefined) => {
|
||||
if (!identifier) return '-'
|
||||
const service = serviceThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier
|
||||
)
|
||||
if (!service?.service?.callType) return '-'
|
||||
return getThingModelServiceCallTypeLabel(service.service.callType) || '-'
|
||||
}
|
||||
|
||||
/** 解析参数 */
|
||||
const parseParams = (params: string) => {
|
||||
if (!params) return '-'
|
||||
try {
|
||||
const parsed = JSON.parse(params)
|
||||
if (parsed.params) {
|
||||
return JSON.stringify(parsed.params, null, 2)
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch (error) {
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Page>
|
||||
<DeviceDetailsHeader
|
||||
:loading="loading"
|
||||
:product="product"
|
||||
:device="device"
|
||||
@refresh="getDeviceData"
|
||||
/>
|
||||
|
||||
<a-tabs v-model:active-key="activeTab" class="device-detail-tabs mt-4">
|
||||
<a-tab-pane key="info" tab="设备信息">
|
||||
<DeviceDetailsInfo v-if="activeTab === 'info'" :product="product" :device="device" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="model" tab="物模型数据">
|
||||
<DeviceDetailsThingModel
|
||||
v-if="activeTab === 'model' && device.id"
|
||||
:device-id="device.id"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="product.deviceType === DeviceTypeEnum.GATEWAY" key="sub-device" tab="子设备管理" />
|
||||
<a-tab-pane key="log" tab="设备消息">
|
||||
<DeviceDetailsMessage v-if="activeTab === 'log' && device.id" :device-id="device.id" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="simulator" tab="模拟设备">
|
||||
<DeviceDetailsSimulator
|
||||
v-if="activeTab === 'simulator'"
|
||||
:product="product"
|
||||
:device="device"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="config" tab="设备配置">
|
||||
<DeviceDetailConfig
|
||||
v-if="activeTab === 'config'"
|
||||
:device="device"
|
||||
@success="getDeviceData"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</Page>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, unref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useTabbarStore } from '@vben/stores'
|
||||
import { Page } from '@vben/common-ui'
|
||||
import { DeviceApi } from '#/api/iot/device/device'
|
||||
import type { DeviceVO } from '#/api/iot/device/device'
|
||||
import { DeviceTypeEnum, ProductApi } from '#/api/iot/product/product'
|
||||
import type { ProductVO } from '#/api/iot/product/product'
|
||||
import { ThingModelApi } from '#/api/iot/thingmodel'
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel'
|
||||
import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
|
||||
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
|
||||
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
|
||||
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
|
||||
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
|
||||
import DeviceDetailConfig from './DeviceDetailConfig.vue'
|
||||
|
||||
defineOptions({ name: 'IoTDeviceDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const id = Number(route.params.id) // 将字符串转换为数字
|
||||
const loading = ref(true) // 加载中
|
||||
const product = ref<ProductVO>({} as ProductVO) // 产品详情
|
||||
const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
|
||||
const activeTab = ref('info') // 默认激活的标签页
|
||||
const thingModelList = ref<ThingModelData[]>([]) // 物模型列表数据
|
||||
|
||||
/** 获取设备详情 */
|
||||
const getDeviceData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
device.value = await DeviceApi.getDevice(id)
|
||||
await getProductData(device.value.productId)
|
||||
await getThingModelList(device.value.productId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取产品详情 */
|
||||
const getProductData = async (id: number) => {
|
||||
product.value = await ProductApi.getProduct(id)
|
||||
}
|
||||
|
||||
/** 获取物模型列表 */
|
||||
const getThingModelList = async (productId: number) => {
|
||||
try {
|
||||
const data = await ThingModelApi.getThingModelList(productId)
|
||||
thingModelList.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取物模型列表失败:', error)
|
||||
thingModelList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
const tabbarStore = useTabbarStore() // 视图操作
|
||||
const router = useRouter() // 路由
|
||||
const { currentRoute } = router
|
||||
onMounted(async () => {
|
||||
if (!id) {
|
||||
message.warning({ content: '参数错误,产品不能为空!' })
|
||||
await tabbarStore.closeTab(unref(currentRoute), router)
|
||||
return
|
||||
}
|
||||
await getDeviceData()
|
||||
activeTab.value = (route.query.tab as string) || 'info'
|
||||
})
|
||||
</script>
|
||||
|
||||
114
apps/web-antd/src/views/iot/device/group/data.ts
Normal file
114
apps/web-antd/src/views/iot/device/group/data.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
|
||||
/** 新增/修改设备分组的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分组名称不能为空')
|
||||
.max(64, '分组名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'parentId',
|
||||
label: '父级分组',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
fieldNames: {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
},
|
||||
placeholder: '请选择父级分组',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '分组描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
title: '分组名称',
|
||||
minWidth: 200,
|
||||
treeNode: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '分组描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'deviceCount',
|
||||
title: '设备数量',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,28 +1,141 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDeviceGroup, getDeviceGroupPage } from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DeviceGroupForm from './modules/device-group-form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroup' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceGroupForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建设备分组 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑设备分组 */
|
||||
function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备分组 */
|
||||
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDeviceGroup(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
treeConfig: {
|
||||
transform: true,
|
||||
rowField: 'id',
|
||||
parentField: 'parentId',
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const data = await getDeviceGroupPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
// 转换为树形结构
|
||||
return {
|
||||
...data,
|
||||
list: handleTree(data.list, 'id', 'parentId'),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<IotDeviceGroupApi.DeviceGroup>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/group/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/group/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="设备分组列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['设备分组']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device-group:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:device-group:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device-group:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createDeviceGroup,
|
||||
getDeviceGroup,
|
||||
updateDeviceGroup,
|
||||
} from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroupForm' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const formData = ref<IotDeviceGroupApi.DeviceGroup>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['设备分组'])
|
||||
: $t('ui.actionTitle.create', ['设备分组']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
schema: useFormSchema(),
|
||||
showCollapseButton: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
|
||||
if (formData.value?.id) {
|
||||
await updateDeviceGroup({
|
||||
...values,
|
||||
id: formData.value.id,
|
||||
} as IotDeviceGroupApi.DeviceGroup);
|
||||
} else {
|
||||
await createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup);
|
||||
}
|
||||
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = modalApi.getData<IotDeviceGroupApi.DeviceGroup>();
|
||||
// 如果没有数据或没有 id,表示是新增
|
||||
if (!data || !data.id) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// 编辑模式:加载数据
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDeviceGroup(data.id);
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
93
apps/web-antd/src/views/iot/home/chartOptions.ts
Normal file
93
apps/web-antd/src/views/iot/home/chartOptions.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 设备数量饼图配置
|
||||
*/
|
||||
export function getDeviceCountChartOptions(productCategoryDeviceCounts: Record<string, number>): any {
|
||||
const data = Object.entries(productCategoryDeviceCounts).map(
|
||||
([name, value]) => ({ name, value })
|
||||
);
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 个 ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
top: '5%',
|
||||
right: '10%',
|
||||
orient: 'vertical',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备数量',
|
||||
type: 'pie',
|
||||
radius: ['50%', '80%'],
|
||||
center: ['30%', '50%'],
|
||||
data: data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}: {c}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘图表配置
|
||||
*/
|
||||
export function getGaugeChartOptions(value: number, max: number, color: string, title: string): any {
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle: 180,
|
||||
endAngle: 0,
|
||||
min: 0,
|
||||
max: max,
|
||||
center: ['50%', '70%'],
|
||||
radius: '120%',
|
||||
progress: {
|
||||
show: true,
|
||||
width: 12,
|
||||
itemStyle: {
|
||||
color: color,
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 12,
|
||||
color: [[1, '#E5E7EB']],
|
||||
},
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: color,
|
||||
offsetCenter: [0, '-20%'],
|
||||
formatter: '{value}',
|
||||
},
|
||||
title: {
|
||||
show: true,
|
||||
offsetCenter: [0, '20%'],
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
data: [{ value: value, name: title }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
114
apps/web-antd/src/views/iot/home/data.ts
Normal file
114
apps/web-antd/src/views/iot/home/data.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* IoT 首页数据配置文件
|
||||
*
|
||||
* 该文件封装了 IoT 首页所需的:
|
||||
* - 统计数据接口定义
|
||||
* - 业务逻辑函数
|
||||
* - 工具函数
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
import { getStatisticsSummary } from '#/api/iot/statistics';
|
||||
|
||||
/** 统计数据接口 - 使用 API 定义的类型 */
|
||||
export type StatsData = IotStatisticsApi.StatisticsSummary;
|
||||
|
||||
/** 默认统计数据 */
|
||||
export const defaultStatsData: StatsData = {
|
||||
productCategoryCount: 0,
|
||||
productCount: 0,
|
||||
deviceCount: 0,
|
||||
deviceMessageCount: 0,
|
||||
productCategoryTodayCount: 0,
|
||||
productTodayCount: 0,
|
||||
deviceTodayCount: 0,
|
||||
deviceMessageTodayCount: 0,
|
||||
deviceOnlineCount: 0,
|
||||
deviceOfflineCount: 0,
|
||||
deviceInactiveCount: 0,
|
||||
productCategoryDeviceCounts: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载统计数据
|
||||
* @returns Promise<StatsData>
|
||||
*/
|
||||
export async function loadStatisticsData(): Promise<StatsData> {
|
||||
try {
|
||||
const data = await getStatisticsSummary();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('获取统计数据出错:', error);
|
||||
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
|
||||
|
||||
// 返回 Mock 数据用于开发调试
|
||||
return {
|
||||
productCategoryCount: 12,
|
||||
productCount: 45,
|
||||
deviceCount: 328,
|
||||
deviceMessageCount: 15678,
|
||||
productCategoryTodayCount: 2,
|
||||
productTodayCount: 5,
|
||||
deviceTodayCount: 23,
|
||||
deviceMessageTodayCount: 1234,
|
||||
deviceOnlineCount: 256,
|
||||
deviceOfflineCount: 48,
|
||||
deviceInactiveCount: 24,
|
||||
productCategoryDeviceCounts: {
|
||||
'智能家居': 120,
|
||||
'工业设备': 98,
|
||||
'环境监测': 65,
|
||||
'智能穿戴': 45,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 首页业务逻辑 Hook
|
||||
* 封装了首页的所有业务逻辑和状态管理
|
||||
*/
|
||||
export function useIotHome() {
|
||||
const loading = ref(true);
|
||||
const statsData = ref<StatsData>(defaultStatsData);
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
statsData.value = await loadStatisticsData();
|
||||
} catch (error) {
|
||||
console.error('获取统计数据出错:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
statsData,
|
||||
loadData,
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化数字 - 大数字显示为 K/M */
|
||||
export const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
@@ -1,28 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { Row, Col } from 'ant-design-vue';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
// 导入组件
|
||||
import ComparisonCard from './modules/ComparisonCard.vue';
|
||||
import DeviceCountCard from './modules/DeviceCountCard.vue';
|
||||
import DeviceStateCountCard from './modules/DeviceStateCountCard.vue';
|
||||
import MessageTrendCard from './modules/MessageTrendCard.vue';
|
||||
|
||||
// 导入业务逻辑
|
||||
import { useIotHome } from './data';
|
||||
|
||||
defineOptions({ name: 'IoTHome' });
|
||||
|
||||
// 使用业务逻辑 Hook
|
||||
const { loading, statsData } = useIotHome();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/home/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/home/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<!-- 第一行:统计卡片 -->
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<ComparisonCard
|
||||
title="分类数量"
|
||||
:value="statsData.productCategoryCount"
|
||||
:todayCount="statsData.productCategoryTodayCount"
|
||||
icon="menu"
|
||||
iconColor="text-blue-500"
|
||||
:loading="loading"
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<ComparisonCard
|
||||
title="产品数量"
|
||||
:value="statsData.productCount"
|
||||
:todayCount="statsData.productTodayCount"
|
||||
icon="box"
|
||||
iconColor="text-orange-500"
|
||||
:loading="loading"
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<ComparisonCard
|
||||
title="设备数量"
|
||||
:value="statsData.deviceCount"
|
||||
:todayCount="statsData.deviceTodayCount"
|
||||
icon="cpu"
|
||||
iconColor="text-purple-500"
|
||||
:loading="loading"
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<ComparisonCard
|
||||
title="设备消息数"
|
||||
:value="statsData.deviceMessageCount"
|
||||
:todayCount="statsData.deviceMessageTodayCount"
|
||||
icon="message"
|
||||
iconColor="text-teal-500"
|
||||
:loading="loading"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第二行:图表 -->
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="12">
|
||||
<DeviceCountCard :statsData="statsData" :loading="loading" />
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<DeviceStateCountCard :statsData="statsData" :loading="loading" />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第三行:消息统计 -->
|
||||
<Row :gutter="16">
|
||||
<Col :span="24">
|
||||
<MessageTrendCard />
|
||||
</Col>
|
||||
</Row>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vben-page-content) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
72
apps/web-antd/src/views/iot/home/modules/ComparisonCard.vue
Normal file
72
apps/web-antd/src/views/iot/home/modules/ComparisonCard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<Card class="stat-card" :loading="loading">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="text-gray-500 text-sm font-medium mb-2">{{ title }}</span>
|
||||
<span class="text-3xl font-bold text-gray-800">
|
||||
<span v-if="value === -1">--</span>
|
||||
<CountTo v-else :end-val="value" :duration="1000" />
|
||||
</span>
|
||||
</div>
|
||||
<div :class="`text-4xl ${iconColor}`">
|
||||
<IconComponent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 border-t border-gray-100">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-400">今日新增</span>
|
||||
<span v-if="todayCount === -1" class="text-gray-400">--</span>
|
||||
<span v-else class="text-green-500 font-medium">+{{ todayCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { CountTo } from '@vben/common-ui';
|
||||
import { createIconifyIcon } from '@vben/icons';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ComparisonCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
value: number;
|
||||
todayCount: number;
|
||||
icon: string;
|
||||
iconColor?: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
'menu': createIconifyIcon('ant-design:appstore-outlined'),
|
||||
'box': createIconifyIcon('ant-design:box-plot-outlined'),
|
||||
'cpu': createIconifyIcon('ant-design:cluster-outlined'),
|
||||
'message': createIconifyIcon('ant-design:message-outlined'),
|
||||
};
|
||||
|
||||
const IconComponent = computed(() => iconMap[props.icon] || iconMap.menu);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 160px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
131
apps/web-antd/src/views/iot/home/modules/DeviceCountCard.vue
Normal file
131
apps/web-antd/src/views/iot/home/modules/DeviceCountCard.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<Card title="设备数量统计" :loading="loading" class="chart-card">
|
||||
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
|
||||
<Empty description="加载中..." />
|
||||
</div>
|
||||
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
|
||||
<Empty description="暂无数据" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<EchartsUI ref="deviceCountChartRef" class="h-[400px] w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { Card, Empty } from 'ant-design-vue';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
defineOptions({ name: 'DeviceCountCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
statsData: IotStatisticsApi.StatisticsSummary;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const deviceCountChartRef = ref();
|
||||
const { renderEcharts } = useEcharts(deviceCountChartRef);
|
||||
|
||||
/** 是否有数据 */
|
||||
const hasData = computed(() => {
|
||||
if (!props.statsData) return false;
|
||||
const categories = Object.entries(props.statsData.productCategoryDeviceCounts || {});
|
||||
return categories.length > 0 && props.statsData.deviceCount !== 0;
|
||||
});
|
||||
|
||||
/** 初始化图表 */
|
||||
const initChart = () => {
|
||||
if (!hasData.value) return;
|
||||
|
||||
nextTick(() => {
|
||||
const data = Object.entries(props.statsData.productCategoryDeviceCounts).map(
|
||||
([name, value]) => ({ name, value })
|
||||
);
|
||||
|
||||
renderEcharts({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 个 ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'horizontal',
|
||||
bottom: '10px',
|
||||
left: 'center',
|
||||
icon: 'circle',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 12,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
pageButtonPosition: 'end',
|
||||
pageIconSize: 12,
|
||||
pageTextStyle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
pageFormatter: '{current}/{total}',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备数量',
|
||||
type: 'pie',
|
||||
radius: ['35%', '55%'],
|
||||
center: ['50%', '40%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: data,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** 监听数据变化 */
|
||||
watch(
|
||||
() => props.statsData,
|
||||
() => {
|
||||
initChart();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/** 组件挂载时初始化图表 */
|
||||
onMounted(() => {
|
||||
initChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-card :deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Card title="设备状态统计" :loading="loading" class="chart-card">
|
||||
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
|
||||
<Empty description="加载中..." />
|
||||
</div>
|
||||
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
|
||||
<Empty description="暂无数据" />
|
||||
</div>
|
||||
<Row v-else class="h-[280px]">
|
||||
<Col :span="8" class="flex items-center justify-center">
|
||||
<EchartsUI ref="deviceOnlineChartRef" class="h-[250px] w-full" />
|
||||
</Col>
|
||||
<Col :span="8" class="flex items-center justify-center">
|
||||
<EchartsUI ref="deviceOfflineChartRef" class="h-[250px] w-full" />
|
||||
</Col>
|
||||
<Col :span="8" class="flex items-center justify-center">
|
||||
<EchartsUI ref="deviceInactiveChartRef" class="h-[250px] w-full" />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { Card, Empty, Row, Col } from 'ant-design-vue';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
defineOptions({ name: 'DeviceStateCountCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
statsData: IotStatisticsApi.StatisticsSummary;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const deviceOnlineChartRef = ref();
|
||||
const deviceOfflineChartRef = ref();
|
||||
const deviceInactiveChartRef = ref();
|
||||
|
||||
const { renderEcharts: renderOnlineChart } = useEcharts(deviceOnlineChartRef);
|
||||
const { renderEcharts: renderOfflineChart } = useEcharts(deviceOfflineChartRef);
|
||||
const { renderEcharts: renderInactiveChart } = useEcharts(deviceInactiveChartRef);
|
||||
|
||||
/** 是否有数据 */
|
||||
const hasData = computed(() => {
|
||||
if (!props.statsData) return false;
|
||||
return props.statsData.deviceCount !== 0;
|
||||
});
|
||||
|
||||
/** 获取仪表盘配置 */
|
||||
const getGaugeOption = (value: number, color: string, title: string): any => {
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle: 225,
|
||||
endAngle: -45,
|
||||
min: 0,
|
||||
max: props.statsData.deviceCount || 100,
|
||||
center: ['50%', '50%'],
|
||||
radius: '80%',
|
||||
progress: {
|
||||
show: true,
|
||||
width: 12,
|
||||
itemStyle: {
|
||||
color: color,
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 12,
|
||||
color: [[1, '#E5E7EB']] as [number, string][],
|
||||
},
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
title: {
|
||||
show: true,
|
||||
offsetCenter: [0, '80%'],
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: color,
|
||||
offsetCenter: [0, '10%'],
|
||||
formatter: (val: number) => `${val} 个`,
|
||||
},
|
||||
data: [{ value: value, name: title }],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** 初始化图表 */
|
||||
const initCharts = () => {
|
||||
if (!hasData.value) return;
|
||||
|
||||
nextTick(() => {
|
||||
// 在线设备
|
||||
renderOnlineChart(
|
||||
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备')
|
||||
);
|
||||
// 离线设备
|
||||
renderOfflineChart(
|
||||
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备')
|
||||
);
|
||||
// 待激活设备
|
||||
renderInactiveChart(
|
||||
getGaugeOption(props.statsData.deviceInactiveCount, '#1890ff', '待激活设备')
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/** 监听数据变化 */
|
||||
watch(
|
||||
() => props.statsData,
|
||||
() => {
|
||||
initCharts();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/** 组件挂载时初始化图表 */
|
||||
onMounted(() => {
|
||||
initCharts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-card :deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
245
apps/web-antd/src/views/iot/home/modules/MessageTrendCard.vue
Normal file
245
apps/web-antd/src/views/iot/home/modules/MessageTrendCard.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<Card class="chart-card" :loading="loading">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-base font-medium">上下行消息量统计</span>
|
||||
<Space :size="8">
|
||||
<Button
|
||||
:type="activeTimeRange === '1h' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setTimeRange('1h')"
|
||||
>
|
||||
最近1小时
|
||||
</Button>
|
||||
<Button
|
||||
:type="activeTimeRange === '24h' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setTimeRange('24h')"
|
||||
>
|
||||
最近24小时
|
||||
</Button>
|
||||
<Button
|
||||
:type="activeTimeRange === '7d' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setTimeRange('7d')"
|
||||
>
|
||||
近一周
|
||||
</Button>
|
||||
<RangePicker
|
||||
v-model:value="dateRange"
|
||||
format="YYYY-MM-DD"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
@change="handleDateChange"
|
||||
size="small"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="h-[350px] flex justify-center items-center">
|
||||
<Empty description="加载中..." />
|
||||
</div>
|
||||
<div v-else-if="!hasData" class="h-[350px] flex justify-center items-center">
|
||||
<Empty description="暂无数据" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue';
|
||||
import { Card, Empty, Space, DatePicker, Button } from 'ant-design-vue';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
StatisticsApi,
|
||||
type IotStatisticsDeviceMessageSummaryByDateRespVO,
|
||||
type IotStatisticsDeviceMessageReqVO,
|
||||
} from '#/api/iot/statistics';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
defineOptions({ name: 'MessageTrendCard' });
|
||||
|
||||
const messageChartRef = ref();
|
||||
const { renderEcharts } = useEcharts(messageChartRef);
|
||||
|
||||
const loading = ref(false);
|
||||
const messageData = ref<IotStatisticsDeviceMessageSummaryByDateRespVO[]>([]);
|
||||
const activeTimeRange = ref('7d'); // 当前选中的时间范围
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
||||
|
||||
const queryParams = reactive<IotStatisticsDeviceMessageReqVO>({
|
||||
interval: 1, // 按天
|
||||
times: [],
|
||||
});
|
||||
|
||||
// 是否有数据
|
||||
const hasData = computed(() => {
|
||||
return messageData.value && messageData.value.length > 0;
|
||||
});
|
||||
|
||||
// 设置时间范围
|
||||
const setTimeRange = (range: string) => {
|
||||
activeTimeRange.value = range;
|
||||
dateRange.value = undefined; // 清空自定义时间选择
|
||||
|
||||
let start: Dayjs;
|
||||
let end = dayjs();
|
||||
|
||||
switch (range) {
|
||||
case '1h':
|
||||
start = dayjs().subtract(1, 'hour');
|
||||
queryParams.interval = 1; // 按分钟
|
||||
break;
|
||||
case '24h':
|
||||
start = dayjs().subtract(24, 'hour');
|
||||
queryParams.interval = 1; // 按小时
|
||||
break;
|
||||
case '7d':
|
||||
start = dayjs().subtract(7, 'day');
|
||||
queryParams.interval = 1; // 按天
|
||||
break;
|
||||
default:
|
||||
start = dayjs().subtract(7, 'day');
|
||||
queryParams.interval = 1;
|
||||
}
|
||||
|
||||
queryParams.times = [
|
||||
start.format('YYYY-MM-DD HH:mm:ss'),
|
||||
end.format('YYYY-MM-DD HH:mm:ss'),
|
||||
];
|
||||
|
||||
fetchMessageData();
|
||||
};
|
||||
|
||||
// 处理自定义日期选择
|
||||
const handleDateChange = () => {
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
activeTimeRange.value = ''; // 清空快捷选择
|
||||
queryParams.interval = 1; // 按天
|
||||
queryParams.times = [
|
||||
dateRange.value[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dateRange.value[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
];
|
||||
fetchMessageData();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取消息统计数据
|
||||
const fetchMessageData = async () => {
|
||||
if (!queryParams.times || queryParams.times.length !== 2) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
messageData.value = await StatisticsApi.getDeviceMessageSummaryByDate(queryParams);
|
||||
await nextTick();
|
||||
initChart();
|
||||
} catch (error) {
|
||||
console.error('获取消息统计数据失败:', error);
|
||||
messageData.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (!hasData.value) return;
|
||||
|
||||
const times = messageData.value.map((item) => item.time);
|
||||
const upstreamData = messageData.value.map((item) => item.upstreamCount);
|
||||
const downstreamData = messageData.value.map((item) => item.downstreamCount);
|
||||
|
||||
renderEcharts({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['上行消息', '下行消息'],
|
||||
top: '5%',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: times,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '消息数量',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '上行消息',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: upstreamData,
|
||||
itemStyle: {
|
||||
color: '#1890ff',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '下行消息',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: downstreamData,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// 组件挂载时查询数据
|
||||
onMounted(() => {
|
||||
setTimeRange('7d'); // 默认显示近一周数据
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-card :deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-card :deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
170
apps/web-antd/src/views/iot/ota/data.ts
Normal file
170
apps/web-antd/src/views/iot/ota/data.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 新增/修改固件的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '固件名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '所属产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'version',
|
||||
label: '版本号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入版本号',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '固件描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'fileUrl',
|
||||
label: '固件文件',
|
||||
component: 'Upload',
|
||||
componentProps: {
|
||||
maxCount: 1,
|
||||
accept: '.bin,.hex,.zip',
|
||||
},
|
||||
rules: 'required',
|
||||
help: '支持上传 .bin、.hex、.zip 格式的固件文件',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '固件名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'checkbox',
|
||||
width: 50,
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
title: '固件编号',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '固件名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'version',
|
||||
title: '版本号',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'productName',
|
||||
title: '所属产品',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '固件描述',
|
||||
minWidth: 200,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'fileSize',
|
||||
title: '文件大小',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => {
|
||||
if (!cellValue) return '-';
|
||||
const kb = cellValue / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
||||
return `${(kb / 1024).toFixed(2)} MB`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue === 1 ? '启用' : '禁用';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
207
apps/web-antd/src/views/iot/ota/firmware/data.ts
Normal file
207
apps/web-antd/src/views/iot/ota/firmware/data.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 新增/修改固件的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '固件名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '所属产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'version',
|
||||
label: '版本号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入版本号',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '固件描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'fileUrl',
|
||||
label: '固件文件',
|
||||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
maxNumber: 1,
|
||||
accept: ['bin', 'hex', 'zip'],
|
||||
maxSize: 50,
|
||||
helpText: '支持上传 .bin、.hex、.zip 格式的固件文件,最大 50MB',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '固件名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '固件编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '固件名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'version',
|
||||
title: '版本号',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '固件描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '所属产品',
|
||||
minWidth: 150,
|
||||
slots: { default: 'product' },
|
||||
},
|
||||
{
|
||||
field: 'fileUrl',
|
||||
title: '固件文件',
|
||||
minWidth: 120,
|
||||
slots: { default: 'fileUrl' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Grid 配置项 */
|
||||
export function useGridOptions(): VxeTableGridOptions<IoTOtaFirmwareApi.Firmware> {
|
||||
return {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getOtaFirmwarePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 删除固件 */
|
||||
export async function handleDeleteFirmware(
|
||||
row: IoTOtaFirmwareApi.Firmware,
|
||||
onSuccess: () => void,
|
||||
) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteOtaFirmware(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onSuccess();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
124
apps/web-antd/src/views/iot/ota/firmware/index.vue
Normal file
124
apps/web-antd/src/views/iot/ota/firmware/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import {
|
||||
handleDeleteFirmware,
|
||||
useGridFormSchema,
|
||||
useGridOptions,
|
||||
} from './data';
|
||||
import Form from '../modules/OtaFirmwareForm.vue';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmware' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建固件 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 编辑固件 */
|
||||
function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
|
||||
formModalApi.setData({ type: 'update', id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 删除固件 */
|
||||
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
|
||||
await handleDeleteFirmware(row, onRefresh);
|
||||
}
|
||||
|
||||
/** 查看固件详情 */
|
||||
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
|
||||
formModalApi.setData({ type: 'view', id: row.id }).open();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: useGridOptions(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="固件列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['固件']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 产品名称列 -->
|
||||
<template #product="{ row }">
|
||||
<span class="text-gray-700">{{ row.productName || '未知产品' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 固件文件列 -->
|
||||
<template #fileUrl="{ row }">
|
||||
<a
|
||||
v-if="row.fileUrl"
|
||||
:href="row.fileUrl"
|
||||
target="_blank"
|
||||
download
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
>
|
||||
<Icon icon="ant-design:download-outlined" class="mr-1" />
|
||||
下载固件
|
||||
</a>
|
||||
<span v-else class="text-gray-400">无文件</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.detail'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
129
apps/web-antd/src/views/iot/ota/index.vue
Normal file
129
apps/web-antd/src/views/iot/ota/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import OtaFirmwareForm from './modules/OtaFirmwareForm.vue';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmware' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: OtaFirmwareForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建固件 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑固件 */
|
||||
function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除固件 */
|
||||
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteOtaFirmware(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getOtaFirmwarePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<IoTOtaFirmwareApi.Firmware>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="OTA 固件列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['固件']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:ota-firmware:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:ota-firmware:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:ota-firmware:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
90
apps/web-antd/src/views/iot/ota/modules/OtaFirmwareForm.vue
Normal file
90
apps/web-antd/src/views/iot/ota/modules/OtaFirmwareForm.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createOtaFirmware,
|
||||
getOtaFirmware,
|
||||
updateOtaFirmware,
|
||||
} from '#/api/iot/ota/firmware';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../firmware/data';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmwareForm' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const formData = ref<IoTOtaFirmwareApi.Firmware>();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['固件'])
|
||||
: $t('ui.actionTitle.create', ['固件']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IoTOtaFirmwareApi.Firmware;
|
||||
try {
|
||||
await (formData.value?.id ? updateOtaFirmware(data) : createOtaFirmware(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<IoTOtaFirmwareApi.Firmware>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getOtaFirmware(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
149
apps/web-antd/src/views/iot/ota/modules/detail/index.vue
Normal file
149
apps/web-antd/src/views/iot/ota/modules/detail/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
|
||||
import { formatDate } from '@vben/utils';
|
||||
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
|
||||
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
|
||||
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
|
||||
import OtaTaskList from '../task/OtaTaskList.vue';
|
||||
|
||||
/** IoT OTA 固件详情 */
|
||||
defineOptions({ name: 'IoTOtaFirmwareDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const firmwareId = ref(Number(route.params.id));
|
||||
const firmwareLoading = ref(false);
|
||||
const firmware = ref<IoTOtaFirmware>({} as IoTOtaFirmware);
|
||||
|
||||
const firmwareStatisticsLoading = ref(false);
|
||||
const firmwareStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
const getFirmwareInfo = async () => {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取升级统计 */
|
||||
const getStatistics = async () => {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
|
||||
firmwareId.value,
|
||||
);
|
||||
} finally {
|
||||
firmwareStatisticsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getFirmwareInfo();
|
||||
getStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- 固件信息 -->
|
||||
<Card title="固件信息" class="mb-5" :loading="firmwareLoading">
|
||||
<Descriptions :column="3" bordered>
|
||||
<Descriptions.Item label="固件名称">
|
||||
{{ firmware?.name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属产品">
|
||||
{{ firmware?.productName }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固件版本">
|
||||
{{ firmware?.version }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固件描述" :span="2">
|
||||
{{ firmware?.description }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 升级设备统计 -->
|
||||
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col :span="6">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||
{{
|
||||
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级设备总数</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-yellow-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-green-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-red-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级取消</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 任务管理 -->
|
||||
<OtaTaskList
|
||||
v-if="firmware?.productId"
|
||||
:firmware-id="firmwareId"
|
||||
:product-id="firmware.productId"
|
||||
@success="getStatistics"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
|
||||
import { formatDate } from '@vben/utils';
|
||||
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
|
||||
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
|
||||
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
|
||||
import OtaTaskList from '../task/OtaTaskList.vue';
|
||||
|
||||
/** IoT OTA 固件详情 */
|
||||
defineOptions({ name: 'IoTOtaFirmwareDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const firmwareId = ref(Number(route.params.id));
|
||||
const firmwareLoading = ref(false);
|
||||
const firmware = ref<IoTOtaFirmware>({} as IoTOtaFirmware);
|
||||
|
||||
const firmwareStatisticsLoading = ref(false);
|
||||
const firmwareStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
const getFirmwareInfo = async () => {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取升级统计 */
|
||||
const getStatistics = async () => {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
|
||||
firmwareId.value,
|
||||
);
|
||||
} finally {
|
||||
firmwareStatisticsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getFirmwareInfo();
|
||||
getStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- 固件信息 -->
|
||||
<Card title="固件信息" class="mb-5" :loading="firmwareLoading">
|
||||
<Descriptions :column="3" bordered>
|
||||
<Descriptions.Item label="固件名称">
|
||||
{{ firmware?.name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属产品">
|
||||
{{ firmware?.productName }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固件版本">
|
||||
{{ firmware?.version }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固件描述" :span="2">
|
||||
{{ firmware?.description }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 升级设备统计 -->
|
||||
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col :span="6">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||
{{
|
||||
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级设备总数</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-yellow-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-green-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-red-500">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级取消</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 任务管理 -->
|
||||
<OtaTaskList
|
||||
v-if="firmware?.productId"
|
||||
:firmware-id="firmwareId"
|
||||
:product-id="firmware.productId"
|
||||
@success="getStatistics"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
349
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskDetail.vue
Normal file
349
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskDetail.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Card, Col, Descriptions, Modal, Row, Table, Tabs, Tag, message } from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
|
||||
import type { OtaTaskRecord } from '#/api/iot/ota/task/record';
|
||||
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
|
||||
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
/** OTA 任务详情组件 */
|
||||
defineOptions({ name: 'OtaTaskDetail' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const taskId = ref<number>();
|
||||
const taskLoading = ref(false);
|
||||
const task = ref<OtaTask>({} as OtaTask);
|
||||
|
||||
const taskStatisticsLoading = ref(false);
|
||||
const taskStatistics = ref<Record<string, number>>({});
|
||||
|
||||
const recordLoading = ref(false);
|
||||
const recordList = ref<OtaTaskRecord[]>([]);
|
||||
const recordTotal = ref(0);
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
taskId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
const activeTab = ref('');
|
||||
|
||||
/** 状态标签配置 */
|
||||
const statusTabs = computed(() => {
|
||||
const tabs = [{ key: '', label: '全部设备' }];
|
||||
Object.values(IoTOtaTaskRecordStatusEnum).forEach((status) => {
|
||||
tabs.push({
|
||||
key: status.value.toString(),
|
||||
label: status.label,
|
||||
});
|
||||
});
|
||||
return tabs;
|
||||
});
|
||||
|
||||
/** 表格列配置 */
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '当前版本',
|
||||
dataIndex: 'fromFirmwareVersion',
|
||||
key: 'fromFirmwareVersion',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '升级状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center' as const,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '升级进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
align: 'center' as const,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
align: 'center' as const,
|
||||
width: 180,
|
||||
customRender: ({ text }: any) => formatDate(text, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center' as const,
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
|
||||
const [ModalComponent, modalApi] = useVbenModal();
|
||||
|
||||
/** 获取任务详情 */
|
||||
const getTaskInfo = async () => {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
task.value = await IoTOtaTaskApi.getOtaTask(taskId.value);
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取统计数据 */
|
||||
const getStatistics = async () => {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskStatisticsLoading.value = true;
|
||||
try {
|
||||
taskStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
|
||||
undefined,
|
||||
taskId.value,
|
||||
);
|
||||
} finally {
|
||||
taskStatisticsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取升级记录列表 */
|
||||
const getRecordList = async () => {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
recordLoading.value = true;
|
||||
try {
|
||||
queryParams.taskId = taskId.value;
|
||||
const data = await IoTOtaTaskRecordApi.getOtaTaskRecordPage(queryParams);
|
||||
recordList.value = data.list || [];
|
||||
recordTotal.value = data.total || 0;
|
||||
} finally {
|
||||
recordLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 切换标签 */
|
||||
const handleTabChange = (tabKey: string | number) => {
|
||||
activeTab.value = String(tabKey);
|
||||
queryParams.pageNo = 1;
|
||||
queryParams.status = activeTab.value === '' ? undefined : parseInt(String(tabKey));
|
||||
getRecordList();
|
||||
};
|
||||
|
||||
/** 分页变化 */
|
||||
const handleTableChange = (pagination: any) => {
|
||||
queryParams.pageNo = pagination.current;
|
||||
queryParams.pageSize = pagination.pageSize;
|
||||
getRecordList();
|
||||
};
|
||||
|
||||
/** 取消升级 */
|
||||
const handleCancelUpgrade = async (record: OtaTaskRecord) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确认要取消该设备的升级任务吗?',
|
||||
async onOk() {
|
||||
try {
|
||||
await IoTOtaTaskRecordApi.cancelOtaTaskRecord(record.id!);
|
||||
message.success('取消成功');
|
||||
await getRecordList();
|
||||
await getStatistics();
|
||||
await getTaskInfo();
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
console.error('取消升级失败', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = (id: number) => {
|
||||
modalApi.open();
|
||||
taskId.value = id;
|
||||
activeTab.value = '';
|
||||
queryParams.pageNo = 1;
|
||||
queryParams.status = undefined;
|
||||
|
||||
// 加载数据
|
||||
getTaskInfo();
|
||||
getStatistics();
|
||||
getRecordList();
|
||||
};
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComponent title="升级任务详情" class="w-5/6">
|
||||
<div class="p-4">
|
||||
<!-- 任务信息 -->
|
||||
<Card title="任务信息" class="mb-5" :loading="taskLoading">
|
||||
<Descriptions :column="3" bordered>
|
||||
<Descriptions.Item label="任务编号">{{ task.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务名称">{{ task.name }}</Descriptions.Item>
|
||||
<Descriptions.Item label="升级范围">
|
||||
<Tag v-if="task.deviceScope === 1" color="blue">全部设备</Tag>
|
||||
<Tag v-else-if="task.deviceScope === 2" color="green">指定设备</Tag>
|
||||
<Tag v-else>{{ task.deviceScope }}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<Tag v-if="task.status === 0" color="orange">待执行</Tag>
|
||||
<Tag v-else-if="task.status === 1" color="blue">执行中</Tag>
|
||||
<Tag v-else-if="task.status === 2" color="green">已完成</Tag>
|
||||
<Tag v-else-if="task.status === 3" color="red">已取消</Tag>
|
||||
<Tag v-else>{{ task.status }}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ task.createTime ? formatDate(task.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务描述" :span="3">
|
||||
{{ task.description }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 任务升级设备统计 -->
|
||||
<Card title="升级设备统计" class="mb-5" :loading="taskStatisticsLoading">
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col :span="6">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||
{{
|
||||
Object.values(taskStatistics).reduce((sum, count) => sum + (count || 0), 0) || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级设备总数</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-blue-400">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-yellow-500">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-green-500">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-red-500">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
|
||||
<div class="text-3xl font-bold mb-2 text-gray-400">
|
||||
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级取消</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 设备管理 -->
|
||||
<Card title="升级设备记录">
|
||||
<Tabs v-model:activeKey="activeTab" @change="handleTabChange" class="mb-4">
|
||||
<Tabs.TabPane v-for="tab in statusTabs" :key="tab.key" :tab="tab.label" />
|
||||
</Tabs>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="recordList"
|
||||
:loading="recordLoading"
|
||||
:pagination="{
|
||||
current: queryParams.pageNo,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: recordTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
}"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 升级状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag v-if="record.status === 0" color="default">待推送</Tag>
|
||||
<Tag v-else-if="record.status === 1" color="blue">已推送</Tag>
|
||||
<Tag v-else-if="record.status === 2" color="processing">升级中</Tag>
|
||||
<Tag v-else-if="record.status === 3" color="success">成功</Tag>
|
||||
<Tag v-else-if="record.status === 4" color="error">失败</Tag>
|
||||
<Tag v-else-if="record.status === 5" color="warning">已取消</Tag>
|
||||
<Tag v-else>{{ record.status }}</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 升级进度 -->
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
{{ record.progress }}%
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a
|
||||
v-if="
|
||||
[
|
||||
IoTOtaTaskRecordStatusEnum.PENDING.value,
|
||||
IoTOtaTaskRecordStatusEnum.PUSHED.value,
|
||||
IoTOtaTaskRecordStatusEnum.UPGRADING.value,
|
||||
].includes(record.status)
|
||||
"
|
||||
class="text-red-500"
|
||||
@click="handleCancelUpgrade(record)"
|
||||
>
|
||||
取消
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
148
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskForm.vue
Normal file
148
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskForm.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { message, Form, Input, Select, Spin } from 'ant-design-vue';
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
|
||||
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
|
||||
import type { DeviceVO } from '#/api/iot/device/device';
|
||||
import * as DeviceApi from '#/api/iot/device/device';
|
||||
|
||||
/** IoT OTA 升级任务表单 */
|
||||
defineOptions({ name: 'OtaTaskForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
firmwareId: number;
|
||||
productId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formLoading = ref(false);
|
||||
const formData = ref<OtaTask>({
|
||||
name: '',
|
||||
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
|
||||
firmwareId: props.firmwareId,
|
||||
description: '',
|
||||
deviceIds: [],
|
||||
});
|
||||
const formRef = ref();
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' as const, type: 'string' as const }],
|
||||
deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' as const, type: 'number' as const }],
|
||||
deviceIds: [{ required: true, message: '请至少选择一个设备', trigger: 'change' as const, type: 'array' as const }],
|
||||
};
|
||||
const devices = ref<DeviceVO[]>([]);
|
||||
|
||||
/** 设备选项 */
|
||||
const deviceOptions = computed(() => {
|
||||
return devices.value.map((device) => ({
|
||||
label: device.nickname
|
||||
? `${device.deviceName} (${device.nickname})`
|
||||
: device.deviceName,
|
||||
value: device.id,
|
||||
}));
|
||||
});
|
||||
|
||||
/** 升级范围选项 */
|
||||
const deviceScopeOptions = computed(() => {
|
||||
return Object.values(IoTOtaTaskDeviceScopeEnum).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}));
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
modalApi.lock();
|
||||
await IoTOtaTaskApi.createOtaTask(formData.value);
|
||||
message.success('创建成功');
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
// 加载设备列表
|
||||
formLoading.value = true;
|
||||
try {
|
||||
devices.value = (await DeviceApi.getDeviceListByProductId(props.productId)) || [];
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
|
||||
firmwareId: props.firmwareId,
|
||||
description: '',
|
||||
deviceIds: [],
|
||||
};
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async () => {
|
||||
await modalApi.open();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="新增升级任务" class="w-3/5">
|
||||
<Spin :spinning="formLoading">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 20 }"
|
||||
class="mx-4"
|
||||
>
|
||||
<Form.Item label="任务名称" name="name">
|
||||
<Input v-model:value="formData.name" placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="任务描述" name="description">
|
||||
<Input.TextArea
|
||||
v-model:value="formData.description"
|
||||
:rows="3"
|
||||
placeholder="请输入任务描述"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="升级范围" name="deviceScope">
|
||||
<Select
|
||||
v-model:value="formData.deviceScope"
|
||||
placeholder="请选择升级范围"
|
||||
:options="deviceScopeOptions"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
v-if="formData.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value"
|
||||
label="选择设备"
|
||||
name="deviceIds"
|
||||
>
|
||||
<Select
|
||||
v-model:value="formData.deviceIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择设备"
|
||||
:options="deviceOptions"
|
||||
:filter-option="true"
|
||||
show-search
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
242
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskList.vue
Normal file
242
apps/web-antd/src/views/iot/ota/modules/task/OtaTaskList.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { formatDate } from '@vben/utils';
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
|
||||
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
|
||||
import OtaTaskForm from './OtaTaskForm.vue';
|
||||
import OtaTaskDetail from './OtaTaskDetail.vue';
|
||||
import { Card, Input, Table, Space, Modal, message, Tag } from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { VbenButton } from '@vben/common-ui';
|
||||
|
||||
/** IoT OTA 任务列表 */
|
||||
defineOptions({ name: 'OtaTaskList' });
|
||||
|
||||
const props = defineProps<{
|
||||
firmwareId: number;
|
||||
productId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// 任务列表
|
||||
const taskLoading = ref(false);
|
||||
const taskList = ref<OtaTask[]>([]);
|
||||
const taskTotal = ref(0);
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: '',
|
||||
firmwareId: props.firmwareId,
|
||||
});
|
||||
const taskFormRef = ref(); // 任务表单引用
|
||||
const taskDetailRef = ref(); // 任务详情引用
|
||||
|
||||
/** 获取任务列表 */
|
||||
const getTaskList = async () => {
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
const data = await IoTOtaTaskApi.getOtaTaskPage(queryParams);
|
||||
taskList.value = data.list;
|
||||
taskTotal.value = data.total;
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 搜索 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1;
|
||||
getTaskList();
|
||||
};
|
||||
|
||||
/** 打开任务表单 */
|
||||
const openTaskForm = () => {
|
||||
taskFormRef.value?.open();
|
||||
};
|
||||
|
||||
/** 处理任务创建成功 */
|
||||
const handleTaskCreateSuccess = () => {
|
||||
getTaskList();
|
||||
emit('success');
|
||||
};
|
||||
|
||||
/** 查看任务详情 */
|
||||
const handleTaskDetail = (id: number) => {
|
||||
taskDetailRef.value?.open(id);
|
||||
};
|
||||
|
||||
/** 取消任务 */
|
||||
const handleCancelTask = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确认要取消该升级任务吗?',
|
||||
async onOk() {
|
||||
try {
|
||||
await IoTOtaTaskApi.cancelOtaTask(id);
|
||||
message.success('取消成功');
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 刷新数据 */
|
||||
const refresh = async () => {
|
||||
await getTaskList();
|
||||
emit('success');
|
||||
};
|
||||
|
||||
/** 分页变化 */
|
||||
const handleTableChange = (pagination: any) => {
|
||||
queryParams.pageNo = pagination.current;
|
||||
queryParams.pageSize = pagination.pageSize;
|
||||
getTaskList();
|
||||
};
|
||||
|
||||
/** 表格列配置 */
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
title: '任务编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '升级范围',
|
||||
dataIndex: 'deviceScope',
|
||||
key: 'deviceScope',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '升级进度',
|
||||
key: 'progress',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: any) => formatDate(text, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '任务描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
align: 'center' as const,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '任务状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center' as const,
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getTaskList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="升级任务管理" class="mb-5">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<VbenButton type="primary" @click="openTaskForm">
|
||||
<Icon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</VbenButton>
|
||||
<Input
|
||||
v-model:value="queryParams.name"
|
||||
placeholder="请输入任务名称"
|
||||
allow-clear
|
||||
@press-enter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="taskList"
|
||||
:loading="taskLoading"
|
||||
:pagination="{
|
||||
current: queryParams.pageNo,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: taskTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
}"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 升级范围 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'deviceScope'">
|
||||
<Tag v-if="record.deviceScope === 1" color="blue">全部设备</Tag>
|
||||
<Tag v-else-if="record.deviceScope === 2" color="green">指定设备</Tag>
|
||||
<Tag v-else>{{ record.deviceScope }}</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 升级进度 -->
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
{{ record.deviceSuccessCount }}/{{ record.deviceTotalCount }}
|
||||
</template>
|
||||
|
||||
<!-- 任务状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag v-if="record.status === 0" color="orange">待执行</Tag>
|
||||
<Tag v-else-if="record.status === 1" color="blue">执行中</Tag>
|
||||
<Tag v-else-if="record.status === 2" color="green">已完成</Tag>
|
||||
<Tag v-else-if="record.status === 3" color="red">已取消</Tag>
|
||||
<Tag v-else>{{ record.status }}</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<a @click="handleTaskDetail(record.id)">详情</a>
|
||||
<a
|
||||
v-if="record.status === IoTOtaTaskStatusEnum.IN_PROGRESS.value"
|
||||
class="text-red-500"
|
||||
@click="handleCancelTask(record.id)"
|
||||
>
|
||||
取消
|
||||
</a>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<!-- 新增任务弹窗 -->
|
||||
<OtaTaskForm
|
||||
ref="taskFormRef"
|
||||
:firmware-id="firmwareId"
|
||||
:product-id="productId"
|
||||
@success="handleTaskCreateSuccess"
|
||||
/>
|
||||
|
||||
<!-- 任务详情弹窗 -->
|
||||
<OtaTaskDetail ref="taskDetailRef" @success="refresh" />
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,28 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'IotPlugin' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/plugin/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/plugin/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page
|
||||
description="物聯網插件管理"
|
||||
title="插件管理"
|
||||
>
|
||||
<div class="p-4">
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
該功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<div class="mt-4">
|
||||
<h3>功能說明:</h3>
|
||||
<p>IoT 插件管理功能</p>
|
||||
<h3 class="mt-4">待實現:</h3>
|
||||
<ul>
|
||||
<li>⚠️ API 接口定義</li>
|
||||
<li>⚠️ 頁面實現</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
191
apps/web-antd/src/views/iot/product/category/data.ts
Normal file
191
apps/web-antd/src/views/iot/product/category/data.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import {
|
||||
deleteProductCategory,
|
||||
getProductCategoryPage,
|
||||
getSimpleProductCategoryList
|
||||
} from '#/api/iot/product/category';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
/** 新增/修改产品分类的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分类名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分类名称不能为空')
|
||||
.max(64, '分类名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'parentId',
|
||||
label: '父级分类',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductCategoryList,
|
||||
fieldNames: {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
},
|
||||
placeholder: '请选择父级分类',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '开启', value: 1 },
|
||||
{ label: '关闭', value: 0 },
|
||||
],
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分类名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
placeholder: ['开始日期', '结束日期'],
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'seq',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '名字',
|
||||
minWidth: 200,
|
||||
treeNode: true,
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '排序',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 删除分类 */
|
||||
export async function handleDeleteCategory(row: IotProductCategoryApi.ProductCategory, onSuccess?: () => void) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteProductCategory(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询分类列表 */
|
||||
export async function queryProductCategoryList({ page }: any, formValues: any) {
|
||||
const data = await getProductCategoryPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
// 转换为树形结构
|
||||
return {
|
||||
...data,
|
||||
list: handleTree(data.list, 'id', 'parentId'),
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import {
|
||||
handleDeleteCategory,
|
||||
queryProductCategoryList,
|
||||
useGridColumns,
|
||||
useGridFormSchema
|
||||
} from './data';
|
||||
import Form from './modules/ProductCategoryForm.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductCategory' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建分类 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑分类 */
|
||||
function handleEdit(row: IotProductCategoryApi.ProductCategory) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除分类 */
|
||||
async function handleDelete(row: IotProductCategoryApi.ProductCategory) {
|
||||
await handleDeleteCategory(row, handleRefresh);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
collapsed: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: queryProductCategoryList,
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search:true,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parentId',
|
||||
rowField: 'id',
|
||||
transform: true,
|
||||
expandAll: true,
|
||||
reserve: true,
|
||||
trigger: 'default',
|
||||
iconOpen: '',
|
||||
iconClose: '',
|
||||
},
|
||||
} as VxeTableGridOptions<IotProductCategoryApi.ProductCategory>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/category/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/category/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['分类']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createProductCategory,
|
||||
getProductCategory,
|
||||
updateProductCategory,
|
||||
} from '#/api/iot/product/category';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<IotProductCategoryApi.ProductCategory>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['产品分类'])
|
||||
: $t('ui.actionTitle.create', ['产品分类']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as IotProductCategoryApi.ProductCategory;
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateProductCategory(data)
|
||||
: createProductCategory(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
let data = modalApi.getData<
|
||||
IotProductCategoryApi.ProductCategory & { parentId?: number }
|
||||
>();
|
||||
if (!data) {
|
||||
// 新增模式:设置默认值
|
||||
await formApi.setValues({
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
if (data.id) {
|
||||
// 编辑模式:加载完整数据
|
||||
data = await getProductCategory(data.id);
|
||||
} else if (data.parentId) {
|
||||
// 新增下级分类:设置父级ID
|
||||
await formApi.setValues({
|
||||
parentId: data.parentId,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 设置到 values
|
||||
formData.value = data;
|
||||
await formApi.setValues(data);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
267
apps/web-antd/src/views/iot/product/product/data.ts
Normal file
267
apps/web-antd/src/views/iot/product/product/data.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
|
||||
import {
|
||||
deleteProduct,
|
||||
exportProduct,
|
||||
getProductPage
|
||||
} from '#/api/iot/product/product';
|
||||
|
||||
/** 新增/修改产品的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '产品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '产品名称不能为空')
|
||||
.max(64, '产品名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '产品分类',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductCategoryList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品分类',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceType',
|
||||
label: '设备类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'netType',
|
||||
label: '联网方式',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_NET_TYPE, 'number'),
|
||||
placeholder: '请选择联网方式',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'protocolType',
|
||||
label: '接入协议',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE, 'number'),
|
||||
placeholder: '请选择接入协议',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'dataFormat',
|
||||
label: '数据格式',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DATA_FORMAT, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '产品描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'validateType',
|
||||
label: '认证方式',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '产品状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '产品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productKey',
|
||||
label: 'ProductKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品标识',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'productKey',
|
||||
title: 'ProductKey',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'categoryId',
|
||||
title: '品类',
|
||||
minWidth: 120,
|
||||
slots: { default: 'category' },
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'icon',
|
||||
title: '产品图标',
|
||||
width: 100,
|
||||
slots: { default: 'icon' },
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '产品图片',
|
||||
width: 100,
|
||||
slots: { default: 'picUrl' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 加载产品分类列表 */
|
||||
export async function loadCategoryList() {
|
||||
return await getSimpleProductCategoryList();
|
||||
}
|
||||
|
||||
/** 获取分类名称 */
|
||||
export function getCategoryName(categoryList: any[], categoryId: number) {
|
||||
const category = categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
export async function handleDeleteProduct(row: any, onSuccess?: () => void) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除 ${row.name}...`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteProduct(row.id);
|
||||
message.success(`删除 ${row.name} 成功`);
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出产品 */
|
||||
export async function handleExportProduct(searchParams: any) {
|
||||
const data = await exportProduct(searchParams);
|
||||
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
|
||||
}
|
||||
|
||||
/** 查询产品列表 */
|
||||
export async function queryProductList({ page }: any, searchParams: any) {
|
||||
return await getProductPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams,
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建图片预览状态 */
|
||||
export function useImagePreview() {
|
||||
const previewVisible = ref(false);
|
||||
const previewImage = ref('');
|
||||
|
||||
function handlePreviewImage(url: string) {
|
||||
previewImage.value = url;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
previewVisible,
|
||||
previewImage,
|
||||
handlePreviewImage,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,340 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Button, Card, Image, Input, Space } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import ProductForm from './modules/ProductForm.vue';
|
||||
// @ts-ignore
|
||||
import ProductCardView from './modules/ProductCardView.vue';
|
||||
import {
|
||||
getCategoryName,
|
||||
handleDeleteProduct,
|
||||
handleExportProduct,
|
||||
loadCategoryList,
|
||||
queryProductList,
|
||||
useGridColumns,
|
||||
useImagePreview,
|
||||
} from './data';
|
||||
|
||||
defineOptions({ name: 'IoTProduct' });
|
||||
|
||||
const router = useRouter();
|
||||
const categoryList = ref<any[]>([]);
|
||||
const viewMode = ref<'list' | 'card'>('card');
|
||||
const cardViewRef = ref();
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({
|
||||
name: '',
|
||||
productKey: '',
|
||||
});
|
||||
|
||||
// 图片预览
|
||||
const { previewVisible, previewImage, handlePreviewImage } = useImagePreview();
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: ProductForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 加载产品分类列表
|
||||
const loadCategories = async () => {
|
||||
categoryList.value = await loadCategoryList();
|
||||
};
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryNameByValue = (categoryId: number) => {
|
||||
return getCategoryName(categoryList.value, categoryId);
|
||||
};
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
searchParams.value.name = '';
|
||||
searchParams.value.productKey = '';
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
await handleExportProduct(searchParams.value);
|
||||
}
|
||||
|
||||
/** 打开产品详情 */
|
||||
function openProductDetail(productId: number) {
|
||||
router.push({
|
||||
name: 'IoTProductDetail',
|
||||
params: { id: productId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开物模型管理 */
|
||||
function openThingModel(productId: number) {
|
||||
router.push({
|
||||
name: 'IoTProductDetail',
|
||||
params: { id: productId },
|
||||
query: { tab: 'thingModel' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增产品 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑产品 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
async function handleDelete(row: any) {
|
||||
await handleDeleteProduct(row, handleRefresh);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [],
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: ({ page }) => queryProductList({ page }, searchParams.value),
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/product/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/product/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 统一搜索工具栏 -->
|
||||
<Card :body-style="{ padding: '16px' }" class="mb-4">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<Input
|
||||
v-model:value="searchParams.name"
|
||||
placeholder="请输入产品名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-gray-400">产品名称</span>
|
||||
</template>
|
||||
</Input>
|
||||
<Input
|
||||
v-model:value="searchParams.productKey"
|
||||
placeholder="请输入产品标识"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-gray-400">ProductKey</span>
|
||||
</template>
|
||||
</Input>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Space :size="12">
|
||||
<Button type="primary" @click="handleCreate">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增产品
|
||||
</Button>
|
||||
<Button type="primary" @click="handleExport">
|
||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
<Button
|
||||
:type="viewMode === 'card' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'card'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:appstore-outlined" />
|
||||
</Button>
|
||||
<Button
|
||||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:unordered-list-outlined" />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<template #toolbar-tools>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<!-- 产品分类列 -->
|
||||
<template #category="{ row }">
|
||||
<span>{{ getCategoryNameByValue(row.categoryId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 产品图标列 -->
|
||||
<template #icon="{ row }">
|
||||
<Button
|
||||
v-if="row.icon"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreviewImage(row.icon)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="text-lg" />
|
||||
</Button>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 产品图片列 -->
|
||||
<template #picUrl="{ row }">
|
||||
<Button
|
||||
v-if="row.picUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreviewImage(row.picUrl)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="text-lg" />
|
||||
</Button>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: openProductDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '物模型',
|
||||
type: 'link',
|
||||
onClick: openThingModel.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: `确认删除产品 ${row.name} 吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<ProductCardView
|
||||
v-show="viewMode === 'card'"
|
||||
ref="cardViewRef"
|
||||
:category-list="categoryList"
|
||||
:search-params="searchParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@detail="openProductDetail"
|
||||
@thing-model="openThingModel"
|
||||
/>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<div style="display: none">
|
||||
<Image.PreviewGroup
|
||||
:preview="{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (visible) => (previewVisible = visible),
|
||||
}"
|
||||
>
|
||||
<Image :src="previewImage" />
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
:deep(.vxe-toolbar div) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 隐藏 VxeGrid 自带的搜索表单区域 */
|
||||
:deep(.vxe-grid--form-wrapper) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 控制图片预览的大小 */
|
||||
.ant-image-preview-img {
|
||||
max-width: 80% !important;
|
||||
max-height: 80% !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.ant-image-preview-operations {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
|
||||
defineOptions({ name: 'ProductCardView' });
|
||||
|
||||
interface Props {
|
||||
categoryList: any[];
|
||||
searchParams?: {
|
||||
name: string;
|
||||
productKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [row: any];
|
||||
delete: [row: any];
|
||||
detail: [productId: number];
|
||||
thingModel: [productId: number];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (categoryId: number) => {
|
||||
const category = props.categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
};
|
||||
|
||||
// 获取产品列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getProductPage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
};
|
||||
|
||||
// 获取设备类型颜色
|
||||
const getDeviceTypeColor = (deviceType: number) => {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'green',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-card-view">
|
||||
<!-- 产品卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: '20px' }"
|
||||
class="product-card h-full"
|
||||
>
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="product-icon">
|
||||
<IconifyIcon icon="ant-design:inbox-outlined" class="text-[32px]" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1 min-w-0">
|
||||
<div class="product-title">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="flex-1 info-list">
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品分类</span>
|
||||
<span class="info-value text-primary">{{ getCategoryName(item.categoryId) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品类型</span>
|
||||
<Tag :color="getDeviceTypeColor(item.deviceType)" class="m-0 info-tag">
|
||||
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品标识</span>
|
||||
<Tooltip :title="item.productKey || item.id" placement="top">
|
||||
<span class="info-value product-key">{{ item.productKey || item.id }}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-3d-icon">
|
||||
<IconifyIcon icon="ant-design:box-plot-outlined" class="text-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-edit"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:edit-outlined" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-detail"
|
||||
@click="emit('detail', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-model"
|
||||
@click="emit('thingModel', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:apartment-outlined" class="mr-1" />
|
||||
物模型
|
||||
</Button>
|
||||
<Popconfirm
|
||||
:title="`确认删除产品 ${item.name} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="action-btn action-btn-delete"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:delete-outlined" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无产品数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-card-view {
|
||||
.product-card {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: #d9d9d9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 产品图标
|
||||
.product-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 产品标题
|
||||
.product-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #6b7280;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.product-key {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D 图标
|
||||
.product-3d-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: auto;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.action-btn-edit {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-detail {
|
||||
color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
|
||||
&:hover {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-model {
|
||||
color: #722ed1;
|
||||
border-color: #722ed1;
|
||||
|
||||
&:hover {
|
||||
background: #722ed1;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { createProduct, getProduct, updateProduct, type IotProductApi } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTProductForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑产品' : '新增产品';
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IotProductApi.Product;
|
||||
try {
|
||||
await (formData.value?.id ? updateProduct(data) : createProduct(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
if (!data || !data.id) {
|
||||
// 设置默认值
|
||||
await formApi.setValues({
|
||||
deviceType: 0, // 默认直连设备
|
||||
dataFormat: 1, // 默认 JSON
|
||||
validateType: 1, // 默认设备密钥
|
||||
status: 0, // 默认启用
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getProduct(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<!-- IoT 产品选择器,使用弹窗展示 -->
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
defineOptions({ name: 'IoTProductTableSelect' });
|
||||
|
||||
interface Props {
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [product: IotProductApi.Product | IotProductApi.Product[]];
|
||||
}>();
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '产品选择器',
|
||||
onConfirm: handleConfirm,
|
||||
});
|
||||
|
||||
const selectedProducts = ref<IotProductApi.Product[]>([]);
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
|
||||
// 搜索参数
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
productKey: '',
|
||||
});
|
||||
|
||||
// 配置表格
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{
|
||||
type: props.multiple ? 'checkbox' : 'radio',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '产品名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'productKey',
|
||||
title: 'ProductKey',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'categoryName',
|
||||
title: '品类',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: 'iot_product_device_type' },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
],
|
||||
checkboxConfig: {
|
||||
reserve: true,
|
||||
highlight: true,
|
||||
},
|
||||
radioConfig: {
|
||||
reserve: true,
|
||||
highlight: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any) => {
|
||||
return await getProductPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...queryParams,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 打开选择器
|
||||
const open = async () => {
|
||||
selectedProducts.value = [];
|
||||
selectedRowKeys.value = [];
|
||||
modalApi.open();
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
queryParams.name = '';
|
||||
queryParams.productKey = '';
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
async function handleConfirm() {
|
||||
const grid = gridApi.grid;
|
||||
if (!grid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
const checkboxRecords = grid.getCheckboxRecords();
|
||||
if (checkboxRecords.length === 0) {
|
||||
message.warning('请至少选择一个产品');
|
||||
return false;
|
||||
}
|
||||
emit('success', checkboxRecords);
|
||||
} else {
|
||||
const radioRecord = grid.getRadioRecord();
|
||||
if (!radioRecord) {
|
||||
message.warning('请选择一个产品');
|
||||
return false;
|
||||
}
|
||||
emit('success', radioRecord);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="!w-[900px]">
|
||||
<div class="mb-4">
|
||||
<a-form layout="inline" :model="queryParams">
|
||||
<a-form-item label="产品名称">
|
||||
<a-input
|
||||
v-model:value="queryParams.name"
|
||||
placeholder="请输入产品名称"
|
||||
allow-clear
|
||||
class="!w-[200px]"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="ProductKey">
|
||||
<a-input
|
||||
v-model:value="queryParams.productKey"
|
||||
placeholder="请输入产品标识"
|
||||
allow-clear
|
||||
class="!w-[200px]"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="ml-2" @click="handleReset">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<Grid />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { updateProductStatus } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import ProductForm from '../ProductForm.vue';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const formRef = ref();
|
||||
|
||||
/** 复制到剪贴板 */
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 跳转到设备管理 */
|
||||
const goToDeviceList = (productId: number) => {
|
||||
router.push({ path: '/iot/device/device', query: { productId: String(productId) } });
|
||||
};
|
||||
|
||||
/** 打开编辑表单 */
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value?.open(type, id);
|
||||
};
|
||||
|
||||
/** 发布产品 */
|
||||
const confirmPublish = async (id: number) => {
|
||||
try {
|
||||
await updateProductStatus(id, 1);
|
||||
message.success('发布成功');
|
||||
emit('refresh');
|
||||
} catch {
|
||||
message.error('发布失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 撤销发布 */
|
||||
const confirmUnpublish = async (id: number) => {
|
||||
try {
|
||||
await updateProductStatus(id, 0);
|
||||
message.success('撤销发布成功');
|
||||
emit('refresh');
|
||||
} catch {
|
||||
message.error('撤销发布失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ product.name }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<a-button
|
||||
:disabled="product.status === 1"
|
||||
@click="openForm('update', product.id)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product.status === 0"
|
||||
type="primary"
|
||||
@click="confirmPublish(product.id!)"
|
||||
>
|
||||
发布
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product.status === 1"
|
||||
danger
|
||||
@click="confirmUnpublish(product.id!)"
|
||||
>
|
||||
撤销发布
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="mt-4">
|
||||
<a-descriptions :column="1">
|
||||
<a-descriptions-item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
<a-button size="small" class="ml-2" @click="copyToClipboard(product.productKey || '')">
|
||||
复制
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备总数">
|
||||
<span class="ml-5 mr-2">{{ product.deviceCount ?? '加载中...' }}</span>
|
||||
<a-button size="small" @click="goToDeviceList(product.id!)">
|
||||
前往管理
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<ProductForm ref="formRef" @success="emit('refresh')" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import { DeviceTypeEnum } from '#/api/iot/product/product';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (date?: Date | string) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('zh-CN');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="产品信息">
|
||||
<a-descriptions bordered :column="3">
|
||||
<a-descriptions-item label="产品名称">
|
||||
{{ product.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属分类">
|
||||
{{ product.categoryName || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
<DictTag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="定位类型">
|
||||
{{ product.locationType ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(product.createTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="数据格式">
|
||||
{{ product.codecType || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="产品状态">
|
||||
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(product.deviceType!)"
|
||||
label="联网方式"
|
||||
>
|
||||
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="产品描述" :span="3">
|
||||
{{ product.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { getProduct } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import { getDeviceCount } from '#/api/iot/device/device';
|
||||
|
||||
import ProductDetailsHeader from './ProductDetailsHeader.vue';
|
||||
import ProductDetailsInfo from './ProductDetailsInfo.vue';
|
||||
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = Number(route.params.id);
|
||||
const loading = ref(true);
|
||||
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
||||
const activeTab = ref('info');
|
||||
|
||||
// 提供产品信息给子组件
|
||||
provide('product', product);
|
||||
|
||||
/** 获取产品详情 */
|
||||
const getProductData = async (productId: number) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
product.value = await getProduct(productId);
|
||||
} catch {
|
||||
message.error('获取产品详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询设备数量 */
|
||||
const getDeviceCountData = async (productId: number) => {
|
||||
try {
|
||||
return await getDeviceCount(productId);
|
||||
} catch (error) {
|
||||
console.error('Error fetching device count:', error, 'productId:', productId);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
if (!id) {
|
||||
message.warning('参数错误,产品不能为空!');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await getProductData(id);
|
||||
|
||||
// 处理 tab 参数
|
||||
const { tab } = route.query;
|
||||
if (tab) {
|
||||
activeTab.value = tab as string;
|
||||
}
|
||||
|
||||
// 查询设备数量
|
||||
if (product.value.id) {
|
||||
product.value.deviceCount = await getDeviceCountData(product.value.id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<ProductDetailsHeader
|
||||
:loading="loading"
|
||||
:product="product"
|
||||
@refresh="() => getProductData(id)"
|
||||
/>
|
||||
|
||||
<a-tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<a-tab-pane key="info" tab="产品信息">
|
||||
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="thingModel" tab="物模型(功能定义)">
|
||||
<IoTProductThingModel v-if="activeTab === 'thingModel'" :product-id="id" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</Page>
|
||||
</template>
|
||||
105
apps/web-antd/src/views/iot/rule/data/data.ts
Normal file
105
apps/web-antd/src/views/iot/rule/data/data.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '规则状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '规则编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '规则名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '所属产品',
|
||||
minWidth: 150,
|
||||
slots: { default: 'product' },
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '规则描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '规则状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sinkCount',
|
||||
title: '数据流转数',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
129
apps/web-antd/src/views/iot/rule/data/index.vue
Normal file
129
apps/web-antd/src/views/iot/rule/data/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDataRule, getDataRulePage } from '#/api/iot/rule/data/rule';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DataRuleForm from './rule/DataRuleForm.vue';
|
||||
|
||||
/** IoT 数据流转规则列表 */
|
||||
defineOptions({ name: 'IoTDataRule' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DataRuleForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建规则 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 编辑规则 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData({ type: 'update', id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 删除规则 */
|
||||
async function handleDelete(row: any) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDataRule(row.id);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDataRulePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="数据规则列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['规则']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:data-rule:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:data-rule:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:data-rule:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
117
apps/web-antd/src/views/iot/rule/data/rule/DataRuleForm.vue
Normal file
117
apps/web-antd/src/views/iot/rule/data/rule/DataRuleForm.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createDataRule,
|
||||
getDataRule,
|
||||
updateDataRule,
|
||||
} from '#/api/iot/rule/data/rule';
|
||||
import { getDataSinkSimpleList } from '#/api/iot/rule/data/sink';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useRuleFormSchema } from './data';
|
||||
import SourceConfigForm from './components/SourceConfigForm.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const sourceConfigRef = ref();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['数据规则'])
|
||||
: $t('ui.actionTitle.create', ['数据规则']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useRuleFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验数据源配置
|
||||
await sourceConfigRef.value?.validate();
|
||||
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as any;
|
||||
data.sourceConfigs = sourceConfigRef.value?.getData() || [];
|
||||
|
||||
try {
|
||||
await (formData.value?.id ? updateDataRule(data) : createDataRule(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
sourceConfigRef.value?.setData([]);
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
|
||||
// 加载数据目的列表
|
||||
const sinkList = await getDataSinkSimpleList();
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'sinkIds',
|
||||
componentProps: {
|
||||
options: sinkList.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDataRule(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
// 设置数据源配置
|
||||
await nextTick();
|
||||
sourceConfigRef.value?.setData(formData.value.sourceConfigs || []);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-4/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
<div class="mx-4 mt-4">
|
||||
<div class="text-sm font-medium mb-2">数据源配置</div>
|
||||
<SourceConfigForm ref="sourceConfigRef" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Table, Select, Button, Form, FormItem } from 'ant-design-vue';
|
||||
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
||||
import {
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '#/views/iot/utils/constants';
|
||||
|
||||
const formData = ref<any[]>([]);
|
||||
const productList = ref<any[]>([]); // 产品列表
|
||||
const deviceList = ref<any[]>([]); // 设备列表
|
||||
const thingModelCache = ref<Map<number, any[]>>(new Map()); // 缓存物模型数据,key 为 productId
|
||||
|
||||
const formRules: any = reactive({
|
||||
productId: [{ required: true, message: '产品不能为空', trigger: 'change' }],
|
||||
deviceId: [{ required: true, message: '设备不能为空', trigger: 'change' }],
|
||||
method: [{ required: true, message: '消息方法不能为空', trigger: 'change' }],
|
||||
});
|
||||
const formRef = ref(); // 表单 Ref
|
||||
|
||||
// 获取上行消息方法列表
|
||||
const upstreamMethods = computed(() => {
|
||||
return Object.values(IotDeviceMessageMethodEnum).filter((item) => item.upstream);
|
||||
});
|
||||
|
||||
/** 根据产品 ID 过滤设备 */
|
||||
const getFilteredDevices = (productId: number) => {
|
||||
if (!productId) return [];
|
||||
return deviceList.value.filter((device: any) => device.productId === productId);
|
||||
};
|
||||
|
||||
/** 判断是否需要显示标识符选择器 */
|
||||
const shouldShowIdentifierSelect = (row: any) => {
|
||||
return [
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.method,
|
||||
].includes(row.method);
|
||||
};
|
||||
|
||||
/** 获取物模型选项 */
|
||||
const getThingModelOptions = (row: any) => {
|
||||
if (!row.productId || !shouldShowIdentifierSelect(row)) {
|
||||
return [];
|
||||
}
|
||||
const thingModels: any[] = thingModelCache.value.get(row.productId) || [];
|
||||
let filteredModels: any[] = [];
|
||||
if (row.method === IotDeviceMessageMethodEnum.EVENT_POST.method) {
|
||||
filteredModels = thingModels.filter(
|
||||
(item: any) => item.type === IoTThingModelTypeEnum.EVENT,
|
||||
);
|
||||
} else if (row.method === IotDeviceMessageMethodEnum.PROPERTY_POST.method) {
|
||||
filteredModels = thingModels.filter(
|
||||
(item: any) => item.type === IoTThingModelTypeEnum.PROPERTY,
|
||||
);
|
||||
}
|
||||
return filteredModels.map((item: any) => ({
|
||||
label: `${item.name} (${item.identifier})`,
|
||||
value: item.identifier,
|
||||
}));
|
||||
};
|
||||
|
||||
/** 加载产品列表 */
|
||||
const loadProductList = async () => {
|
||||
try {
|
||||
productList.value = await getSimpleProductList();
|
||||
} catch (error) {
|
||||
console.error('加载产品列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载设备列表 */
|
||||
const loadDeviceList = async () => {
|
||||
try {
|
||||
deviceList.value = await getSimpleDeviceList();
|
||||
} catch (error) {
|
||||
console.error('加载设备列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载物模型数据 */
|
||||
const loadThingModel = async (productId: number) => {
|
||||
// 已缓存,无需重复加载
|
||||
if (thingModelCache.value.has(productId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const thingModels = await getThingModelListByProductId(productId);
|
||||
thingModelCache.value.set(productId, thingModels);
|
||||
} catch (error) {
|
||||
console.error('加载物模型失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 产品变化时处理 */
|
||||
const handleProductChange = async (row: any, _index: number) => {
|
||||
row.deviceId = 0;
|
||||
row.method = undefined;
|
||||
row.identifier = undefined;
|
||||
row.identifierLoading = false;
|
||||
};
|
||||
|
||||
/** 消息方法变化时处理 */
|
||||
const handleMethodChange = async (row: any, _index: number) => {
|
||||
// 清空标识符
|
||||
row.identifier = undefined;
|
||||
// 如果需要加载物模型数据
|
||||
if (shouldShowIdentifierSelect(row) && row.productId) {
|
||||
row.identifierLoading = true;
|
||||
await loadThingModel(row.productId);
|
||||
row.identifierLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = () => {
|
||||
const row = {
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
method: undefined,
|
||||
identifier: undefined,
|
||||
identifierLoading: false,
|
||||
};
|
||||
formData.value.push(row);
|
||||
};
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = (index: number) => {
|
||||
formData.value.splice(index, 1);
|
||||
};
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = () => {
|
||||
return formRef.value.validate();
|
||||
};
|
||||
|
||||
/** 表单值 */
|
||||
const getData = () => {
|
||||
return formData.value;
|
||||
};
|
||||
|
||||
/** 设置表单值 */
|
||||
const setData = (data: any[]) => {
|
||||
// 确保每个项都有必要的字段
|
||||
formData.value = (data || []).map((item) => ({
|
||||
...item,
|
||||
identifierLoading: false,
|
||||
}));
|
||||
// 为已有数据预加载物模型
|
||||
data?.forEach(async (item) => {
|
||||
if (item.productId && shouldShowIdentifierSelect(item)) {
|
||||
await loadThingModel(item.productId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProductList(), loadDeviceList()]);
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '产品',
|
||||
dataIndex: 'productId',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'deviceId',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'method',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '标识符',
|
||||
dataIndex: 'identifier',
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
},
|
||||
];
|
||||
|
||||
defineExpose({ validate, getData, setData });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form ref="formRef" :model="{ data: formData }">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="formData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'productId'">
|
||||
<FormItem
|
||||
:name="['data', index, 'productId']"
|
||||
:rules="formRules.productId"
|
||||
class="mb-0"
|
||||
>
|
||||
<Select
|
||||
v-model:value="record.productId"
|
||||
placeholder="请选择产品"
|
||||
show-search
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="
|
||||
productList.map((p: any) => ({ label: p.name, value: p.id }))
|
||||
"
|
||||
@change="() => handleProductChange(record, index)"
|
||||
/>
|
||||
</FormItem>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'deviceId'">
|
||||
<FormItem
|
||||
:name="['data', index, 'deviceId']"
|
||||
:rules="formRules.deviceId"
|
||||
class="mb-0"
|
||||
>
|
||||
<Select
|
||||
v-model:value="record.deviceId"
|
||||
placeholder="请选择设备"
|
||||
show-search
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="[
|
||||
{ label: '全部设备', value: 0 },
|
||||
...getFilteredDevices(record.productId).map((d: any) => ({
|
||||
label: d.deviceName,
|
||||
value: d.id,
|
||||
})),
|
||||
]"
|
||||
/>
|
||||
</FormItem>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'method'">
|
||||
<FormItem
|
||||
:name="['data', index, 'method']"
|
||||
:rules="formRules.method"
|
||||
class="mb-0"
|
||||
>
|
||||
<Select
|
||||
v-model:value="record.method"
|
||||
placeholder="请选择消息"
|
||||
show-search
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="
|
||||
upstreamMethods.map((m: any) => ({
|
||||
label: m.name,
|
||||
value: m.method,
|
||||
}))
|
||||
"
|
||||
@change="() => handleMethodChange(record, index)"
|
||||
/>
|
||||
</FormItem>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'identifier'">
|
||||
<FormItem :name="['data', index, 'identifier']" class="mb-0">
|
||||
<Select
|
||||
v-if="shouldShowIdentifierSelect(record)"
|
||||
v-model:value="record.identifier"
|
||||
placeholder="请选择标识符"
|
||||
show-search
|
||||
:loading="record.identifierLoading"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="getThingModelOptions(record)"
|
||||
/>
|
||||
</FormItem>
|
||||
</template>
|
||||
<template v-else-if="column.title === '操作'">
|
||||
<Button type="link" danger @click="handleDelete(index)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="mt-3 text-center">
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<Icon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
148
apps/web-antd/src/views/iot/rule/data/rule/data.ts
Normal file
148
apps/web-antd/src/views/iot/rule/data/rule/data.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '规则状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 规则表单 Schema */
|
||||
export function useRuleFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '规则描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '规则状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
},
|
||||
defaultValue: 0,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'sinkIds',
|
||||
label: '数据目的',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择数据目的',
|
||||
mode: 'multiple',
|
||||
allowClear: true,
|
||||
options: [],
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '规则编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '规则名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '规则描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '规则状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sourceConfigs',
|
||||
title: '数据源',
|
||||
minWidth: 100,
|
||||
formatter: ({ cellValue }: any) => `${cellValue?.length || 0} 个`,
|
||||
},
|
||||
{
|
||||
field: 'sinkIds',
|
||||
title: '数据目的',
|
||||
minWidth: 100,
|
||||
formatter: ({ cellValue }: any) => `${cellValue?.length || 0} 个`,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
129
apps/web-antd/src/views/iot/rule/data/rule/index.vue
Normal file
129
apps/web-antd/src/views/iot/rule/data/rule/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDataRule, getDataRulePage } from '#/api/iot/rule/data/rule';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DataRuleForm from './DataRuleForm.vue';
|
||||
|
||||
/** IoT 数据流转规则列表 */
|
||||
defineOptions({ name: 'IotDataRule' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DataRuleForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建规则 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 编辑规则 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData({ type: 'update', id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 删除规则 */
|
||||
async function handleDelete(row: any) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDataRule(row.id);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDataRulePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="数据规则列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['规则']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:data-rule:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:data-rule:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:data-rule:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
148
apps/web-antd/src/views/iot/rule/data/sink/DataSinkForm.vue
Normal file
148
apps/web-antd/src/views/iot/rule/data/sink/DataSinkForm.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createDataSink,
|
||||
getDataSink,
|
||||
updateDataSink,
|
||||
} from '#/api/iot/rule/data';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useSinkFormSchema } from './data';
|
||||
import {
|
||||
HttpConfigForm,
|
||||
KafkaMQConfigForm,
|
||||
MqttConfigForm,
|
||||
RabbitMQConfigForm,
|
||||
RedisStreamConfigForm,
|
||||
RocketMQConfigForm,
|
||||
} from './config';
|
||||
|
||||
const IotDataSinkTypeEnum = {
|
||||
HTTP: 1,
|
||||
MQTT: 2,
|
||||
ROCKETMQ: 3,
|
||||
KAFKA: 4,
|
||||
RABBITMQ: 5,
|
||||
REDIS_STREAM: 6,
|
||||
} as const;
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['数据目的'])
|
||||
: $t('ui.actionTitle.create', ['数据目的']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useSinkFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as any;
|
||||
data.config = formData.value.config;
|
||||
|
||||
try {
|
||||
await (formData.value?.id ? updateDataSink(data) : createDataSink(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
|
||||
if (!data || !data.id) {
|
||||
formData.value = {
|
||||
type: IotDataSinkTypeEnum.HTTP,
|
||||
status: 0,
|
||||
config: {},
|
||||
};
|
||||
await formApi.setValues(formData.value);
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDataSink(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 监听类型变化,重置配置
|
||||
watch(
|
||||
() => formApi.form.type,
|
||||
(newType) => {
|
||||
if (formData.value && newType !== formData.value.type) {
|
||||
formData.value.config = {};
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
<div v-if="formData" class="mx-4 mt-4">
|
||||
<div class="text-sm font-medium mb-2">配置信息</div>
|
||||
<HttpConfigForm
|
||||
v-if="IotDataSinkTypeEnum.HTTP === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
<MqttConfigForm
|
||||
v-if="IotDataSinkTypeEnum.MQTT === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
<RocketMQConfigForm
|
||||
v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
<KafkaMQConfigForm
|
||||
v-if="IotDataSinkTypeEnum.KAFKA === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
<RabbitMQConfigForm
|
||||
v-if="IotDataSinkTypeEnum.RABBITMQ === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
<RedisStreamConfigForm
|
||||
v-if="IotDataSinkTypeEnum.REDIS_STREAM === formData.type"
|
||||
v-model="formData.config"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, Select, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import KeyValueEditor from './components/KeyValueEditor.vue';
|
||||
|
||||
defineOptions({ name: 'HttpConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
// noinspection HttpUrlsUsage
|
||||
/** URL处理 */
|
||||
const urlPrefix = ref('http://');
|
||||
const urlPath = ref('');
|
||||
const fullUrl = computed(() => {
|
||||
return urlPath.value ? urlPrefix.value + urlPath.value : '';
|
||||
});
|
||||
|
||||
/** 监听 URL 变化 */
|
||||
watch([urlPrefix, urlPath], () => {
|
||||
config.value.url = fullUrl.value;
|
||||
});
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
// 初始化 URL
|
||||
if (config.value.url) {
|
||||
if (config.value.url.startsWith('https://')) {
|
||||
urlPrefix.value = 'https://';
|
||||
urlPath.value = config.value.url.substring(8);
|
||||
} else if (config.value.url.startsWith('http://')) {
|
||||
urlPrefix.value = 'http://';
|
||||
urlPath.value = config.value.url.substring(7);
|
||||
} else {
|
||||
urlPath.value = config.value.url;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
config.value = {
|
||||
url: '',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
query: {},
|
||||
body: '',
|
||||
};
|
||||
});
|
||||
|
||||
const methodOptions = [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="请求地址" required>
|
||||
<Input v-model:value="urlPath" placeholder="请输入请求地址">
|
||||
<template #addonBefore>
|
||||
<Select
|
||||
v-model:value="urlPrefix"
|
||||
placeholder="Select"
|
||||
style="width: 115px"
|
||||
:options="[
|
||||
{ label: 'http://', value: 'http://' },
|
||||
{ label: 'https://', value: 'https://' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</FormItem>
|
||||
<FormItem label="请求方法" required>
|
||||
<Select
|
||||
v-model:value="config.method"
|
||||
placeholder="请选择请求方法"
|
||||
:options="methodOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="请求头">
|
||||
<KeyValueEditor v-model="config.headers" add-button-text="添加请求头" />
|
||||
</FormItem>
|
||||
<FormItem label="请求参数">
|
||||
<KeyValueEditor v-model="config.query" add-button-text="添加参数" />
|
||||
</FormItem>
|
||||
<FormItem label="请求体">
|
||||
<Input.TextArea
|
||||
v-model:value="config.body"
|
||||
placeholder="请输入内容"
|
||||
:rows="3"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, Switch, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
defineOptions({ name: 'KafkaMQConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
bootstrapServers: '',
|
||||
username: '',
|
||||
password: '',
|
||||
ssl: false,
|
||||
topic: '',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="服务地址" required>
|
||||
<Input
|
||||
v-model:value="config.bootstrapServers"
|
||||
placeholder="请输入服务地址,如:localhost:9092"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="用户名">
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</FormItem>
|
||||
<FormItem label="密码">
|
||||
<Input.Password
|
||||
v-model:value="config.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="启用 SSL" required>
|
||||
<Switch v-model:checked="config.ssl" />
|
||||
</FormItem>
|
||||
<FormItem label="主题" required>
|
||||
<Input v-model:value="config.topic" placeholder="请输入主题" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
defineOptions({ name: 'MqttConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
clientId: '',
|
||||
topic: '',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="服务地址" required>
|
||||
<Input
|
||||
v-model:value="config.url"
|
||||
placeholder="请输入MQTT服务地址,如:mqtt://localhost:1883"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="用户名" required>
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</FormItem>
|
||||
<FormItem label="密码" required>
|
||||
<Input.Password
|
||||
v-model:value="config.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="客户端ID" required>
|
||||
<Input v-model:value="config.clientId" placeholder="请输入客户端ID" />
|
||||
</FormItem>
|
||||
<FormItem label="主题" required>
|
||||
<Input v-model:value="config.topic" placeholder="请输入主题" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, InputNumber, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
defineOptions({ name: 'RabbitMQConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
host: '',
|
||||
port: 5672,
|
||||
username: '',
|
||||
password: '',
|
||||
virtualHost: '/',
|
||||
exchange: '',
|
||||
routingKey: '',
|
||||
queue: '',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="主机地址" required>
|
||||
<Input
|
||||
v-model:value="config.host"
|
||||
placeholder="请输入主机地址,如:localhost"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="端口" required>
|
||||
<InputNumber
|
||||
v-model:value="config.port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
placeholder="请输入端口,如:5672"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="用户名" required>
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</FormItem>
|
||||
<FormItem label="密码" required>
|
||||
<Input.Password
|
||||
v-model:value="config.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="虚拟主机" required>
|
||||
<Input
|
||||
v-model:value="config.virtualHost"
|
||||
placeholder="请输入虚拟主机,如:/"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="交换机" required>
|
||||
<Input v-model:value="config.exchange" placeholder="请输入交换机名称" />
|
||||
</FormItem>
|
||||
<FormItem label="路由键" required>
|
||||
<Input v-model:value="config.routingKey" placeholder="请输入路由键" />
|
||||
</FormItem>
|
||||
<FormItem label="队列" required>
|
||||
<Input v-model:value="config.queue" placeholder="请输入队列名称" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, InputNumber, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
defineOptions({ name: 'RedisStreamConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
url: '',
|
||||
password: '',
|
||||
database: 0,
|
||||
streamKey: '',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="服务地址" required>
|
||||
<Input
|
||||
v-model:value="config.url"
|
||||
placeholder="请输入Redis服务地址,如:redis://127.0.0.1:6379"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码">
|
||||
<Input.Password
|
||||
v-model:value="config.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="数据库索引" required>
|
||||
<InputNumber
|
||||
v-model:value="config.database"
|
||||
:min="0"
|
||||
:max="15"
|
||||
placeholder="请输入数据库索引"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Stream Key" required>
|
||||
<Input v-model:value="config.streamKey" placeholder="请输入Stream Key" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import { Input, FormItem } from 'ant-design-vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
defineOptions({ name: 'RocketMQConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as Ref<any>;
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
nameServer: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
group: '',
|
||||
topic: '',
|
||||
tags: '',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FormItem label="NameServer" required>
|
||||
<Input
|
||||
v-model:value="config.nameServer"
|
||||
placeholder="请输入 NameServer 地址,如:127.0.0.1:9876"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="AccessKey" required>
|
||||
<Input v-model:value="config.accessKey" placeholder="请输入 AccessKey" />
|
||||
</FormItem>
|
||||
<FormItem label="SecretKey" required>
|
||||
<Input.Password
|
||||
v-model:value="config.secretKey"
|
||||
placeholder="请输入 SecretKey"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="消费组" required>
|
||||
<Input v-model:value="config.group" placeholder="请输入消费组" />
|
||||
</FormItem>
|
||||
<FormItem label="主题" required>
|
||||
<Input v-model:value="config.topic" placeholder="请输入主题" />
|
||||
</FormItem>
|
||||
<FormItem label="标签">
|
||||
<Input v-model:value="config.tags" placeholder="请输入标签" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div v-for="(item, index) in items" :key="index" class="flex mb-2 w-full">
|
||||
<el-input v-model="item.key" class="mr-2" placeholder="键" />
|
||||
<el-input v-model="item.value" placeholder="值" />
|
||||
<el-button class="ml-2" text type="danger" @click="removeItem(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button text type="primary" @click="addItem">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
{{ addButtonText }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { isEmpty } from '@vben/utils'
|
||||
|
||||
defineOptions({ name: 'KeyValueEditor' })
|
||||
|
||||
interface KeyValueItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, string>
|
||||
addButtonText: string
|
||||
}>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const items = ref<KeyValueItem[]>([]) // 内部 key-value 项列表
|
||||
|
||||
/** 添加项目 */
|
||||
const addItem = () => {
|
||||
items.value.push({ key: '', value: '' })
|
||||
updateModelValue()
|
||||
}
|
||||
|
||||
/** 移除项目 */
|
||||
const removeItem = (index: number) => {
|
||||
items.value.splice(index, 1)
|
||||
updateModelValue()
|
||||
}
|
||||
|
||||
/** 更新 modelValue */
|
||||
const updateModelValue = () => {
|
||||
const result: Record<string, string> = {}
|
||||
items.value.forEach((item) => {
|
||||
if (item.key) {
|
||||
result[item.key] = item.value
|
||||
}
|
||||
})
|
||||
emit('update:modelValue', result)
|
||||
}
|
||||
|
||||
/** 监听项目变化 */
|
||||
watch(items, updateModelValue, { deep: true })
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
// 列表有值后以列表中的值为准
|
||||
if (isEmpty(val) || !isEmpty(items.value)) {
|
||||
return
|
||||
}
|
||||
items.value = Object.entries(props.modelValue).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
)
|
||||
</script>
|
||||
15
apps/web-antd/src/views/iot/rule/data/sink/config/index.ts
Normal file
15
apps/web-antd/src/views/iot/rule/data/sink/config/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import HttpConfigForm from './HttpConfigForm.vue'
|
||||
import MqttConfigForm from './MqttConfigForm.vue'
|
||||
import RocketMQConfigForm from './RocketMQConfigForm.vue'
|
||||
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
|
||||
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
|
||||
import RedisStreamConfigForm from './RedisStreamConfigForm.vue'
|
||||
|
||||
export {
|
||||
HttpConfigForm,
|
||||
MqttConfigForm,
|
||||
RocketMQConfigForm,
|
||||
KafkaMQConfigForm,
|
||||
RabbitMQConfigForm,
|
||||
RedisStreamConfigForm
|
||||
}
|
||||
153
apps/web-antd/src/views/iot/rule/data/sink/data.ts
Normal file
153
apps/web-antd/src/views/iot/rule/data/sink/data.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '目的名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入目的名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '目的状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '目的类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM, 'number'),
|
||||
placeholder: '请选择目的类型',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 目的表单 Schema */
|
||||
export function useSinkFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '目的名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入目的名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '目的描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入目的描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '目的类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM, 'number'),
|
||||
placeholder: '请选择目的类型',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '目的状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
},
|
||||
defaultValue: 0,
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '目的编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '目的名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '目的描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '目的状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '目的类型',
|
||||
minWidth: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
129
apps/web-antd/src/views/iot/rule/data/sink/index.vue
Normal file
129
apps/web-antd/src/views/iot/rule/data/sink/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDataSink, getDataSinkPage } from '#/api/iot/rule/data/sink';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DataSinkForm from './DataSinkForm.vue';
|
||||
|
||||
/** IoT 数据流转目的 列表 */
|
||||
defineOptions({ name: 'IotDataSink' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DataSinkForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建数据目的 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 编辑数据目的 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData({ type: 'update', id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 删除数据目的 */
|
||||
async function handleDelete(row: any) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDataSink(row.id);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDataSinkPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="数据目的列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['数据目的']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:data-sink:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:data-sink:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:data-sink:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -1,28 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'IotRuleDataBridge' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/rule/databridge/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/rule/databridge/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page
|
||||
description="物聯網規則引擎 - 數據橋接"
|
||||
title="數據橋接"
|
||||
>
|
||||
<div class="p-4">
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/tree/master/src/views/iot/rule"
|
||||
>
|
||||
該功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/rule/data/rule/index.vue"
|
||||
>
|
||||
可參考源代碼進行遷移!
|
||||
</Button>
|
||||
<div class="mt-4">
|
||||
<h3>功能說明:</h3>
|
||||
<p>規則引擎包括:</p>
|
||||
<ul>
|
||||
<li>數據規則配置</li>
|
||||
<li>數據轉發配置</li>
|
||||
<li>場景聯動配置</li>
|
||||
</ul>
|
||||
<h3 class="mt-4">待實現:</h3>
|
||||
<ul>
|
||||
<li>⚠️ API 接口定義</li>
|
||||
<li>⚠️ 頁面實現</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
136
apps/web-antd/src/views/iot/rule/scene/data.ts
Normal file
136
apps/web-antd/src/views/iot/rule/scene/data.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '规则状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '规则描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则描述',
|
||||
rows: 3,
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '规则名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '规则状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: getRangePickerDefaultProps(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '规则编号',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '规则名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '规则描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '规则状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'actionCount',
|
||||
title: '执行动作数',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'executeCount',
|
||||
title: '执行次数',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
330
apps/web-antd/src/views/iot/rule/scene/form/RuleSceneForm.vue
Normal file
330
apps/web-antd/src/views/iot/rule/scene/form/RuleSceneForm.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="drawerTitle"
|
||||
size="80%"
|
||||
direction="rtl"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="110px">
|
||||
<!-- 基础信息配置 -->
|
||||
<BasicInfoSection v-model="formData" :rules="formRules" />
|
||||
<!-- 触发器配置 -->
|
||||
<TriggerSection v-model:triggers="formData.triggers" />
|
||||
<!-- 执行器配置 -->
|
||||
<ActionSection v-model:actions="formData.actions" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button :disabled="submitLoading" type="primary" @click="handleSubmit">
|
||||
<Icon icon="ep:check" />
|
||||
确 定
|
||||
</el-button>
|
||||
<el-button @click="handleClose">
|
||||
<Icon icon="ep:close" />
|
||||
取 消
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import BasicInfoSection from './sections/BasicInfoSection.vue'
|
||||
import TriggerSection from './sections/TriggerSection.vue'
|
||||
import ActionSection from './sections/ActionSection.vue'
|
||||
import { IotSceneRule } from '#/api/iot/rule/scene'
|
||||
import { RuleSceneApi } from '#/api/iot/rule/scene'
|
||||
import {
|
||||
IotRuleSceneTriggerTypeEnum,
|
||||
IotRuleSceneActionTypeEnum,
|
||||
isDeviceTrigger
|
||||
} from '#/views/iot/utils/constants'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
|
||||
/** IoT 场景联动规则表单 - 主表单组件 */
|
||||
defineOptions({ name: 'RuleSceneForm' })
|
||||
|
||||
/** 组件属性定义 */
|
||||
const props = defineProps<{
|
||||
/** 抽屉显示状态 */
|
||||
modelValue: boolean
|
||||
/** 编辑的场景联动规则数据 */
|
||||
ruleScene?: IotSceneRule
|
||||
}>()
|
||||
|
||||
/** 组件事件定义 */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const drawerVisible = useVModel(props, 'modelValue', emit) // 抽屉显示状态
|
||||
|
||||
/**
|
||||
* 创建默认的表单数据
|
||||
* @returns 默认表单数据对象
|
||||
*/
|
||||
const createDefaultFormData = (): IotSceneRule => {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
status: CommonStatusEnum.ENABLE, // 默认启用状态
|
||||
triggers: [
|
||||
{
|
||||
type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
identifier: undefined,
|
||||
operator: undefined,
|
||||
value: undefined,
|
||||
cronExpression: undefined,
|
||||
conditionGroups: [] // 空的条件组数组
|
||||
}
|
||||
],
|
||||
actions: []
|
||||
}
|
||||
}
|
||||
|
||||
const formRef = ref() // 表单引用
|
||||
const formData = ref<IotSceneRule>(createDefaultFormData()) // 表单数据
|
||||
|
||||
/**
|
||||
* 触发器校验器
|
||||
* @param _rule 校验规则(未使用)
|
||||
* @param value 校验值
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
const validateTriggers = (_rule: any, value: any, callback: any) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
callback(new Error('至少需要一个触发器'))
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const trigger = value[i]
|
||||
|
||||
// 校验触发器类型
|
||||
if (!trigger.type) {
|
||||
callback(new Error(`触发器 ${i + 1}: 触发器类型不能为空`))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验设备触发器
|
||||
if (isDeviceTrigger(trigger.type)) {
|
||||
if (!trigger.productId) {
|
||||
callback(new Error(`触发器 ${i + 1}: 产品不能为空`))
|
||||
return
|
||||
}
|
||||
if (!trigger.deviceId) {
|
||||
callback(new Error(`触发器 ${i + 1}: 设备不能为空`))
|
||||
return
|
||||
}
|
||||
if (!trigger.identifier) {
|
||||
callback(new Error(`触发器 ${i + 1}: 物模型标识符不能为空`))
|
||||
return
|
||||
}
|
||||
if (!trigger.operator) {
|
||||
callback(new Error(`触发器 ${i + 1}: 操作符不能为空`))
|
||||
return
|
||||
}
|
||||
if (trigger.value === undefined || trigger.value === null || trigger.value === '') {
|
||||
callback(new Error(`触发器 ${i + 1}: 参数值不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 校验定时触发器
|
||||
if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) {
|
||||
if (!trigger.cronExpression) {
|
||||
callback(new Error(`触发器 ${i + 1}: CRON表达式不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行器校验器
|
||||
* @param _rule 校验规则(未使用)
|
||||
* @param value 校验值
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
const validateActions = (_rule: any, value: any, callback: any) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
callback(new Error('至少需要一个执行器'))
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const action = value[i]
|
||||
|
||||
// 校验执行器类型
|
||||
if (!action.type) {
|
||||
callback(new Error(`执行器 ${i + 1}: 执行器类型不能为空`))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验设备控制执行器
|
||||
if (
|
||||
action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET ||
|
||||
action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
) {
|
||||
if (!action.productId) {
|
||||
callback(new Error(`执行器 ${i + 1}: 产品不能为空`))
|
||||
return
|
||||
}
|
||||
if (!action.deviceId) {
|
||||
callback(new Error(`执行器 ${i + 1}: 设备不能为空`))
|
||||
return
|
||||
}
|
||||
|
||||
// 服务调用需要验证服务标识符
|
||||
if (action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE) {
|
||||
if (!action.identifier) {
|
||||
callback(new Error(`执行器 ${i + 1}: 服务不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!action.params || Object.keys(action.params).length === 0) {
|
||||
callback(new Error(`执行器 ${i + 1}: 参数配置不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 校验告警执行器
|
||||
if (
|
||||
action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
|
||||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER
|
||||
) {
|
||||
if (!action.alertConfigId) {
|
||||
callback(new Error(`执行器 ${i + 1}: 告警配置不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
const formRules = reactive({
|
||||
name: [
|
||||
{ required: true, message: '场景名称不能为空', trigger: 'blur' },
|
||||
{ type: 'string', min: 1, max: 50, message: '场景名称长度应在1-50个字符之间', trigger: 'blur' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '场景状态不能为空', trigger: 'change' },
|
||||
{
|
||||
type: 'enum',
|
||||
enum: [CommonStatusEnum.ENABLE, CommonStatusEnum.DISABLE],
|
||||
message: '状态值必须为启用或禁用',
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
description: [
|
||||
{ type: 'string', max: 200, message: '场景描述不能超过200个字符', trigger: 'blur' }
|
||||
],
|
||||
triggers: [{ required: true, validator: validateTriggers, trigger: 'change' }],
|
||||
actions: [{ required: true, validator: validateActions, trigger: 'change' }]
|
||||
}) // 表单校验规则
|
||||
|
||||
const submitLoading = ref(false) // 提交加载状态
|
||||
const isEdit = ref(false) // 是否为编辑模式
|
||||
const drawerTitle = computed(() => (isEdit.value ? '编辑场景联动规则' : '新增场景联动规则')) // 抽屉标题
|
||||
|
||||
/** 提交表单 */
|
||||
const handleSubmit = async () => {
|
||||
// 校验表单
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 提交请求
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
// 更新场景联动规则
|
||||
await RuleSceneApi.updateRuleScene(formData.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
// 创建场景联动规则
|
||||
await RuleSceneApi.createRuleScene(formData.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
// 关闭抽屉并触发成功事件
|
||||
drawerVisible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理抽屉关闭事件 */
|
||||
const handleClose = () => {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
/** 初始化表单数据 */
|
||||
const initFormData = () => {
|
||||
if (props.ruleScene) {
|
||||
// 编辑模式:数据结构已对齐,直接使用后端数据
|
||||
isEdit.value = true
|
||||
formData.value = {
|
||||
...props.ruleScene,
|
||||
// 确保触发器数组不为空
|
||||
triggers: props.ruleScene.triggers?.length
|
||||
? props.ruleScene.triggers
|
||||
: [
|
||||
{
|
||||
type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
identifier: undefined,
|
||||
operator: undefined,
|
||||
value: undefined,
|
||||
cronExpression: undefined,
|
||||
conditionGroups: []
|
||||
}
|
||||
],
|
||||
// 确保执行器数组不为空
|
||||
actions: props.ruleScene.actions || []
|
||||
}
|
||||
} else {
|
||||
// 新增模式:使用默认数据
|
||||
isEdit.value = false
|
||||
formData.value = createDefaultFormData()
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听抽屉显示 */
|
||||
watch(drawerVisible, async (visible) => {
|
||||
if (visible) {
|
||||
initFormData()
|
||||
// 重置表单验证状态
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
})
|
||||
|
||||
/** 监听编辑数据变化 */
|
||||
watch(
|
||||
() => props.ruleScene,
|
||||
() => {
|
||||
if (drawerVisible.value) {
|
||||
initFormData()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!-- 告警配置组件 -->
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<el-form-item label="告警配置" required>
|
||||
<el-select
|
||||
v-model="localValue"
|
||||
placeholder="请选择告警配置"
|
||||
filterable
|
||||
clearable
|
||||
@change="handleChange"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
>
|
||||
<el-option
|
||||
v-for="config in alertConfigs"
|
||||
:key="config.id"
|
||||
:label="config.name"
|
||||
:value="config.id"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ config.name }}</span>
|
||||
<el-tag :type="config.enabled ? 'success' : 'danger'" size="small">
|
||||
{{ config.enabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { AlertConfigApi } from '#/api/iot/alert/config'
|
||||
|
||||
/** 告警配置组件 */
|
||||
defineOptions({ name: 'AlertConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value?: number): void
|
||||
}>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const loading = ref(false) // 加载状态
|
||||
const alertConfigs = ref<any[]>([]) // 告警配置列表
|
||||
|
||||
/**
|
||||
* 处理选择变化事件
|
||||
* @param value 选中的值
|
||||
*/
|
||||
const handleChange = (value?: number) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载告警配置列表
|
||||
*/
|
||||
const loadAlertConfigs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await AlertConfigApi.getAlertConfigPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100,
|
||||
enabled: true // 只加载启用的配置
|
||||
})
|
||||
alertConfigs.value = data.list || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadAlertConfigs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,301 @@
|
||||
<!-- 单个条件配置组件 -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-16px">
|
||||
<!-- 条件类型选择 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="条件类型" required>
|
||||
<el-select
|
||||
:model-value="condition.type"
|
||||
@update:model-value="(value) => updateConditionField('type', value)"
|
||||
@change="handleConditionTypeChange"
|
||||
placeholder="请选择条件类型"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in getConditionTypeOptions()"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 产品设备选择 - 设备相关条件的公共部分 -->
|
||||
<el-row v-if="isDeviceCondition" :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品" required>
|
||||
<ProductSelector
|
||||
:model-value="condition.productId"
|
||||
@update:model-value="(value) => updateConditionField('productId', value)"
|
||||
@change="handleProductChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备" required>
|
||||
<DeviceSelector
|
||||
:model-value="condition.deviceId"
|
||||
@update:model-value="(value) => updateConditionField('deviceId', value)"
|
||||
:product-id="condition.productId"
|
||||
@change="handleDeviceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 设备状态条件配置 -->
|
||||
<div
|
||||
v-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS"
|
||||
class="flex flex-col gap-16px"
|
||||
>
|
||||
<!-- 状态和操作符选择 -->
|
||||
<el-row :gutter="16">
|
||||
<!-- 操作符选择 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="操作符" required>
|
||||
<el-select
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="(value) => updateConditionField('operator', value)"
|
||||
placeholder="请选择操作符"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in statusOperatorOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 状态选择 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备状态" required>
|
||||
<el-select
|
||||
:model-value="condition.param"
|
||||
@update:model-value="(value) => updateConditionField('param', value)"
|
||||
placeholder="请选择设备状态"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in deviceStatusOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 设备属性条件配置 -->
|
||||
<div
|
||||
v-else-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY"
|
||||
class="space-y-16px"
|
||||
>
|
||||
<!-- 属性配置 -->
|
||||
<el-row :gutter="16">
|
||||
<!-- 属性/事件/服务选择 -->
|
||||
<el-col :span="6">
|
||||
<el-form-item label="监控项" required>
|
||||
<PropertySelector
|
||||
:model-value="condition.identifier"
|
||||
@update:model-value="(value) => updateConditionField('identifier', value)"
|
||||
:trigger-type="triggerType"
|
||||
:product-id="condition.productId"
|
||||
:device-id="condition.deviceId"
|
||||
@change="handlePropertyChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 操作符选择 -->
|
||||
<el-col :span="6">
|
||||
<el-form-item label="操作符" required>
|
||||
<OperatorSelector
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="(value) => updateConditionField('operator', value)"
|
||||
:property-type="propertyType"
|
||||
@change="handleOperatorChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 值输入 -->
|
||||
<el-col :span="12">
|
||||
<el-form-item label="比较值" required>
|
||||
<ValueInput
|
||||
:model-value="condition.param"
|
||||
@update:model-value="(value) => updateConditionField('param', value)"
|
||||
:property-type="propertyType"
|
||||
:operator="condition.operator"
|
||||
:property-config="propertyConfig"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 当前时间条件配置 -->
|
||||
<CurrentTimeConditionConfig
|
||||
v-else-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME"
|
||||
:model-value="condition"
|
||||
@update:model-value="updateCondition"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import CurrentTimeConditionConfig from './CurrentTimeConditionConfig.vue'
|
||||
import ProductSelector from '../selectors/ProductSelector.vue'
|
||||
import DeviceSelector from '../selectors/DeviceSelector.vue'
|
||||
import PropertySelector from '../selectors/PropertySelector.vue'
|
||||
import OperatorSelector from '../selectors/OperatorSelector.vue'
|
||||
import ValueInput from '../inputs/ValueInput.vue'
|
||||
import type { TriggerCondition } from '#/api/iot/rule/scene'
|
||||
import {
|
||||
IotRuleSceneTriggerConditionTypeEnum,
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum,
|
||||
getConditionTypeOptions,
|
||||
IoTDeviceStatusEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
/** 单个条件配置组件 */
|
||||
defineOptions({ name: 'ConditionConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TriggerCondition
|
||||
triggerType: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TriggerCondition): void
|
||||
}>()
|
||||
|
||||
/** 获取设备状态选项 */
|
||||
const deviceStatusOptions = [
|
||||
{
|
||||
value: IoTDeviceStatusEnum.ONLINE.value,
|
||||
label: IoTDeviceStatusEnum.ONLINE.label
|
||||
},
|
||||
{
|
||||
value: IoTDeviceStatusEnum.OFFLINE.value,
|
||||
label: IoTDeviceStatusEnum.OFFLINE.label
|
||||
}
|
||||
]
|
||||
|
||||
/** 获取状态操作符选项 */
|
||||
const statusOperatorOptions = [
|
||||
{
|
||||
value: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value,
|
||||
label: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.name
|
||||
},
|
||||
{
|
||||
value: IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS.value,
|
||||
label: IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS.name
|
||||
}
|
||||
]
|
||||
|
||||
const condition = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const propertyType = ref<string>('string') // 属性类型
|
||||
const propertyConfig = ref<any>(null) // 属性配置
|
||||
const isDeviceCondition = computed(() => {
|
||||
return (
|
||||
condition.value.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS ||
|
||||
condition.value.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY
|
||||
)
|
||||
}) // 计算属性:判断是否为设备相关条件
|
||||
|
||||
/**
|
||||
* 更新条件字段
|
||||
* @param field 字段名
|
||||
* @param value 字段值
|
||||
*/
|
||||
const updateConditionField = (field: any, value: any) => {
|
||||
;(condition.value as any)[field] = value
|
||||
emit('update:modelValue', condition.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新整个条件对象
|
||||
* @param newCondition 新的条件对象
|
||||
*/
|
||||
const updateCondition = (newCondition: TriggerCondition) => {
|
||||
condition.value = newCondition
|
||||
emit('update:modelValue', condition.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理条件类型变化事件
|
||||
* @param type 条件类型
|
||||
*/
|
||||
const handleConditionTypeChange = (type: number) => {
|
||||
// 根据条件类型清理字段
|
||||
const isCurrentTime = type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME
|
||||
const isDeviceStatus = type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS
|
||||
|
||||
// 清理标识符字段(时间条件和设备状态条件都不需要)
|
||||
if (isCurrentTime || isDeviceStatus) {
|
||||
condition.value.identifier = undefined
|
||||
}
|
||||
|
||||
// 清理设备相关字段(仅时间条件需要)
|
||||
if (isCurrentTime) {
|
||||
condition.value.productId = undefined
|
||||
condition.value.deviceId = undefined
|
||||
}
|
||||
|
||||
// 设置默认操作符
|
||||
condition.value.operator = isCurrentTime
|
||||
? 'at_time'
|
||||
: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
|
||||
|
||||
// 清空参数值
|
||||
condition.value.param = ''
|
||||
}
|
||||
|
||||
/** 处理产品变化事件 */
|
||||
const handleProductChange = (_: number) => {
|
||||
// 产品变化时清空设备和属性
|
||||
condition.value.deviceId = undefined
|
||||
condition.value.identifier = ''
|
||||
}
|
||||
|
||||
/** 处理设备变化事件 */
|
||||
const handleDeviceChange = (_: number) => {
|
||||
// 设备变化时清空属性
|
||||
condition.value.identifier = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理属性变化事件
|
||||
* @param propertyInfo 属性信息对象
|
||||
*/
|
||||
const handlePropertyChange = (propertyInfo: { type: string; config: any }) => {
|
||||
propertyType.value = propertyInfo.type
|
||||
propertyConfig.value = propertyInfo.config
|
||||
|
||||
// 重置操作符和值
|
||||
condition.value.operator = IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
|
||||
condition.value.param = ''
|
||||
}
|
||||
|
||||
/** 处理操作符变化事件 */
|
||||
const handleOperatorChange = () => {
|
||||
// 重置值
|
||||
condition.value.param = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!-- 当前时间条件配置组件 -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-16px">
|
||||
<el-row :gutter="16">
|
||||
<!-- 时间操作符选择 -->
|
||||
<el-col :span="8">
|
||||
<el-form-item label="时间条件" required>
|
||||
<el-select
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="(value) => updateConditionField('operator', value)"
|
||||
placeholder="请选择时间条件"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in timeOperatorOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-8px">
|
||||
<Icon :icon="option.icon" :class="option.iconClass" />
|
||||
<span>{{ option.label }}</span>
|
||||
</div>
|
||||
<el-tag :type="option.tag as any" size="small">{{ option.category }}</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 时间值输入 -->
|
||||
<el-col :span="8">
|
||||
<el-form-item label="时间值" required>
|
||||
<el-time-picker
|
||||
v-if="needsTimeInput"
|
||||
:model-value="timeValue"
|
||||
@update:model-value="handleTimeValueChange"
|
||||
placeholder="请选择时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-else-if="needsDateInput"
|
||||
:model-value="timeValue"
|
||||
@update:model-value="handleTimeValueChange"
|
||||
type="datetime"
|
||||
placeholder="请选择日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
<div v-else class="text-[var(--el-text-color-placeholder)] text-14px">
|
||||
无需设置时间值
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 第二个时间值(范围条件) -->
|
||||
<el-col :span="8" v-if="needsSecondTimeInput">
|
||||
<el-form-item label="结束时间" required>
|
||||
<el-time-picker
|
||||
v-if="needsTimeInput"
|
||||
:model-value="timeValue2"
|
||||
@update:model-value="handleTimeValue2Change"
|
||||
placeholder="请选择结束时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-else
|
||||
:model-value="timeValue2"
|
||||
@update:model-value="handleTimeValue2Change"
|
||||
type="datetime"
|
||||
placeholder="请选择结束日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { IotRuleSceneTriggerTimeOperatorEnum } from '#/views/iot/utils/constants'
|
||||
import type { TriggerCondition } from '#/api/iot/rule/scene'
|
||||
|
||||
/** 当前时间条件配置组件 */
|
||||
defineOptions({ name: 'CurrentTimeConditionConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TriggerCondition
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TriggerCondition): void
|
||||
}>()
|
||||
|
||||
const condition = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 时间操作符选项
|
||||
const timeOperatorOptions = [
|
||||
{
|
||||
value: IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.value,
|
||||
label: IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.name,
|
||||
icon: 'ep:arrow-left',
|
||||
iconClass: 'text-blue-500',
|
||||
tag: 'primary',
|
||||
category: '时间点'
|
||||
},
|
||||
{
|
||||
value: IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.value,
|
||||
label: IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.name,
|
||||
icon: 'ep:arrow-right',
|
||||
iconClass: 'text-green-500',
|
||||
tag: 'success',
|
||||
category: '时间点'
|
||||
},
|
||||
{
|
||||
value: IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value,
|
||||
label: IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.name,
|
||||
icon: 'ep:sort',
|
||||
iconClass: 'text-orange-500',
|
||||
tag: 'warning',
|
||||
category: '时间段'
|
||||
},
|
||||
{
|
||||
value: IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.value,
|
||||
label: IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.name,
|
||||
icon: 'ep:position',
|
||||
iconClass: 'text-purple-500',
|
||||
tag: 'info',
|
||||
category: '时间点'
|
||||
},
|
||||
{
|
||||
value: IotRuleSceneTriggerTimeOperatorEnum.TODAY.value,
|
||||
label: IotRuleSceneTriggerTimeOperatorEnum.TODAY.name,
|
||||
icon: 'ep:calendar',
|
||||
iconClass: 'text-red-500',
|
||||
tag: 'danger',
|
||||
category: '日期'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性:是否需要时间输入
|
||||
const needsTimeInput = computed(() => {
|
||||
const timeOnlyOperators = [
|
||||
IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.value,
|
||||
IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.value,
|
||||
IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value,
|
||||
IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.value
|
||||
]
|
||||
return timeOnlyOperators.includes(condition.value.operator as any)
|
||||
})
|
||||
|
||||
// 计算属性:是否需要日期输入
|
||||
const needsDateInput = computed(() => {
|
||||
return false // 暂时不支持日期输入,只支持时间
|
||||
})
|
||||
|
||||
// 计算属性:是否需要第二个时间输入
|
||||
const needsSecondTimeInput = computed(() => {
|
||||
return condition.value.operator === IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value
|
||||
})
|
||||
|
||||
// 计算属性:从 param 中解析时间值
|
||||
const timeValue = computed(() => {
|
||||
if (!condition.value.param) return ''
|
||||
const params = condition.value.param.split(',')
|
||||
return params[0] || ''
|
||||
})
|
||||
|
||||
// 计算属性:从 param 中解析第二个时间值
|
||||
const timeValue2 = computed(() => {
|
||||
if (!condition.value.param) return ''
|
||||
const params = condition.value.param.split(',')
|
||||
return params[1] || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新条件字段
|
||||
* @param field 字段名
|
||||
* @param value 字段值
|
||||
*/
|
||||
const updateConditionField = (field: any, value: any) => {
|
||||
condition.value[field] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理第一个时间值变化
|
||||
* @param value 时间值
|
||||
*/
|
||||
const handleTimeValueChange = (value: string) => {
|
||||
const currentParams = condition.value.param ? condition.value.param.split(',') : []
|
||||
currentParams[0] = value || ''
|
||||
|
||||
// 如果是范围条件,保留第二个值;否则只保留第一个值
|
||||
if (needsSecondTimeInput.value) {
|
||||
condition.value.param = currentParams.slice(0, 2).join(',')
|
||||
} else {
|
||||
condition.value.param = currentParams[0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理第二个时间值变化
|
||||
* @param value 时间值
|
||||
*/
|
||||
const handleTimeValue2Change = (value: string) => {
|
||||
const currentParams = condition.value.param ? condition.value.param.split(',') : ['']
|
||||
currentParams[1] = value || ''
|
||||
condition.value.param = currentParams.slice(0, 2).join(',')
|
||||
}
|
||||
|
||||
/** 监听操作符变化,清理不相关的时间值 */
|
||||
watch(
|
||||
() => condition.value.operator,
|
||||
(newOperator) => {
|
||||
if (newOperator === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value) {
|
||||
// 今日条件不需要时间参数
|
||||
condition.value.param = ''
|
||||
} else if (!needsSecondTimeInput.value) {
|
||||
// 非范围条件只保留第一个时间值
|
||||
const currentParams = condition.value.param ? condition.value.param.split(',') : []
|
||||
condition.value.param = currentParams[0] || ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,376 @@
|
||||
<!-- 设备控制配置组件 -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-16px">
|
||||
<!-- 产品和设备选择 - 与触发器保持一致的分离式选择器 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品" required>
|
||||
<ProductSelector v-model="action.productId" @change="handleProductChange" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备" required>
|
||||
<DeviceSelector
|
||||
v-model="action.deviceId"
|
||||
:product-id="action.productId"
|
||||
@change="handleDeviceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 服务选择 - 服务调用类型时显示 -->
|
||||
<div v-if="action.productId && isServiceInvokeAction" class="space-y-16px">
|
||||
<el-form-item label="服务" required>
|
||||
<el-select
|
||||
v-model="action.identifier"
|
||||
placeholder="请选择服务"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
:loading="loadingServices"
|
||||
@change="handleServiceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="service in serviceList"
|
||||
:key="service.identifier"
|
||||
:label="service.name"
|
||||
:value="service.identifier"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ service.name }}</span>
|
||||
<el-tag :type="service.callType === 'sync' ? 'primary' : 'success'" size="small">
|
||||
{{ service.callType === 'sync' ? '同步' : '异步' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 服务参数配置 -->
|
||||
<div v-if="action.identifier" class="space-y-16px">
|
||||
<el-form-item label="服务参数" required>
|
||||
<JsonParamsInput
|
||||
v-model="paramsValue"
|
||||
type="service"
|
||||
:config="{ service: selectedService } as any"
|
||||
placeholder="请输入 JSON 格式的服务参数"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制参数配置 - 属性设置类型时显示 -->
|
||||
<div v-if="action.productId && isPropertySetAction" class="space-y-16px">
|
||||
<!-- 参数配置 -->
|
||||
<el-form-item label="参数" required>
|
||||
<JsonParamsInput
|
||||
v-model="paramsValue"
|
||||
type="property"
|
||||
:config="{ properties: thingModelProperties }"
|
||||
placeholder="请输入 JSON 格式的控制参数"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import ProductSelector from '../selectors/ProductSelector.vue'
|
||||
import DeviceSelector from '../selectors/DeviceSelector.vue'
|
||||
import JsonParamsInput from '../inputs/JsonParamsInput.vue'
|
||||
import type { Action } from '#/api/iot/rule/scene'
|
||||
import type { ThingModelProperty, ThingModelService } from '#/api/iot/thingmodel'
|
||||
import {
|
||||
IotRuleSceneActionTypeEnum,
|
||||
IoTThingModelAccessModeEnum,
|
||||
IoTDataSpecsDataTypeEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
import { ThingModelApi } from '#/api/iot/thingmodel'
|
||||
|
||||
/** 设备控制配置组件 */
|
||||
defineOptions({ name: 'DeviceControlConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Action
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Action): void
|
||||
}>()
|
||||
|
||||
const action = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const thingModelProperties = ref<ThingModelProperty[]>([]) // 物模型属性列表
|
||||
const loadingThingModel = ref(false) // 物模型加载状态
|
||||
const selectedService = ref<ThingModelService | null>(null) // 选中的服务对象
|
||||
const serviceList = ref<ThingModelService[]>([]) // 服务列表
|
||||
const loadingServices = ref(false) // 服务加载状态
|
||||
|
||||
// 参数值的计算属性,用于双向绑定
|
||||
const paramsValue = computed({
|
||||
get: () => {
|
||||
// 如果 params 是对象,转换为 JSON 字符串(兼容旧数据)
|
||||
if (action.value.params && typeof action.value.params === 'object') {
|
||||
return JSON.stringify(action.value.params, null, 2)
|
||||
}
|
||||
// 如果 params 已经是字符串,直接返回
|
||||
return action.value.params || ''
|
||||
},
|
||||
set: (value: string) => {
|
||||
// 直接保存为 JSON 字符串,不进行解析转换
|
||||
action.value.params = value.trim() || ''
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:是否为属性设置类型
|
||||
const isPropertySetAction = computed(() => {
|
||||
return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
|
||||
})
|
||||
|
||||
// 计算属性:是否为服务调用类型
|
||||
const isServiceInvokeAction = computed(() => {
|
||||
return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理产品变化事件
|
||||
* @param productId 产品 ID
|
||||
*/
|
||||
const handleProductChange = (productId?: number) => {
|
||||
// 当产品变化时,清空设备选择和参数配置
|
||||
if (action.value.productId !== productId) {
|
||||
action.value.deviceId = undefined
|
||||
action.value.identifier = undefined // 清空服务标识符
|
||||
action.value.params = '' // 清空参数,保存为空字符串
|
||||
selectedService.value = null // 清空选中的服务
|
||||
serviceList.value = [] // 清空服务列表
|
||||
}
|
||||
|
||||
// 加载新产品的物模型属性或服务列表
|
||||
if (productId) {
|
||||
if (isPropertySetAction.value) {
|
||||
loadThingModelProperties(productId)
|
||||
} else if (isServiceInvokeAction.value) {
|
||||
loadServiceList(productId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备变化事件
|
||||
* @param deviceId 设备 ID
|
||||
*/
|
||||
const handleDeviceChange = (deviceId?: number) => {
|
||||
// 当设备变化时,清空参数配置
|
||||
if (action.value.deviceId !== deviceId) {
|
||||
action.value.params = '' // 清空参数,保存为空字符串
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务变化事件
|
||||
* @param serviceIdentifier 服务标识符
|
||||
*/
|
||||
const handleServiceChange = (serviceIdentifier?: string) => {
|
||||
// 根据服务标识符找到对应的服务对象
|
||||
const service = serviceList.value.find((s) => s.identifier === serviceIdentifier) || null
|
||||
selectedService.value = service
|
||||
|
||||
// 当服务变化时,清空参数配置
|
||||
action.value.params = ''
|
||||
|
||||
// 如果选择了服务且有输入参数,生成默认参数结构
|
||||
if (service && service.inputParams && service.inputParams.length > 0) {
|
||||
const defaultParams = {}
|
||||
service.inputParams.forEach((param) => {
|
||||
defaultParams[param.identifier] = getDefaultValueForParam(param)
|
||||
})
|
||||
// 将默认参数转换为 JSON 字符串保存
|
||||
action.value.params = JSON.stringify(defaultParams, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取物模型TSL数据
|
||||
* @param productId 产品ID
|
||||
* @returns 物模型TSL数据
|
||||
*/
|
||||
const getThingModelTSL = async (productId: number) => {
|
||||
if (!productId) return null
|
||||
|
||||
try {
|
||||
return await ThingModelApi.getThingModelTSLByProductId(productId)
|
||||
} catch (error) {
|
||||
console.error('获取物模型TSL数据失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载物模型属性(可写属性)
|
||||
* @param productId 产品ID
|
||||
*/
|
||||
const loadThingModelProperties = async (productId: number) => {
|
||||
if (!productId) {
|
||||
thingModelProperties.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loadingThingModel.value = true
|
||||
const tslData = await getThingModelTSL(productId)
|
||||
|
||||
if (!tslData?.properties) {
|
||||
thingModelProperties.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出可写的属性(accessMode 包含 'w')
|
||||
thingModelProperties.value = tslData.properties.filter(
|
||||
(property: ThingModelProperty) =>
|
||||
property.accessMode &&
|
||||
(property.accessMode === IoTThingModelAccessModeEnum.READ_WRITE.value ||
|
||||
property.accessMode === IoTThingModelAccessModeEnum.WRITE_ONLY.value)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载物模型属性失败:', error)
|
||||
thingModelProperties.value = []
|
||||
} finally {
|
||||
loadingThingModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载服务列表
|
||||
* @param productId 产品ID
|
||||
*/
|
||||
const loadServiceList = async (productId: number) => {
|
||||
if (!productId) {
|
||||
serviceList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loadingServices.value = true
|
||||
const tslData = await getThingModelTSL(productId)
|
||||
|
||||
if (!tslData?.services) {
|
||||
serviceList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
serviceList.value = tslData.services
|
||||
} catch (error) {
|
||||
console.error('加载服务列表失败:', error)
|
||||
serviceList.value = []
|
||||
} finally {
|
||||
loadingServices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从TSL加载服务信息(用于编辑模式回显)
|
||||
* @param productId 产品ID
|
||||
* @param serviceIdentifier 服务标识符
|
||||
*/
|
||||
const loadServiceFromTSL = async (productId: number, serviceIdentifier: string) => {
|
||||
// 先加载服务列表
|
||||
await loadServiceList(productId)
|
||||
|
||||
// 然后设置选中的服务
|
||||
const service = serviceList.value.find((s: any) => s.identifier === serviceIdentifier)
|
||||
if (service) {
|
||||
selectedService.value = service
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据参数类型获取默认值
|
||||
* @param param 参数对象
|
||||
* @returns 默认值
|
||||
*/
|
||||
const getDefaultValueForParam = (param: any) => {
|
||||
switch (param.dataType) {
|
||||
case IoTDataSpecsDataTypeEnum.INT:
|
||||
return 0
|
||||
case IoTDataSpecsDataTypeEnum.FLOAT:
|
||||
case IoTDataSpecsDataTypeEnum.DOUBLE:
|
||||
return 0.0
|
||||
case IoTDataSpecsDataTypeEnum.BOOL:
|
||||
return false
|
||||
case IoTDataSpecsDataTypeEnum.TEXT:
|
||||
return ''
|
||||
case IoTDataSpecsDataTypeEnum.ENUM:
|
||||
// 如果有枚举值,使用第一个
|
||||
if (param.dataSpecs?.dataSpecsList && param.dataSpecs.dataSpecsList.length > 0) {
|
||||
return param.dataSpecs.dataSpecsList[0].value
|
||||
}
|
||||
return ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const isInitialized = ref(false) // 防止重复初始化的标志
|
||||
|
||||
/**
|
||||
* 初始化组件数据
|
||||
*/
|
||||
const initializeComponent = async () => {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const currentAction = action.value
|
||||
if (!currentAction) return
|
||||
|
||||
// 如果已经选择了产品且是属性设置类型,加载物模型
|
||||
if (currentAction.productId && isPropertySetAction.value) {
|
||||
await loadThingModelProperties(currentAction.productId)
|
||||
}
|
||||
|
||||
// 如果是服务调用类型且已有标识符,初始化服务选择
|
||||
if (currentAction.productId && isServiceInvokeAction.value && currentAction.identifier) {
|
||||
// 加载物模型TSL以获取服务信息
|
||||
await loadServiceFromTSL(currentAction.productId, currentAction.identifier)
|
||||
}
|
||||
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
initializeComponent()
|
||||
})
|
||||
|
||||
/** 监听关键字段的变化,避免深度监听导致的性能问题 */
|
||||
watch(
|
||||
() => [action.value.productId, action.value.type, action.value.identifier],
|
||||
async ([newProductId, , newIdentifier], [oldProductId, , oldIdentifier]) => {
|
||||
// 避免初始化时的重复调用
|
||||
if (!isInitialized.value) return
|
||||
|
||||
// 产品变化时重新加载数据
|
||||
if (newProductId !== oldProductId) {
|
||||
if (newProductId && isPropertySetAction.value) {
|
||||
await loadThingModelProperties(newProductId as number)
|
||||
} else if (newProductId && isServiceInvokeAction.value) {
|
||||
await loadServiceList(newProductId as number)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务标识符变化时更新选中的服务
|
||||
if (
|
||||
newIdentifier !== oldIdentifier &&
|
||||
newProductId &&
|
||||
isServiceInvokeAction.value &&
|
||||
newIdentifier
|
||||
) {
|
||||
const service = serviceList.value.find((s: any) => s.identifier === newIdentifier)
|
||||
if (service) {
|
||||
selectedService.value = service
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
<!-- 设备触发配置组件 -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-16px">
|
||||
<!-- 主条件配置 - 默认直接展示 -->
|
||||
<div class="space-y-16px">
|
||||
<!-- 主条件配置 -->
|
||||
<div class="flex flex-col gap-16px">
|
||||
<!-- 主条件配置 -->
|
||||
<div class="space-y-16px">
|
||||
<!-- 主条件头部 - 与附加条件组保持一致的绿色风格 -->
|
||||
<div
|
||||
class="flex items-center justify-between p-16px bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-8px"
|
||||
>
|
||||
<div class="flex items-center gap-12px">
|
||||
<div class="flex items-center gap-8px text-16px font-600 text-green-700">
|
||||
<div
|
||||
class="w-24px h-24px bg-green-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
|
||||
>
|
||||
主
|
||||
</div>
|
||||
<span>主条件</span>
|
||||
</div>
|
||||
<el-tag size="small" type="success">必须满足</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主条件内容配置 -->
|
||||
<MainConditionInnerConfig
|
||||
:model-value="trigger"
|
||||
@update:model-value="updateCondition"
|
||||
:trigger-type="trigger.type"
|
||||
@trigger-type-change="handleTriggerTypeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 条件组配置 -->
|
||||
<div class="space-y-16px">
|
||||
<!-- 条件组配置 -->
|
||||
<div class="flex flex-col gap-16px">
|
||||
<!-- 条件组容器头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between p-16px bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-8px"
|
||||
>
|
||||
<div class="flex items-center gap-12px">
|
||||
<div class="flex items-center gap-8px text-16px font-600 text-green-700">
|
||||
<div
|
||||
class="w-24px h-24px bg-green-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
|
||||
>
|
||||
组
|
||||
</div>
|
||||
<span>附加条件组</span>
|
||||
</div>
|
||||
<el-tag size="small" type="success">与"主条件"为且关系</el-tag>
|
||||
<el-tag size="small" type="info">
|
||||
{{ trigger.conditionGroups?.length || 0 }} 个子条件组
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="addSubGroup"
|
||||
:disabled="(trigger.conditionGroups?.length || 0) >= maxSubGroups"
|
||||
>
|
||||
<Icon icon="ep:plus" />
|
||||
添加子条件组
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" text @click="removeConditionGroup">
|
||||
<Icon icon="ep:delete" />
|
||||
删除条件组
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子条件组列表 -->
|
||||
<div
|
||||
v-if="trigger.conditionGroups && trigger.conditionGroups.length > 0"
|
||||
class="space-y-16px"
|
||||
>
|
||||
<!-- 逻辑关系说明 -->
|
||||
<div class="relative">
|
||||
<div
|
||||
v-for="(subGroup, subGroupIndex) in trigger.conditionGroups"
|
||||
:key="`sub-group-${subGroupIndex}`"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 子条件组容器 -->
|
||||
<div
|
||||
class="border-2 border-orange-200 rounded-8px bg-orange-50 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-16px bg-gradient-to-r from-orange-50 to-yellow-50 border-b border-orange-200 rounded-t-6px"
|
||||
>
|
||||
<div class="flex items-center gap-12px">
|
||||
<div class="flex items-center gap-8px text-16px font-600 text-orange-700">
|
||||
<div
|
||||
class="w-24px h-24px bg-orange-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
|
||||
>
|
||||
{{ subGroupIndex + 1 }}
|
||||
</div>
|
||||
<span>子条件组 {{ subGroupIndex + 1 }}</span>
|
||||
</div>
|
||||
<el-tag size="small" type="warning" class="font-500">组内条件为"且"关系</el-tag>
|
||||
<el-tag size="small" type="info"> {{ subGroup?.length || 0 }}个条件 </el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
@click="removeSubGroup(subGroupIndex)"
|
||||
class="hover:bg-red-50"
|
||||
>
|
||||
<Icon icon="ep:delete" />
|
||||
删除组
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<SubConditionGroupConfig
|
||||
:model-value="subGroup"
|
||||
@update:model-value="(value) => updateSubGroup(subGroupIndex, value)"
|
||||
:trigger-type="trigger.type"
|
||||
:max-conditions="maxConditionsPerGroup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 子条件组间的"或"连接符 -->
|
||||
<div
|
||||
v-if="subGroupIndex < trigger.conditionGroups!.length - 1"
|
||||
class="flex items-center justify-center py-12px"
|
||||
>
|
||||
<div class="flex items-center gap-8px">
|
||||
<!-- 连接线 -->
|
||||
<div class="w-32px h-1px bg-orange-300"></div>
|
||||
<!-- 或标签 -->
|
||||
<div class="px-16px py-6px bg-orange-100 border-2 border-orange-300 rounded-full">
|
||||
<span class="text-14px font-600 text-orange-600">或</span>
|
||||
</div>
|
||||
<!-- 连接线 -->
|
||||
<div class="w-32px h-1px bg-orange-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="p-24px border-2 border-dashed border-orange-200 rounded-8px text-center bg-orange-50"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-12px">
|
||||
<Icon icon="ep:plus" class="text-32px text-orange-400" />
|
||||
<div class="text-orange-600">
|
||||
<p class="text-14px font-500 mb-4px">暂无子条件组</p>
|
||||
<p class="text-12px">点击上方"添加子条件组"按钮开始配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
import MainConditionInnerConfig from './MainConditionInnerConfig.vue'
|
||||
import SubConditionGroupConfig from './SubConditionGroupConfig.vue'
|
||||
import type { Trigger } from '#/api/iot/rule/scene'
|
||||
|
||||
/** 设备触发配置组件 */
|
||||
defineOptions({ name: 'DeviceTriggerConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Trigger
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Trigger): void
|
||||
(e: 'trigger-type-change', type: number): void
|
||||
}>()
|
||||
|
||||
const trigger = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const maxSubGroups = 3 // 最多 3 个子条件组
|
||||
const maxConditionsPerGroup = 3 // 每组最多 3 个条件
|
||||
|
||||
/**
|
||||
* 更新条件
|
||||
* @param condition 条件对象
|
||||
*/
|
||||
const updateCondition = (condition: Trigger) => {
|
||||
trigger.value = condition
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触发器类型变化事件
|
||||
* @param type 触发器类型
|
||||
*/
|
||||
const handleTriggerTypeChange = (type: number) => {
|
||||
trigger.value.type = type
|
||||
emit('trigger-type-change', type)
|
||||
}
|
||||
|
||||
/** 添加子条件组 */
|
||||
const addSubGroup = async () => {
|
||||
if (!trigger.value.conditionGroups) {
|
||||
trigger.value.conditionGroups = []
|
||||
}
|
||||
|
||||
// 检查是否达到最大子组数量限制
|
||||
if (trigger.value.conditionGroups?.length >= maxSubGroups) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 nextTick 确保响应式更新完成后再添加新的子组
|
||||
await nextTick()
|
||||
if (trigger.value.conditionGroups) {
|
||||
trigger.value.conditionGroups.push([])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除子条件组
|
||||
* @param index 子条件组索引
|
||||
*/
|
||||
const removeSubGroup = (index: number) => {
|
||||
if (trigger.value.conditionGroups) {
|
||||
trigger.value.conditionGroups.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新子条件组
|
||||
* @param index 子条件组索引
|
||||
* @param subGroup 子条件组数据
|
||||
*/
|
||||
const updateSubGroup = (index: number, subGroup: any) => {
|
||||
if (trigger.value.conditionGroups) {
|
||||
trigger.value.conditionGroups[index] = subGroup
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除整个条件组 */
|
||||
const removeConditionGroup = () => {
|
||||
trigger.value.conditionGroups = undefined
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="space-y-16px">
|
||||
<!-- 触发事件类型选择 -->
|
||||
<el-form-item label="触发事件类型" required>
|
||||
<el-select
|
||||
:model-value="triggerType"
|
||||
@update:model-value="handleTriggerTypeChange"
|
||||
placeholder="请选择触发事件类型"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in triggerTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 设备属性条件配置 -->
|
||||
<div v-if="isDevicePropertyTrigger" class="space-y-16px">
|
||||
<!-- 产品设备选择 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品" required>
|
||||
<ProductSelector
|
||||
:model-value="condition.productId"
|
||||
@update:model-value="(value) => updateConditionField('productId', value)"
|
||||
@change="handleProductChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备" required>
|
||||
<DeviceSelector
|
||||
:model-value="condition.deviceId"
|
||||
@update:model-value="(value) => updateConditionField('deviceId', value)"
|
||||
:product-id="condition.productId"
|
||||
@change="handleDeviceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 属性配置 -->
|
||||
<el-row :gutter="16">
|
||||
<!-- 属性/事件/服务选择 -->
|
||||
<el-col :span="6">
|
||||
<el-form-item label="监控项" required>
|
||||
<PropertySelector
|
||||
:model-value="condition.identifier"
|
||||
@update:model-value="(value) => updateConditionField('identifier', value)"
|
||||
:trigger-type="triggerType"
|
||||
:product-id="condition.productId"
|
||||
:device-id="condition.deviceId"
|
||||
@change="handlePropertyChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 操作符选择 - 服务调用和事件上报不需要操作符 -->
|
||||
<el-col v-if="needsOperatorSelector" :span="6">
|
||||
<el-form-item label="操作符" required>
|
||||
<OperatorSelector
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="(value) => updateConditionField('operator', value)"
|
||||
:property-type="propertyType"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 值输入 -->
|
||||
<el-col :span="isWideValueColumn ? 18 : 12">
|
||||
<el-form-item :label="valueInputLabel" required>
|
||||
<!-- 服务调用参数配置 -->
|
||||
<JsonParamsInput
|
||||
v-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE"
|
||||
v-model="condition.value"
|
||||
type="service"
|
||||
:config="serviceConfig"
|
||||
placeholder="请输入 JSON 格式的服务参数"
|
||||
/>
|
||||
<!-- 事件上报参数配置 -->
|
||||
<JsonParamsInput
|
||||
v-else-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST"
|
||||
v-model="condition.value"
|
||||
type="event"
|
||||
:config="eventConfig"
|
||||
placeholder="请输入 JSON 格式的事件参数"
|
||||
/>
|
||||
<!-- 普通值输入 -->
|
||||
<ValueInput
|
||||
v-else
|
||||
:model-value="condition.value"
|
||||
@update:model-value="(value) => updateConditionField('value', value)"
|
||||
:property-type="propertyType"
|
||||
:operator="condition.operator"
|
||||
:property-config="propertyConfig"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 设备状态条件配置 -->
|
||||
<div v-else-if="isDeviceStatusTrigger" class="space-y-16px">
|
||||
<!-- 设备状态触发器使用简化的配置 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品" required>
|
||||
<ProductSelector
|
||||
:model-value="condition.productId"
|
||||
@update:model-value="(value) => updateConditionField('productId', value)"
|
||||
@change="handleProductChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备" required>
|
||||
<DeviceSelector
|
||||
:model-value="condition.deviceId"
|
||||
@update:model-value="(value) => updateConditionField('deviceId', value)"
|
||||
:product-id="condition.productId"
|
||||
@change="handleDeviceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="操作符" required>
|
||||
<el-select
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="(value) => updateConditionField('operator', value)"
|
||||
placeholder="请选择操作符"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
:label="IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.name"
|
||||
:value="IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="参数" required>
|
||||
<el-select
|
||||
:model-value="condition.value"
|
||||
@update:model-value="(value) => updateConditionField('value', value)"
|
||||
placeholder="请选择操作符"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in deviceStatusChangeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 其他触发类型的提示 -->
|
||||
<div v-else class="text-center py-20px">
|
||||
<p class="text-14px text-[var(--el-text-color-secondary)] mb-4px">
|
||||
当前触发事件类型:{{ getTriggerTypeLabel(triggerType) }}
|
||||
</p>
|
||||
<p class="text-12px text-[var(--el-text-color-placeholder)]">
|
||||
此触发类型暂不需要配置额外条件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductSelector from '../selectors/ProductSelector.vue'
|
||||
import DeviceSelector from '../selectors/DeviceSelector.vue'
|
||||
import PropertySelector from '../selectors/PropertySelector.vue'
|
||||
import OperatorSelector from '../selectors/OperatorSelector.vue'
|
||||
import ValueInput from '../inputs/ValueInput.vue'
|
||||
import JsonParamsInput from '../inputs/JsonParamsInput.vue'
|
||||
|
||||
import type { Trigger } from '#/api/iot/rule/scene'
|
||||
import {
|
||||
IotRuleSceneTriggerTypeEnum,
|
||||
triggerTypeOptions,
|
||||
getTriggerTypeLabel,
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum,
|
||||
IoTDeviceStatusEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** 主条件内部配置组件 */
|
||||
defineOptions({ name: 'MainConditionInnerConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Trigger
|
||||
triggerType: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Trigger): void
|
||||
(e: 'trigger-type-change', value: number): void
|
||||
}>()
|
||||
|
||||
/** 获取设备状态变更选项(用于触发器配置) */
|
||||
const deviceStatusChangeOptions = [
|
||||
{
|
||||
label: IoTDeviceStatusEnum.ONLINE.label,
|
||||
value: IoTDeviceStatusEnum.ONLINE.value
|
||||
},
|
||||
{
|
||||
label: IoTDeviceStatusEnum.OFFLINE.label,
|
||||
value: IoTDeviceStatusEnum.OFFLINE.value
|
||||
}
|
||||
]
|
||||
|
||||
const condition = useVModel(props, 'modelValue', emit)
|
||||
const propertyType = ref('') // 属性类型
|
||||
const propertyConfig = ref<any>(null) // 属性配置
|
||||
|
||||
// 计算属性:是否为设备属性触发器
|
||||
const isDevicePropertyTrigger = computed(() => {
|
||||
return (
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
)
|
||||
})
|
||||
|
||||
// 计算属性:是否为设备状态触发器
|
||||
const isDeviceStatusTrigger = computed(() => {
|
||||
return props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE
|
||||
})
|
||||
|
||||
// 计算属性:是否需要操作符选择(服务调用和事件上报不需要操作符)
|
||||
const needsOperatorSelector = computed(() => {
|
||||
const noOperatorTriggerTypes = [
|
||||
IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
|
||||
IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
|
||||
] as number[]
|
||||
return !noOperatorTriggerTypes.includes(props.triggerType)
|
||||
})
|
||||
|
||||
// 计算属性:是否需要宽列布局(服务调用和事件上报不需要操作符列,所以值输入列更宽)
|
||||
const isWideValueColumn = computed(() => {
|
||||
const wideColumnTriggerTypes = [
|
||||
IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
|
||||
IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
|
||||
] as number[]
|
||||
return wideColumnTriggerTypes.includes(props.triggerType)
|
||||
})
|
||||
|
||||
// 计算属性:值输入字段的标签文本
|
||||
const valueInputLabel = computed(() => {
|
||||
return props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
? '服务参数'
|
||||
: '比较值'
|
||||
})
|
||||
|
||||
// 计算属性:服务配置 - 用于 JsonParamsInput
|
||||
const serviceConfig = computed(() => {
|
||||
if (
|
||||
propertyConfig.value &&
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
) {
|
||||
return {
|
||||
service: {
|
||||
name: propertyConfig.value.name || '服务',
|
||||
inputParams: propertyConfig.value.inputParams || []
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// 计算属性:事件配置 - 用于 JsonParamsInput
|
||||
const eventConfig = computed(() => {
|
||||
if (propertyConfig.value && props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
|
||||
return {
|
||||
event: {
|
||||
name: propertyConfig.value.name || '事件',
|
||||
outputParams: propertyConfig.value.outputParams || []
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新条件字段
|
||||
* @param field 字段名
|
||||
* @param value 字段值
|
||||
*/
|
||||
const updateConditionField = (field: any, value: any) => {
|
||||
condition.value[field] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触发器类型变化事件
|
||||
* @param type 触发器类型
|
||||
*/
|
||||
const handleTriggerTypeChange = (type: number) => {
|
||||
emit('trigger-type-change', type)
|
||||
}
|
||||
|
||||
/** 处理产品变化事件 */
|
||||
const handleProductChange = () => {
|
||||
// 产品变化时清空设备和属性
|
||||
condition.value.deviceId = undefined
|
||||
condition.value.identifier = ''
|
||||
}
|
||||
|
||||
/** 处理设备变化事件 */
|
||||
const handleDeviceChange = () => {
|
||||
// 设备变化时清空属性
|
||||
condition.value.identifier = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理属性变化事件
|
||||
* @param propertyInfo 属性信息对象
|
||||
*/
|
||||
const handlePropertyChange = (propertyInfo: any) => {
|
||||
if (propertyInfo) {
|
||||
propertyType.value = propertyInfo.type
|
||||
propertyConfig.value = propertyInfo.config
|
||||
|
||||
// 对于事件上报和服务调用,自动设置操作符为 '='
|
||||
if (
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
) {
|
||||
condition.value.operator = IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="p-16px">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!subGroup || subGroup.length === 0" class="text-center py-24px">
|
||||
<div class="flex flex-col items-center gap-12px">
|
||||
<Icon icon="ep:plus" class="text-32px text-[var(--el-text-color-placeholder)]" />
|
||||
<div class="text-[var(--el-text-color-secondary)]">
|
||||
<p class="text-14px font-500 mb-4px">暂无条件</p>
|
||||
<p class="text-12px">点击下方按钮添加第一个条件</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="addCondition">
|
||||
<Icon icon="ep:plus" />
|
||||
添加条件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 条件列表 -->
|
||||
<div v-else class="space-y-16px">
|
||||
<div
|
||||
v-for="(condition, conditionIndex) in subGroup"
|
||||
:key="`condition-${conditionIndex}`"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 条件配置 -->
|
||||
<div
|
||||
class="border border-[var(--el-border-color-lighter)] rounded-6px bg-[var(--el-fill-color-blank)] shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-12px bg-[var(--el-fill-color-light)] border-b border-[var(--el-border-color-lighter)] rounded-t-4px"
|
||||
>
|
||||
<div class="flex items-center gap-8px">
|
||||
<div
|
||||
class="w-20px h-20px bg-blue-500 text-white rounded-full flex items-center justify-center text-10px font-bold"
|
||||
>
|
||||
{{ conditionIndex + 1 }}
|
||||
</div>
|
||||
<span class="text-12px font-500 text-[var(--el-text-color-primary)]"
|
||||
>条件 {{ conditionIndex + 1 }}</span
|
||||
>
|
||||
</div>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
@click="removeCondition(conditionIndex)"
|
||||
v-if="subGroup!.length > 1"
|
||||
class="hover:bg-red-50"
|
||||
>
|
||||
<Icon icon="ep:delete" />
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="p-12px">
|
||||
<ConditionConfig
|
||||
:model-value="condition"
|
||||
@update:model-value="(value) => updateCondition(conditionIndex, value)"
|
||||
:trigger-type="triggerType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加条件按钮 -->
|
||||
<div
|
||||
v-if="subGroup && subGroup.length > 0 && subGroup.length < maxConditions"
|
||||
class="text-center py-16px"
|
||||
>
|
||||
<el-button type="primary" plain @click="addCondition">
|
||||
<Icon icon="ep:plus" />
|
||||
继续添加条件
|
||||
</el-button>
|
||||
<span class="block mt-8px text-12px text-[var(--el-text-color-secondary)]">
|
||||
最多可添加 {{ maxConditions }} 个条件
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import ConditionConfig from './ConditionConfig.vue'
|
||||
import type { TriggerCondition } from '#/api/iot/rule/scene'
|
||||
import {
|
||||
IotRuleSceneTriggerConditionTypeEnum,
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
/** 子条件组配置组件 */
|
||||
defineOptions({ name: 'SubConditionGroupConfig' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TriggerCondition[]
|
||||
triggerType: number
|
||||
maxConditions?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TriggerCondition[]): void
|
||||
}>()
|
||||
|
||||
const subGroup = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const maxConditions = computed(() => props.maxConditions || 3) // 最大条件数量
|
||||
|
||||
/** 添加条件 */
|
||||
const addCondition = async () => {
|
||||
// 确保 subGroup.value 是一个数组
|
||||
if (!subGroup.value) {
|
||||
subGroup.value = []
|
||||
}
|
||||
|
||||
// 检查是否达到最大条件数量限制
|
||||
if (subGroup.value?.length >= maxConditions.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const newCondition: TriggerCondition = {
|
||||
type: IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY, // 默认为设备属性
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
identifier: '',
|
||||
operator: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value, // 使用枚举默认值
|
||||
param: ''
|
||||
}
|
||||
|
||||
// 使用 nextTick 确保响应式更新完成后再添加新条件
|
||||
await nextTick()
|
||||
if (subGroup.value) {
|
||||
subGroup.value.push(newCondition)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除条件
|
||||
* @param index 条件索引
|
||||
*/
|
||||
const removeCondition = (index: number) => {
|
||||
if (subGroup.value) {
|
||||
subGroup.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新条件
|
||||
* @param index 条件索引
|
||||
* @param condition 条件对象
|
||||
*/
|
||||
const updateCondition = (index: number, condition: TriggerCondition) => {
|
||||
if (subGroup.value) {
|
||||
subGroup.value[index] = condition
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,519 @@
|
||||
<!-- JSON参数输入组件 - 通用版本 -->
|
||||
<template>
|
||||
<!-- 参数配置 -->
|
||||
<div class="w-full space-y-12px">
|
||||
<!-- JSON 输入框 -->
|
||||
<div class="relative">
|
||||
<el-input
|
||||
v-model="paramsJson"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="placeholder"
|
||||
@input="handleParamsChange"
|
||||
:class="{ 'is-error': jsonError }"
|
||||
/>
|
||||
<!-- 查看详细示例弹出层 -->
|
||||
<div class="absolute top-8px right-8px">
|
||||
<el-popover
|
||||
placement="left-start"
|
||||
:width="450"
|
||||
trigger="click"
|
||||
:show-arrow="true"
|
||||
:offset="8"
|
||||
popper-class="json-params-detail-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
type="info"
|
||||
:icon="InfoFilled"
|
||||
circle
|
||||
size="small"
|
||||
:title="JSON_PARAMS_INPUT_CONSTANTS.VIEW_EXAMPLE_TITLE"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 弹出层内容 -->
|
||||
<div class="json-params-detail-content">
|
||||
<div class="flex items-center gap-8px mb-16px">
|
||||
<Icon :icon="titleIcon" class="text-[var(--el-color-primary)] text-18px" />
|
||||
<span class="text-16px font-600 text-[var(--el-text-color-primary)]">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-16px">
|
||||
<!-- 参数列表 -->
|
||||
<div v-if="paramsList.length > 0">
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon :icon="paramsIcon" class="text-[var(--el-color-primary)] text-14px" />
|
||||
<span class="text-14px font-500 text-[var(--el-text-color-primary)]">
|
||||
{{ paramsLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-22px space-y-8px">
|
||||
<div
|
||||
v-for="param in paramsList"
|
||||
:key="param.identifier"
|
||||
class="flex items-center justify-between p-8px bg-[var(--el-fill-color-lighter)] rounded-4px"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="text-12px font-500 text-[var(--el-text-color-primary)]">
|
||||
{{ param.name }}
|
||||
<el-tag v-if="param.required" size="small" type="danger" class="ml-4px">
|
||||
{{ JSON_PARAMS_INPUT_CONSTANTS.REQUIRED_TAG }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="text-11px text-[var(--el-text-color-secondary)]">
|
||||
{{ param.identifier }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<el-tag :type="getParamTypeTag(param.dataType)" size="small">
|
||||
{{ getParamTypeName(param.dataType) }}
|
||||
</el-tag>
|
||||
<span class="text-11px text-[var(--el-text-color-secondary)]">
|
||||
{{ getExampleValue(param) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12px ml-22px">
|
||||
<div class="text-12px text-[var(--el-text-color-secondary)] mb-6px">
|
||||
{{ JSON_PARAMS_INPUT_CONSTANTS.COMPLETE_JSON_FORMAT }}
|
||||
</div>
|
||||
<pre
|
||||
class="p-12px bg-[var(--el-fill-color-light)] rounded-4px text-11px text-[var(--el-text-color-primary)] overflow-x-auto border-l-3px border-[var(--el-color-primary)]"
|
||||
>
|
||||
<code>{{ generateExampleJson() }}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无参数提示 -->
|
||||
<div v-else>
|
||||
<div class="text-center py-16px">
|
||||
<p class="text-14px text-[var(--el-text-color-secondary)]">{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证状态和错误提示 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-8px">
|
||||
<Icon
|
||||
:icon="
|
||||
jsonError
|
||||
? JSON_PARAMS_INPUT_ICONS.STATUS_ICONS.ERROR
|
||||
: JSON_PARAMS_INPUT_ICONS.STATUS_ICONS.SUCCESS
|
||||
"
|
||||
:class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
|
||||
class="text-14px"
|
||||
/>
|
||||
<span
|
||||
:class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
|
||||
class="text-12px"
|
||||
>
|
||||
{{ jsonError || JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_CORRECT }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 快速填充按钮 -->
|
||||
<div v-if="paramsList.length > 0" class="flex items-center gap-8px">
|
||||
<span class="text-12px text-[var(--el-text-color-secondary)]">{{
|
||||
JSON_PARAMS_INPUT_CONSTANTS.QUICK_FILL_LABEL
|
||||
}}</span>
|
||||
<el-button size="small" type="primary" plain @click="fillExampleJson">
|
||||
{{ JSON_PARAMS_INPUT_CONSTANTS.EXAMPLE_DATA_BUTTON }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="clearParams">{{
|
||||
JSON_PARAMS_INPUT_CONSTANTS.CLEAR_BUTTON
|
||||
}}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
IoTDataSpecsDataTypeEnum,
|
||||
JSON_PARAMS_INPUT_CONSTANTS,
|
||||
JSON_PARAMS_INPUT_ICONS,
|
||||
JSON_PARAMS_EXAMPLE_VALUES,
|
||||
JsonParamsInputTypeEnum,
|
||||
type JsonParamsInputType
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
/** JSON参数输入组件 - 通用版本 */
|
||||
defineOptions({ name: 'JsonParamsInput' })
|
||||
|
||||
interface JsonParamsConfig {
|
||||
// 服务配置
|
||||
service?: {
|
||||
name: string
|
||||
inputParams?: any[]
|
||||
}
|
||||
// 事件配置
|
||||
event?: {
|
||||
name: string
|
||||
outputParams?: any[]
|
||||
}
|
||||
// 属性配置
|
||||
properties?: any[]
|
||||
// 自定义配置
|
||||
custom?: {
|
||||
name: string
|
||||
params: any[]
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
config?: JsonParamsConfig
|
||||
type?: JsonParamsInputType
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: JsonParamsInputTypeEnum.SERVICE,
|
||||
placeholder: JSON_PARAMS_INPUT_CONSTANTS.PLACEHOLDER
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
const paramsJson = ref('') // JSON参数字符串
|
||||
const jsonError = ref('') // JSON验证错误信息
|
||||
|
||||
// 计算属性:参数列表
|
||||
const paramsList = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return props.config?.service?.inputParams || []
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return props.config?.event?.outputParams || []
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return props.config?.properties || []
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return props.config?.custom?.params || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:标题
|
||||
const title = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.TITLES.SERVICE(props.config?.service?.name)
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.TITLES.EVENT(props.config?.event?.name)
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.TITLES.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.TITLES.CUSTOM(props.config?.custom?.name)
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.TITLES.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:标题图标
|
||||
const titleIcon = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.SERVICE
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.EVENT
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.CUSTOM
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:参数图标
|
||||
const paramsIcon = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.SERVICE
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.EVENT
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.CUSTOM
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:参数标签
|
||||
const paramsLabel = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.SERVICE
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.EVENT
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.CUSTOM
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:空状态消息
|
||||
const emptyMessage = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.SERVICE
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.EVENT
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.CUSTOM
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:无配置消息
|
||||
const noConfigMessage = computed(() => {
|
||||
switch (props.type) {
|
||||
case JsonParamsInputTypeEnum.SERVICE:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.SERVICE
|
||||
case JsonParamsInputTypeEnum.EVENT:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.EVENT
|
||||
case JsonParamsInputTypeEnum.PROPERTY:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.PROPERTY
|
||||
case JsonParamsInputTypeEnum.CUSTOM:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.CUSTOM
|
||||
default:
|
||||
return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.DEFAULT
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理参数变化事件
|
||||
*/
|
||||
const handleParamsChange = () => {
|
||||
try {
|
||||
jsonError.value = '' // 清除之前的错误
|
||||
|
||||
if (paramsJson.value.trim()) {
|
||||
const parsed = JSON.parse(paramsJson.value)
|
||||
localValue.value = paramsJson.value
|
||||
|
||||
// 额外的参数验证
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填参数
|
||||
for (const param of paramsList.value) {
|
||||
if (param.required && (!parsed[param.identifier] || parsed[param.identifier] === '')) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(param.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localValue.value = ''
|
||||
}
|
||||
|
||||
// 验证通过
|
||||
jsonError.value = ''
|
||||
} catch (error) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_ERROR(
|
||||
error instanceof Error ? error.message : JSON_PARAMS_INPUT_CONSTANTS.UNKNOWN_ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速填充示例数据
|
||||
*/
|
||||
const fillExampleJson = () => {
|
||||
paramsJson.value = generateExampleJson()
|
||||
handleParamsChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空参数
|
||||
*/
|
||||
const clearParams = () => {
|
||||
paramsJson.value = ''
|
||||
localValue.value = ''
|
||||
jsonError.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参数类型名称
|
||||
* @param dataType 数据类型
|
||||
* @returns 类型名称
|
||||
*/
|
||||
const getParamTypeName = (dataType: string) => {
|
||||
// 使用 constants.ts 中已有的 getDataTypeName 函数逻辑
|
||||
const typeMap = {
|
||||
[IoTDataSpecsDataTypeEnum.INT]: '整数',
|
||||
[IoTDataSpecsDataTypeEnum.FLOAT]: '浮点数',
|
||||
[IoTDataSpecsDataTypeEnum.DOUBLE]: '双精度',
|
||||
[IoTDataSpecsDataTypeEnum.TEXT]: '字符串',
|
||||
[IoTDataSpecsDataTypeEnum.BOOL]: '布尔值',
|
||||
[IoTDataSpecsDataTypeEnum.ENUM]: '枚举',
|
||||
[IoTDataSpecsDataTypeEnum.DATE]: '日期',
|
||||
[IoTDataSpecsDataTypeEnum.STRUCT]: '结构体',
|
||||
[IoTDataSpecsDataTypeEnum.ARRAY]: '数组'
|
||||
}
|
||||
return typeMap[dataType] || dataType
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参数类型标签样式
|
||||
* @param dataType 数据类型
|
||||
* @returns 标签样式
|
||||
*/
|
||||
const getParamTypeTag = (dataType: string) => {
|
||||
const tagMap = {
|
||||
[IoTDataSpecsDataTypeEnum.INT]: 'primary',
|
||||
[IoTDataSpecsDataTypeEnum.FLOAT]: 'success',
|
||||
[IoTDataSpecsDataTypeEnum.DOUBLE]: 'success',
|
||||
[IoTDataSpecsDataTypeEnum.TEXT]: 'info',
|
||||
[IoTDataSpecsDataTypeEnum.BOOL]: 'warning',
|
||||
[IoTDataSpecsDataTypeEnum.ENUM]: 'danger',
|
||||
[IoTDataSpecsDataTypeEnum.DATE]: 'primary',
|
||||
[IoTDataSpecsDataTypeEnum.STRUCT]: 'info',
|
||||
[IoTDataSpecsDataTypeEnum.ARRAY]: 'warning'
|
||||
}
|
||||
return tagMap[dataType] || 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取示例值
|
||||
* @param param 参数对象
|
||||
* @returns 示例值
|
||||
*/
|
||||
const getExampleValue = (param: any) => {
|
||||
const exampleConfig =
|
||||
JSON_PARAMS_EXAMPLE_VALUES[param.dataType] || JSON_PARAMS_EXAMPLE_VALUES.DEFAULT
|
||||
return exampleConfig.display
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成示例JSON
|
||||
* @returns JSON字符串
|
||||
*/
|
||||
const generateExampleJson = () => {
|
||||
if (paramsList.value.length === 0) {
|
||||
return '{}'
|
||||
}
|
||||
|
||||
const example = {}
|
||||
paramsList.value.forEach((param) => {
|
||||
const exampleConfig =
|
||||
JSON_PARAMS_EXAMPLE_VALUES[param.dataType] || JSON_PARAMS_EXAMPLE_VALUES.DEFAULT
|
||||
example[param.identifier] = exampleConfig.value
|
||||
})
|
||||
|
||||
return JSON.stringify(example, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理数据回显
|
||||
* @param value 值字符串
|
||||
*/
|
||||
const handleDataDisplay = (value: string) => {
|
||||
if (!value || !value.trim()) {
|
||||
paramsJson.value = ''
|
||||
jsonError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析JSON,如果成功则格式化
|
||||
const parsed = JSON.parse(value)
|
||||
paramsJson.value = JSON.stringify(parsed, null, 2)
|
||||
jsonError.value = ''
|
||||
} catch {
|
||||
// 如果不是有效的JSON,直接使用原字符串
|
||||
paramsJson.value = value
|
||||
jsonError.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部值变化(编辑模式数据回显)
|
||||
watch(
|
||||
() => localValue.value,
|
||||
async (newValue, oldValue) => {
|
||||
// 避免循环更新
|
||||
if (newValue === oldValue) return
|
||||
|
||||
// 使用 nextTick 确保在下一个 tick 中处理数据
|
||||
await nextTick()
|
||||
handleDataDisplay(newValue || '')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 组件挂载后也尝试处理一次数据回显
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (localValue.value) {
|
||||
handleDataDisplay(localValue.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
() => props.config,
|
||||
(newConfig, oldConfig) => {
|
||||
// 只有在配置真正变化时才清空数据
|
||||
if (JSON.stringify(newConfig) !== JSON.stringify(oldConfig)) {
|
||||
// 如果没有外部传入的值,才清空数据
|
||||
if (!localValue.value) {
|
||||
paramsJson.value = ''
|
||||
jsonError.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 弹出层内容样式 */
|
||||
.json-params-detail-content {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 弹出层自定义样式 */
|
||||
:global(.json-params-detail-popover) {
|
||||
max-width: 500px !important;
|
||||
}
|
||||
|
||||
:global(.json-params-detail-popover .el-popover__content) {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
/* JSON 代码块样式 */
|
||||
.json-params-detail-content pre {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!-- 值输入组件 -->
|
||||
<template>
|
||||
<div class="w-full min-w-0">
|
||||
<!-- 布尔值选择 -->
|
||||
<el-select
|
||||
v-if="propertyType === IoTDataSpecsDataTypeEnum.BOOL"
|
||||
v-model="localValue"
|
||||
placeholder="请选择布尔值"
|
||||
class="w-full!"
|
||||
>
|
||||
<el-option label="真 (true)" value="true" />
|
||||
<el-option label="假 (false)" value="false" />
|
||||
</el-select>
|
||||
|
||||
<!-- 枚举值选择 -->
|
||||
<el-select
|
||||
v-else-if="propertyType === IoTDataSpecsDataTypeEnum.ENUM && enumOptions.length > 0"
|
||||
v-model="localValue"
|
||||
placeholder="请选择枚举值"
|
||||
class="w-full!"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in enumOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 范围输入 (between 操作符) -->
|
||||
<div
|
||||
v-else-if="operator === IotRuleSceneTriggerConditionParameterOperatorEnum.BETWEEN.value"
|
||||
class="w-full! flex items-center gap-8px"
|
||||
>
|
||||
<el-input
|
||||
v-model="rangeStart"
|
||||
:type="getInputType()"
|
||||
placeholder="最小值"
|
||||
@input="handleRangeChange"
|
||||
class="flex-1 min-w-0"
|
||||
style="width: auto !important"
|
||||
/>
|
||||
<span class="text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap">至</span>
|
||||
<el-input
|
||||
v-model="rangeEnd"
|
||||
:type="getInputType()"
|
||||
placeholder="最大值"
|
||||
@input="handleRangeChange"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表输入 (in 操作符) -->
|
||||
<div
|
||||
v-else-if="operator === IotRuleSceneTriggerConditionParameterOperatorEnum.IN.value"
|
||||
class="w-full!"
|
||||
>
|
||||
<el-input v-model="localValue" placeholder="请输入值列表,用逗号分隔" class="w-full!">
|
||||
<template #suffix>
|
||||
<el-tooltip content="多个值用逗号分隔,如:1,2,3" placement="top">
|
||||
<Icon
|
||||
icon="ep:question-filled"
|
||||
class="text-[var(--el-text-color-placeholder)] cursor-help"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
<div v-if="listPreview.length > 0" class="mt-8px flex items-center gap-6px flex-wrap">
|
||||
<span class="text-12px text-[var(--el-text-color-secondary)]">解析结果:</span>
|
||||
<el-tag v-for="(item, index) in listPreview" :key="index" size="small" class="m-0">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期时间输入 -->
|
||||
<el-date-picker
|
||||
v-else-if="propertyType === IoTDataSpecsDataTypeEnum.DATE"
|
||||
v-model="dateValue"
|
||||
type="datetime"
|
||||
placeholder="请选择日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="handleDateChange"
|
||||
class="w-full!"
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="isNumericType()"
|
||||
v-model="numberValue"
|
||||
:precision="getPrecision()"
|
||||
:step="getStep()"
|
||||
:min="getMin()"
|
||||
:max="getMax()"
|
||||
placeholder="请输入数值"
|
||||
@change="handleNumberChange"
|
||||
class="w-full!"
|
||||
/>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="localValue"
|
||||
:type="getInputType()"
|
||||
:placeholder="getPlaceholder()"
|
||||
class="w-full!"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-tooltip
|
||||
v-if="propertyConfig?.unit"
|
||||
:content="`单位:${propertyConfig.unit}`"
|
||||
placement="top"
|
||||
>
|
||||
<span class="text-12px text-[var(--el-text-color-secondary)] px-4px">
|
||||
{{ propertyConfig.unit }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import {
|
||||
IoTDataSpecsDataTypeEnum,
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
/** 值输入组件 */
|
||||
defineOptions({ name: 'ValueInput' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
propertyType?: string
|
||||
operator?: string
|
||||
propertyConfig?: any
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
const rangeStart = ref('') // 范围开始值
|
||||
const rangeEnd = ref('') // 范围结束值
|
||||
const dateValue = ref('') // 日期值
|
||||
const numberValue = ref<number>() // 数字值
|
||||
|
||||
/** 计算属性:枚举选项 */
|
||||
const enumOptions = computed(() => {
|
||||
if (props.propertyConfig?.enum) {
|
||||
return props.propertyConfig.enum.map((item: any) => ({
|
||||
label: item.name || item.label || item.value,
|
||||
value: item.value
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
/** 计算属性:列表预览 */
|
||||
const listPreview = computed(() => {
|
||||
if (
|
||||
props.operator === IotRuleSceneTriggerConditionParameterOperatorEnum.IN.value &&
|
||||
localValue.value
|
||||
) {
|
||||
return localValue.value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
/** 判断是否为数字类型 */
|
||||
const isNumericType = () => {
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.INT,
|
||||
IoTDataSpecsDataTypeEnum.FLOAT,
|
||||
IoTDataSpecsDataTypeEnum.DOUBLE
|
||||
].includes((props.propertyType || '') as any)
|
||||
}
|
||||
|
||||
/** 获取输入框类型 */
|
||||
const getInputType = () => {
|
||||
switch (props.propertyType) {
|
||||
case IoTDataSpecsDataTypeEnum.INT:
|
||||
case IoTDataSpecsDataTypeEnum.FLOAT:
|
||||
case IoTDataSpecsDataTypeEnum.DOUBLE:
|
||||
return 'number'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取占位符文本 */
|
||||
const getPlaceholder = () => {
|
||||
const typeMap = {
|
||||
[IoTDataSpecsDataTypeEnum.TEXT]: '请输入字符串',
|
||||
[IoTDataSpecsDataTypeEnum.INT]: '请输入整数',
|
||||
[IoTDataSpecsDataTypeEnum.FLOAT]: '请输入浮点数',
|
||||
[IoTDataSpecsDataTypeEnum.DOUBLE]: '请输入双精度数',
|
||||
[IoTDataSpecsDataTypeEnum.STRUCT]: '请输入 JSON 格式数据',
|
||||
[IoTDataSpecsDataTypeEnum.ARRAY]: '请输入数组格式数据'
|
||||
}
|
||||
return typeMap[props.propertyType || ''] || '请输入值'
|
||||
}
|
||||
|
||||
/** 获取数字精度 */
|
||||
const getPrecision = () => {
|
||||
return props.propertyType === IoTDataSpecsDataTypeEnum.INT ? 0 : 2
|
||||
}
|
||||
|
||||
/** 获取数字步长 */
|
||||
const getStep = () => {
|
||||
return props.propertyType === IoTDataSpecsDataTypeEnum.INT ? 1 : 0.1
|
||||
}
|
||||
|
||||
/** 获取最小值 */
|
||||
const getMin = () => {
|
||||
return props.propertyConfig?.min || undefined
|
||||
}
|
||||
|
||||
/** 获取最大值 */
|
||||
const getMax = () => {
|
||||
return props.propertyConfig?.max || undefined
|
||||
}
|
||||
|
||||
/** 处理范围变化事件 */
|
||||
const handleRangeChange = () => {
|
||||
if (rangeStart.value && rangeEnd.value) {
|
||||
localValue.value = `${rangeStart.value},${rangeEnd.value}`
|
||||
} else {
|
||||
localValue.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理日期变化事件 */
|
||||
const handleDateChange = (value: string) => {
|
||||
localValue.value = value || ''
|
||||
}
|
||||
|
||||
/** 处理数字变化事件 */
|
||||
const handleNumberChange = (value: number | undefined) => {
|
||||
localValue.value = value?.toString() || ''
|
||||
}
|
||||
|
||||
/** 监听操作符变化 */
|
||||
watch(
|
||||
() => props.operator,
|
||||
() => {
|
||||
localValue.value = ''
|
||||
rangeStart.value = ''
|
||||
rangeEnd.value = ''
|
||||
dateValue.value = ''
|
||||
numberValue.value = undefined
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,272 @@
|
||||
<!-- 执行器配置组件 -->
|
||||
<template>
|
||||
<el-card class="border border-[var(--el-border-color-light)] rounded-8px" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-8px">
|
||||
<Icon icon="ep:setting" class="text-[var(--el-color-primary)] text-18px" />
|
||||
<span class="text-16px font-600 text-[var(--el-text-color-primary)]">执行器配置</span>
|
||||
<el-tag size="small" type="info">{{ actions.length }} 个执行器</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<el-button type="primary" size="small" @click="addAction">
|
||||
<Icon icon="ep:plus" />
|
||||
添加执行器
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-0">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="actions.length === 0">
|
||||
<el-empty description="暂无执行器配置">
|
||||
<el-button type="primary" @click="addAction">
|
||||
<Icon icon="ep:plus" />
|
||||
添加第一个执行器
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 执行器列表 -->
|
||||
<div v-else class="space-y-24px">
|
||||
<div
|
||||
v-for="(action, index) in actions"
|
||||
:key="`action-${index}`"
|
||||
class="border-2 border-blue-200 rounded-8px bg-blue-50 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- 执行器头部 - 蓝色主题 -->
|
||||
<div
|
||||
class="flex items-center justify-between p-16px bg-gradient-to-r from-blue-50 to-sky-50 border-b border-blue-200 rounded-t-6px"
|
||||
>
|
||||
<div class="flex items-center gap-12px">
|
||||
<div class="flex items-center gap-8px text-16px font-600 text-blue-700">
|
||||
<div
|
||||
class="w-24px h-24px bg-blue-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span>执行器 {{ index + 1 }}</span>
|
||||
</div>
|
||||
<el-tag :type="getActionTypeTag(action.type)" size="small" class="font-500">
|
||||
{{ getActionTypeLabel(action.type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<el-button
|
||||
v-if="actions.length > 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
@click="removeAction(index)"
|
||||
class="hover:bg-red-50"
|
||||
>
|
||||
<Icon icon="ep:delete" />
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行器内容区域 -->
|
||||
<div class="p-16px space-y-16px">
|
||||
<!-- 执行类型选择 -->
|
||||
<div class="w-full">
|
||||
<el-form-item label="执行类型" required>
|
||||
<el-select
|
||||
:model-value="action.type"
|
||||
@update:model-value="(value) => updateActionType(index, value)"
|
||||
@change="(value) => onActionTypeChange(action, value)"
|
||||
placeholder="请选择执行类型"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in getActionTypeOptions()"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 设备控制配置 -->
|
||||
<DeviceControlConfig
|
||||
v-if="isDeviceAction(action.type)"
|
||||
:model-value="action"
|
||||
@update:model-value="(value) => updateAction(index, value)"
|
||||
/>
|
||||
|
||||
<!-- 告警配置 - 只有恢复告警时才显示 -->
|
||||
<AlertConfig
|
||||
v-if="action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER"
|
||||
:model-value="action.alertConfigId"
|
||||
@update:model-value="(value) => updateActionAlertConfig(index, value)"
|
||||
/>
|
||||
|
||||
<!-- 触发告警提示 - 触发告警时显示 -->
|
||||
<div
|
||||
v-if="action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER"
|
||||
class="border border-[var(--el-border-color-light)] rounded-6px p-16px bg-[var(--el-fill-color-blank)]"
|
||||
>
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon icon="ep:warning" class="text-[var(--el-color-warning)] text-16px" />
|
||||
<span class="text-14px font-600 text-[var(--el-text-color-primary)]">触发告警</span>
|
||||
<el-tag size="small" type="warning">自动执行</el-tag>
|
||||
</div>
|
||||
<div class="text-12px text-[var(--el-text-color-secondary)] leading-relaxed">
|
||||
当触发条件满足时,系统将自动发送告警通知,可在菜单 [告警中心 -> 告警配置] 管理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加提示 -->
|
||||
<div v-if="actions.length > 0" class="text-center py-16px">
|
||||
<el-button type="primary" plain @click="addAction">
|
||||
<Icon icon="ep:plus" />
|
||||
继续添加执行器
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import DeviceControlConfig from '../configs/DeviceControlConfig.vue'
|
||||
import AlertConfig from '../configs/AlertConfig.vue'
|
||||
import type { Action } from '#/api/iot/rule/scene'
|
||||
import {
|
||||
getActionTypeLabel,
|
||||
getActionTypeOptions,
|
||||
IotRuleSceneActionTypeEnum
|
||||
} from '#/views/iot/utils/constants'
|
||||
|
||||
/** 执行器配置组件 */
|
||||
defineOptions({ name: 'ActionSection' })
|
||||
|
||||
const props = defineProps<{
|
||||
actions: Action[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:actions', value: Action[]): void
|
||||
}>()
|
||||
|
||||
const actions = useVModel(props, 'actions', emit)
|
||||
|
||||
/** 获取执行器标签类型(用于 el-tag 的 type 属性) */
|
||||
const getActionTypeTag = (type: number): 'primary' | 'success' | 'info' | 'warning' | 'danger' => {
|
||||
const actionTypeTags = {
|
||||
[IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET]: 'primary',
|
||||
[IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE]: 'success',
|
||||
[IotRuleSceneActionTypeEnum.ALERT_TRIGGER]: 'danger',
|
||||
[IotRuleSceneActionTypeEnum.ALERT_RECOVER]: 'warning'
|
||||
} as const
|
||||
return actionTypeTags[type] || 'info'
|
||||
}
|
||||
|
||||
/** 判断是否为设备执行器类型 */
|
||||
const isDeviceAction = (type: number): boolean => {
|
||||
const deviceActionTypes = [
|
||||
IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
|
||||
IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
] as number[]
|
||||
return deviceActionTypes.includes(type)
|
||||
}
|
||||
|
||||
/** 判断是否为告警执行器类型 */
|
||||
const isAlertAction = (type: number): boolean => {
|
||||
const alertActionTypes = [
|
||||
IotRuleSceneActionTypeEnum.ALERT_TRIGGER,
|
||||
IotRuleSceneActionTypeEnum.ALERT_RECOVER
|
||||
] as number[]
|
||||
return alertActionTypes.includes(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的执行器数据
|
||||
* @returns 默认执行器对象
|
||||
*/
|
||||
const createDefaultActionData = (): Action => {
|
||||
return {
|
||||
type: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET, // 默认为设备属性设置
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
identifier: undefined, // 物模型标识符(服务调用时使用)
|
||||
params: undefined,
|
||||
alertConfigId: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加执行器
|
||||
*/
|
||||
const addAction = () => {
|
||||
const newAction = createDefaultActionData()
|
||||
actions.value.push(newAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除执行器
|
||||
* @param index 执行器索引
|
||||
*/
|
||||
const removeAction = (index: number) => {
|
||||
actions.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新执行器类型
|
||||
* @param index 执行器索引
|
||||
* @param type 执行器类型
|
||||
*/
|
||||
const updateActionType = (index: number, type: number) => {
|
||||
actions.value[index].type = type
|
||||
onActionTypeChange(actions.value[index], type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新执行器
|
||||
* @param index 执行器索引
|
||||
* @param action 执行器对象
|
||||
*/
|
||||
const updateAction = (index: number, action: Action) => {
|
||||
actions.value[index] = action
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新告警配置
|
||||
* @param index 执行器索引
|
||||
* @param alertConfigId 告警配置ID
|
||||
*/
|
||||
const updateActionAlertConfig = (index: number, alertConfigId?: number) => {
|
||||
actions.value[index].alertConfigId = alertConfigId
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听执行器类型变化
|
||||
* @param action 执行器对象
|
||||
* @param type 执行器类型
|
||||
*/
|
||||
const onActionTypeChange = (action: Action, type: number) => {
|
||||
// 清理不相关的配置,确保数据结构干净
|
||||
if (isDeviceAction(type)) {
|
||||
// 设备控制类型:清理告警配置,确保设备参数存在
|
||||
action.alertConfigId = undefined
|
||||
if (!action.params) {
|
||||
action.params = ''
|
||||
}
|
||||
// 如果从其他类型切换到设备控制类型,清空identifier(让用户重新选择)
|
||||
if (action.identifier && type !== action.type) {
|
||||
action.identifier = undefined
|
||||
}
|
||||
} else if (isAlertAction(type)) {
|
||||
action.productId = undefined
|
||||
action.deviceId = undefined
|
||||
action.identifier = undefined // 清理服务标识符
|
||||
action.params = undefined
|
||||
action.alertConfigId = undefined
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user