feat(web-antdv-next): sync IoT module
parent
6315055c08
commit
09970d89a4
|
|
@ -3,37 +3,24 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace AlertConfigApi {
|
||||
/** IoT 告警配置 VO */
|
||||
/** IoT 告警配置 */
|
||||
export interface AlertConfig {
|
||||
id?: number;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
level?: number;
|
||||
status?: number;
|
||||
sceneRuleIds?: number[];
|
||||
receiveUserIds?: number[];
|
||||
receiveUserNames?: string;
|
||||
receiveUserNames?: string[];
|
||||
receiveTypes?: number[];
|
||||
smsTemplateCode?: string;
|
||||
mailTemplateCode?: string;
|
||||
notifyTemplateCode?: string;
|
||||
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>>(
|
||||
|
|
@ -49,20 +36,20 @@ export function getAlertConfig(id: number) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 查询所有告警配置列表 */
|
||||
export function getAlertConfigList() {
|
||||
/** 获取告警配置简单列表 */
|
||||
export function getSimpleAlertConfigList() {
|
||||
return requestClient.get<AlertConfigApi.AlertConfig[]>(
|
||||
'/iot/alert-config/list',
|
||||
'/iot/alert-config/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增告警配置 */
|
||||
export function createAlertConfig(data: AlertConfig) {
|
||||
export function createAlertConfig(data: AlertConfigApi.AlertConfig) {
|
||||
return requestClient.post('/iot/alert-config/create', data);
|
||||
}
|
||||
|
||||
/** 修改告警配置 */
|
||||
export function updateAlertConfig(data: AlertConfig) {
|
||||
export function updateAlertConfig(data: AlertConfigApi.AlertConfig) {
|
||||
return requestClient.put('/iot/alert-config/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -70,25 +57,3 @@ export function updateAlertConfig(data: AlertConfig) {
|
|||
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',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,41 +3,21 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace AlertRecordApi {
|
||||
/** IoT 告警记录 VO */
|
||||
/** IoT 告警记录 */
|
||||
export interface AlertRecord {
|
||||
id?: number;
|
||||
configId?: number;
|
||||
configName?: string;
|
||||
configLevel?: number;
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
deviceMessage?: string;
|
||||
deviceMessage?: any;
|
||||
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>>(
|
||||
|
|
@ -54,29 +34,9 @@ export function getAlertRecord(id: number) {
|
|||
}
|
||||
|
||||
/** 处理告警记录 */
|
||||
export function processAlertRecord(id: number, remark?: string) {
|
||||
export function processAlertRecord(id: number, processRemark?: 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(',') },
|
||||
processRemark,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,11 +150,8 @@ export function importDeviceTemplate() {
|
|||
/** 导入设备 */
|
||||
export function importDevice(file: File, updateSupport: boolean) {
|
||||
return requestClient.upload<IotDeviceApi.DeviceImportRespVO>(
|
||||
'/iot/device/import',
|
||||
{
|
||||
file,
|
||||
updateSupport,
|
||||
},
|
||||
`/iot/device/import?updateSupport=${updateSupport}`,
|
||||
{ file },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +165,7 @@ export function getLatestDeviceProperties(params: any) {
|
|||
|
||||
/** 获取设备属性历史数据 */
|
||||
export function getHistoryDevicePropertyList(params: any) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.DeviceProperty>>(
|
||||
return requestClient.get<IotDeviceApi.DeviceProperty[]>(
|
||||
'/iot/device/property/history-list',
|
||||
{ params },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,39 +3,22 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaFirmwareApi {
|
||||
/** IoT OTA 固件 VO */
|
||||
/** IoT OTA 固件信息 */
|
||||
export interface Firmware {
|
||||
id?: number;
|
||||
name: string;
|
||||
version: string;
|
||||
productId: number;
|
||||
productName?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
fileUrl?: string;
|
||||
fileMd5?: string;
|
||||
fileSize?: number;
|
||||
status?: number;
|
||||
fileDigestAlgorithm?: string;
|
||||
fileDigestValue?: string;
|
||||
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>>(
|
||||
|
|
@ -52,12 +35,12 @@ export function getOtaFirmware(id: number) {
|
|||
}
|
||||
|
||||
/** 新增 OTA 固件 */
|
||||
export function createOtaFirmware(data: IoTOtaFirmware) {
|
||||
export function createOtaFirmware(data: IoTOtaFirmwareApi.Firmware) {
|
||||
return requestClient.post('/iot/ota/firmware/create', data);
|
||||
}
|
||||
|
||||
/** 修改 OTA 固件 */
|
||||
export function updateOtaFirmware(data: IoTOtaFirmware) {
|
||||
export function updateOtaFirmware(data: IoTOtaFirmwareApi.Firmware) {
|
||||
return requestClient.put('/iot/ota/firmware/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -65,26 +48,3 @@ export function updateOtaFirmware(data: IoTOtaFirmware) {
|
|||
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 } },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,45 +3,21 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaTaskApi {
|
||||
/** IoT OTA 升级任务 VO */
|
||||
/** IoT OTA 升级任务 */
|
||||
export interface Task {
|
||||
id?: number;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
firmwareId: number;
|
||||
firmwareName?: string;
|
||||
productId?: number;
|
||||
productName?: string;
|
||||
firmwareId?: number;
|
||||
status?: number;
|
||||
deviceScope?: number;
|
||||
deviceIds?: number[];
|
||||
status?: number;
|
||||
successCount?: number;
|
||||
failureCount?: number;
|
||||
pendingCount?: number;
|
||||
deviceTotalCount?: number;
|
||||
deviceSuccessCount?: 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>>(
|
||||
|
|
@ -56,43 +32,11 @@ export function getOtaTask(id: number) {
|
|||
}
|
||||
|
||||
/** 新增 OTA 升级任务 */
|
||||
export function createOtaTask(data: OtaTask) {
|
||||
export function createOtaTask(data: IoTOtaTaskApi.Task) {
|
||||
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}`);
|
||||
return requestClient.post(`/iot/ota/task/cancel?id=${id}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,44 +3,24 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IoTOtaTaskRecordApi {
|
||||
/** IoT OTA 升级任务记录 VO */
|
||||
/** IoT OTA 升级任务记录 */
|
||||
export interface TaskRecord {
|
||||
id?: number;
|
||||
taskId: number;
|
||||
taskName?: string;
|
||||
deviceId: number;
|
||||
deviceName?: string;
|
||||
firmwareId?: number;
|
||||
firmwareName?: string;
|
||||
firmwareVersion?: string;
|
||||
taskId?: number;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
currentVersion?: string;
|
||||
fromFirmwareId?: number;
|
||||
fromFirmwareVersion?: string;
|
||||
status?: number;
|
||||
progress?: number;
|
||||
errorMessage?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
createTime?: Date;
|
||||
description?: string;
|
||||
updateTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @AI:这里应该拿到 IoTOtaTaskRecordApi 里
|
||||
/** 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>>(
|
||||
|
|
@ -49,48 +29,12 @@ export function getOtaTaskRecordPage(params: PageParam) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 查询 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}`);
|
||||
}
|
||||
|
||||
/** 取消升级任务记录 */
|
||||
/** 取消 OTA 升级任务记录 */
|
||||
export function cancelOtaTaskRecord(id: number) {
|
||||
return requestClient.put(`/iot/ota/task/record/cancel?id=${id}`);
|
||||
}
|
||||
|
||||
/** 获取升级任务记录状态统计 */
|
||||
/** 获取 OTA 升级任务记录状态统计 */
|
||||
export function getOtaTaskRecordStatusStatistics(
|
||||
firmwareId?: number,
|
||||
taskId?: number,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ export namespace IotProductCategoryApi {
|
|||
export interface ProductCategory {
|
||||
id?: number; // 分类 ID
|
||||
name: string; // 分类名称
|
||||
parentId?: number; // 父级分类 ID
|
||||
sort?: number; // 分类排序
|
||||
status?: number; // 分类状态
|
||||
description?: string; // 分类描述
|
||||
createTime?: string; // 创建时间
|
||||
createTime?: Date; // 创建时间
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ export namespace IotProductApi {
|
|||
deviceType?: number; // 设备类型
|
||||
netType?: number; // 联网方式
|
||||
serializeType?: string; // 序列化类型
|
||||
dataFormat?: number; // 数据格式
|
||||
validateType?: number; // 认证方式
|
||||
registerEnabled?: boolean; // 是否开启动态注册
|
||||
deviceCount?: number; // 设备数量
|
||||
createTime?: Date; // 创建时间
|
||||
|
|
@ -103,3 +101,10 @@ export function getProductByKey(productKey: string) {
|
|||
params: { productKey },
|
||||
});
|
||||
}
|
||||
|
||||
/** 同步产品物模型 TDengine 超级表结构 */
|
||||
export function syncProductPropertyTable(productId: number) {
|
||||
return requestClient.post(
|
||||
`/iot/product/sync-property-table?productId=${productId}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,45 +3,21 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace DataRuleApi {
|
||||
/** IoT 数据流转规则 VO */
|
||||
export interface Rule {
|
||||
/** IoT 数据流转规则 */
|
||||
export interface DataRule {
|
||||
id?: number;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
sourceConfigs?: SourceConfig[];
|
||||
sourceConfigs?: any[];
|
||||
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>>(
|
||||
return requestClient.get<PageResult<DataRuleApi.DataRule>>(
|
||||
'/iot/data-rule/page',
|
||||
{ params },
|
||||
);
|
||||
|
|
@ -49,16 +25,16 @@ export function getDataRulePage(params: PageParam) {
|
|||
|
||||
/** 查询数据流转规则详情 */
|
||||
export function getDataRule(id: number) {
|
||||
return requestClient.get<DataRuleApi.Rule>(`/iot/data-rule/get?id=${id}`);
|
||||
return requestClient.get<DataRuleApi.DataRule>(`/iot/data-rule/get?id=${id}`);
|
||||
}
|
||||
|
||||
/** 新增数据流转规则 */
|
||||
export function createDataRule(data: DataRule) {
|
||||
export function createDataRule(data: DataRuleApi.DataRule) {
|
||||
return requestClient.post('/iot/data-rule/create', data);
|
||||
}
|
||||
|
||||
/** 修改数据流转规则 */
|
||||
export function updateDataRule(data: DataRule) {
|
||||
export function updateDataRule(data: DataRuleApi.DataRule) {
|
||||
return requestClient.put('/iot/data-rule/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -66,18 +42,3 @@ export function updateDataRule(data: DataRule) {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,101 +2,147 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
interface BaseConfig {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export namespace DataSinkApi {
|
||||
/** IoT 数据流转目的 VO */
|
||||
export interface Sink {
|
||||
export interface DataSink {
|
||||
id?: number;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
type: string;
|
||||
config?: any;
|
||||
direction?: number;
|
||||
type?: number;
|
||||
config?:
|
||||
| DatabaseConfig
|
||||
| HttpConfig
|
||||
| KafkaMQConfig
|
||||
| MqttConfig
|
||||
| RabbitMQConfig
|
||||
| RedisStreamMQConfig
|
||||
| RocketMQConfig
|
||||
| TcpConfig
|
||||
| WebSocketConfig;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** HTTP 配置 */
|
||||
export interface HttpConfig extends BaseConfig {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** TCP 配置 */
|
||||
export interface TcpConfig extends BaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
connectTimeoutMs: number;
|
||||
readTimeoutMs: number;
|
||||
ssl: boolean;
|
||||
sslCertPath: string;
|
||||
dataFormat: string;
|
||||
heartbeatIntervalMs: number;
|
||||
reconnectIntervalMs: number;
|
||||
maxReconnectAttempts: number;
|
||||
}
|
||||
|
||||
/** WebSocket 配置 */
|
||||
export interface WebSocketConfig extends BaseConfig {
|
||||
serverUrl: string;
|
||||
connectTimeoutMs: number;
|
||||
sendTimeoutMs: number;
|
||||
heartbeatIntervalMs: number;
|
||||
heartbeatMessage: string;
|
||||
subprotocols: string;
|
||||
customHeaders: string;
|
||||
verifySslCert: boolean;
|
||||
dataFormat: string;
|
||||
reconnectIntervalMs: number;
|
||||
maxReconnectAttempts: number;
|
||||
enableCompression: boolean;
|
||||
sendRetryCount: number;
|
||||
sendRetryIntervalMs: number;
|
||||
}
|
||||
|
||||
/** MQTT 配置 */
|
||||
export interface MqttConfig extends BaseConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
clientId: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
/** Database 配置 */
|
||||
export interface DatabaseConfig extends BaseConfig {
|
||||
jdbcUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
/** RocketMQ 配置 */
|
||||
export interface RocketMQConfig extends BaseConfig {
|
||||
nameServer: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
group: string;
|
||||
topic: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
/** Kafka 配置 */
|
||||
export interface KafkaMQConfig extends BaseConfig {
|
||||
bootstrapServers: string;
|
||||
username: string;
|
||||
password: string;
|
||||
ssl: boolean;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
/** RabbitMQ 配置 */
|
||||
export interface RabbitMQConfig extends BaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
virtualHost: string;
|
||||
username: string;
|
||||
password: string;
|
||||
exchange: string;
|
||||
routingKey: string;
|
||||
queue: string;
|
||||
}
|
||||
|
||||
/** Redis Stream MQ 配置 */
|
||||
export interface RedisStreamMQConfig extends BaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
database: number;
|
||||
topic: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** IoT 数据流转目的 */
|
||||
export interface DataSinkVO {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
type?: string;
|
||||
config?: any;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** IoT 数据目的类型枚举 */
|
||||
export enum IotDataSinkTypeEnum {
|
||||
HTTP = 'HTTP',
|
||||
KAFKA = 'KAFKA',
|
||||
MQTT = 'MQTT',
|
||||
RABBITMQ = 'RABBITMQ',
|
||||
REDIS_STREAM = 'REDIS_STREAM',
|
||||
ROCKETMQ = 'ROCKETMQ',
|
||||
}
|
||||
|
||||
/** 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 const IotDataSinkTypeEnum = {
|
||||
HTTP: 1,
|
||||
TCP: 2,
|
||||
WEBSOCKET: 3,
|
||||
MQTT: 10,
|
||||
DATABASE: 20,
|
||||
REDIS_STREAM: 21,
|
||||
ROCKETMQ: 30,
|
||||
RABBITMQ: 31,
|
||||
KAFKA: 32,
|
||||
} as const;
|
||||
|
||||
/** 查询数据流转目的分页 */
|
||||
export function getDataSinkPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<DataSinkApi.Sink>>(
|
||||
return requestClient.get<PageResult<DataSinkApi.DataSink>>(
|
||||
'/iot/data-sink/page',
|
||||
{ params },
|
||||
);
|
||||
|
|
@ -104,26 +150,23 @@ export function getDataSinkPage(params: PageParam) {
|
|||
|
||||
/** 查询数据流转目的详情 */
|
||||
export function getDataSink(id: number) {
|
||||
return requestClient.get<DataSinkApi.Sink>(`/iot/data-sink/get?id=${id}`);
|
||||
return requestClient.get<DataSinkApi.DataSink>(`/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');
|
||||
return requestClient.get<DataSinkApi.DataSink[]>(
|
||||
'/iot/data-sink/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增数据流转目的 */
|
||||
export function createDataSink(data: DataSinkVO) {
|
||||
export function createDataSink(data: DataSinkApi.DataSink) {
|
||||
return requestClient.post('/iot/data-sink/create', data);
|
||||
}
|
||||
|
||||
/** 修改数据流转目的 */
|
||||
export function updateDataSink(data: DataSinkVO) {
|
||||
export function updateDataSink(data: DataSinkApi.DataSink) {
|
||||
return requestClient.put('/iot/data-sink/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -131,18 +174,3 @@ export function updateDataSink(data: DataSinkVO) {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,25 +11,20 @@ export namespace RuleSceneApi {
|
|||
status?: number;
|
||||
triggers?: Trigger[];
|
||||
actions?: Action[];
|
||||
lastTriggeredTime?: Date;
|
||||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 场景联动规则的触发器 */
|
||||
export interface Trigger {
|
||||
type?: string;
|
||||
type?: number;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
cronExpression?: string;
|
||||
conditionGroups?: TriggerConditionGroup[];
|
||||
}
|
||||
|
||||
/** 场景联动规则的触发条件组 */
|
||||
export interface TriggerConditionGroup {
|
||||
conditions?: TriggerCondition[];
|
||||
operator?: string;
|
||||
conditionGroups?: TriggerCondition[][]; // 后端结构:List<List<TriggerCondition>>;外层「或」、组内「且」
|
||||
}
|
||||
|
||||
/** 场景联动规则的触发条件 */
|
||||
|
|
@ -39,72 +34,22 @@ export namespace RuleSceneApi {
|
|||
identifier?: string;
|
||||
operator?: string;
|
||||
value?: any;
|
||||
type?: string;
|
||||
type?: number;
|
||||
param?: string;
|
||||
}
|
||||
|
||||
/** 场景联动规则的动作 */
|
||||
export interface Action {
|
||||
type?: string;
|
||||
type?: number;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
value?: any;
|
||||
alertConfigId?: number;
|
||||
params?: string;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @haohao:貌似下面的,和 RuleSceneApi 重复了。
|
||||
/** 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;
|
||||
param?: string;
|
||||
}
|
||||
|
||||
/** IoT 场景联动规则动作 */
|
||||
export interface Action {
|
||||
type?: string;
|
||||
productId?: number;
|
||||
deviceId?: number;
|
||||
identifier?: string;
|
||||
value?: any;
|
||||
alertConfigId?: number;
|
||||
params?: string;
|
||||
}
|
||||
|
||||
/** 查询场景联动规则分页 */
|
||||
export function getSceneRulePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<RuleSceneApi.SceneRule>>(
|
||||
|
|
@ -121,12 +66,12 @@ export function getSceneRule(id: number) {
|
|||
}
|
||||
|
||||
/** 新增场景联动规则 */
|
||||
export function createSceneRule(data: IotSceneRule) {
|
||||
export function createSceneRule(data: RuleSceneApi.SceneRule) {
|
||||
return requestClient.post('/iot/scene-rule/create', data);
|
||||
}
|
||||
|
||||
/** 修改场景联动规则 */
|
||||
export function updateSceneRule(data: IotSceneRule) {
|
||||
export function updateSceneRule(data: RuleSceneApi.SceneRule) {
|
||||
return requestClient.put('/iot/scene-rule/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -135,14 +80,6 @@ export function deleteSceneRule(id: number) {
|
|||
return requestClient.delete(`/iot/scene-rule/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除场景联动规则 */
|
||||
// TODO @haohao:貌似用上。
|
||||
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`, {
|
||||
|
|
|
|||
|
|
@ -17,18 +17,6 @@ export namespace IotStatisticsApi {
|
|||
productCategoryDeviceCounts: Record<string, number>; // 按品类统计的设备数量
|
||||
}
|
||||
|
||||
/** 时间戳-数值的键值对类型 */
|
||||
export interface TimeValueItem {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/** 消息统计数据类型 */
|
||||
export interface DeviceMessageSummary {
|
||||
statType: number;
|
||||
upstreamCounts: TimeValueItem[];
|
||||
downstreamCounts: TimeValueItem[];
|
||||
}
|
||||
|
||||
/** 设备消息数量统计(按日期) */
|
||||
export interface DeviceMessageSummaryByDateRespVO {
|
||||
time: string; // 时间轴
|
||||
|
|
|
|||
|
|
@ -1,126 +1,209 @@
|
|||
import type { Rule } from 'antdv-next/es/form';
|
||||
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace ThingModelApi {
|
||||
/** IoT 物模型数据 VO */
|
||||
/** IoT 物模型数据 */
|
||||
export interface ThingModel {
|
||||
id?: number;
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
type: string;
|
||||
property?: ThingModelProperty;
|
||||
event?: ThingModelEvent;
|
||||
service?: ThingModelService;
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
dataType?: string;
|
||||
type?: number; // 参见 IoTThingModelTypeEnum 枚举类
|
||||
property?: Property;
|
||||
event?: Event;
|
||||
service?: Service;
|
||||
}
|
||||
|
||||
/** IoT 物模型属性 */
|
||||
export interface Property {
|
||||
identifier: string;
|
||||
name: string;
|
||||
accessMode: string;
|
||||
dataType: string;
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
accessMode?: string;
|
||||
required?: boolean;
|
||||
dataType?: string;
|
||||
description?: string;
|
||||
dataSpecs?: any;
|
||||
dataSpecsList?: any[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型服务 */
|
||||
export interface Service {
|
||||
identifier: string;
|
||||
name: string;
|
||||
callType: string;
|
||||
inputData?: any[];
|
||||
outputData?: any[];
|
||||
desc?: string;
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
callType?: string;
|
||||
description?: string;
|
||||
inputParams?: Param[];
|
||||
outputParams?: Param[];
|
||||
method?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型事件 */
|
||||
export interface Event {
|
||||
identifier: string;
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
description?: string;
|
||||
outputParams?: Param[];
|
||||
method?: string;
|
||||
}
|
||||
|
||||
/** IoT 物模型参数 */
|
||||
export interface Param {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
direction?: string;
|
||||
paraOrder?: number;
|
||||
dataType?: string;
|
||||
dataSpecs?: any;
|
||||
dataSpecsList?: any[];
|
||||
}
|
||||
|
||||
/** IoT 物模型 TSL(树形)响应 */
|
||||
export interface ThingModelTSL {
|
||||
productId?: number;
|
||||
productKey?: string;
|
||||
properties?: Property[];
|
||||
events?: Event[];
|
||||
services?: Service[];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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;
|
||||
/** 生成「必填 + 数字」类校验器:拼到 size / length / 枚举值上 */
|
||||
function buildRequiredNumberValidator(label: string) {
|
||||
return (_rule: any, value: any, callback: any) => {
|
||||
if (isEmpty(value)) {
|
||||
callback(new Error(`${label}不能为空`));
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(Number(value))) {
|
||||
callback(new Error(`${label}必须是数字`));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 生成「标识符样式」名称校验器:开头需为中文 / 英文 / 数字,整体仅允许中文、英文、数字、下划线、短划线,长度 ≤ 20 */
|
||||
export function buildIdentifierLikeNameValidator(label: string) {
|
||||
return (_rule: any, value: string, callback: any) => {
|
||||
if (isEmpty(value)) {
|
||||
callback(new Error(`${label}不能为空`));
|
||||
return;
|
||||
}
|
||||
if (!/^[一-龥A-Za-z0-9]/.test(value)) {
|
||||
callback(new Error(`${label}必须以中文、英文字母或数字开头`));
|
||||
return;
|
||||
}
|
||||
if (!/^[一-龥A-Za-z0-9][\w一-龥-]*$/.test(value)) {
|
||||
callback(
|
||||
new Error(`${label}只能包含中文、英文字母、数字、下划线和短划线`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (value.length > 20) {
|
||||
callback(new Error(`${label}长度不能超过 20 个字符`));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
/** IoT 物模型表单校验规则 */
|
||||
export interface ThingModelFormRules {
|
||||
[key: string]: any;
|
||||
}
|
||||
export const ThingModelFormRules: Record<string, Rule[]> = {
|
||||
name: [
|
||||
{ required: true, message: '功能名称不能为空', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[一-龥A-Za-z0-9][一-龥A-Za-z0-9\-_/.]{0,29}$/,
|
||||
message:
|
||||
'支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
|
||||
identifier: [
|
||||
{ required: true, message: '标识符不能为空', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/,
|
||||
message: '支持大小写字母、数字和下划线,必须以字母开头,不超过 32 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string, callback: any) => {
|
||||
const reservedKeywords = [
|
||||
'set',
|
||||
'get',
|
||||
'post',
|
||||
'property',
|
||||
'event',
|
||||
'time',
|
||||
'value',
|
||||
];
|
||||
if (reservedKeywords.includes(value)) {
|
||||
callback(
|
||||
new Error(
|
||||
'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
callback(new Error('标识符不能是纯数字'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
childDataType: [{ required: true, message: '元素类型不能为空' }],
|
||||
size: [
|
||||
{
|
||||
required: true,
|
||||
validator: buildRequiredNumberValidator('元素个数'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
length: [
|
||||
{
|
||||
required: true,
|
||||
validator: buildRequiredNumberValidator('文本长度'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
accessMode: [
|
||||
{ required: true, message: '请选择读写类型', trigger: 'change' },
|
||||
],
|
||||
callType: [{ required: true, message: '请选择调用方式', trigger: 'change' }],
|
||||
eventType: [{ required: true, message: '请选择事件类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
/** 验证布尔型名称 */
|
||||
export function validateBoolName(_rule: any, value: any, callback: any) {
|
||||
if (value) {
|
||||
callback();
|
||||
} else {
|
||||
callback(new Error('枚举描述不能为空'));
|
||||
}
|
||||
}
|
||||
/** 校验布尔值名称 */
|
||||
export const validateBoolName = buildIdentifierLikeNameValidator('布尔值名称');
|
||||
|
||||
/** 查询产品物模型分页 */
|
||||
export function getThingModelPage(params: PageParam) {
|
||||
|
|
@ -141,17 +224,19 @@ export function getThingModel(id: number) {
|
|||
export function getThingModelListByProductId(productId: number) {
|
||||
return requestClient.get<ThingModelApi.ThingModel[]>(
|
||||
'/iot/thing-model/list',
|
||||
{ params: { productId } },
|
||||
{
|
||||
params: { productId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增物模型 */
|
||||
export function createThingModel(data: ThingModelData) {
|
||||
export function createThingModel(data: ThingModelApi.ThingModel) {
|
||||
return requestClient.post('/iot/thing-model/create', data);
|
||||
}
|
||||
|
||||
/** 修改物模型 */
|
||||
export function updateThingModel(data: ThingModelData) {
|
||||
export function updateThingModel(data: ThingModelApi.ThingModel) {
|
||||
return requestClient.put('/iot/thing-model/update', data);
|
||||
}
|
||||
|
||||
|
|
@ -161,26 +246,11 @@ export function deleteThingModel(id: number) {
|
|||
}
|
||||
|
||||
/** 获取物模型 TSL */
|
||||
export function getThingModelTSL(productId: number) {
|
||||
return requestClient.get<ThingModelApi.ThingModel[]>(
|
||||
export function getThingModelTSLByProductId(productId: number) {
|
||||
return requestClient.get<ThingModelApi.ThingModelTSL>(
|
||||
'/iot/thing-model/get-tsl',
|
||||
{ params: { productId } },
|
||||
{
|
||||
params: { productId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 导入物模型 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 },
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export namespace SystemMailTemplateApi {
|
|||
createTime: Date;
|
||||
}
|
||||
|
||||
/** 邮件模版精简信息 */
|
||||
export interface MailTemplateSimple {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** 邮件发送信息 */
|
||||
export interface MailSendReqVO {
|
||||
toMails: string[];
|
||||
|
|
@ -35,6 +42,13 @@ export function getMailTemplatePage(params: PageParam) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 查询邮件模版精简列表 */
|
||||
export function getSimpleMailTemplateList() {
|
||||
return requestClient.get<SystemMailTemplateApi.MailTemplateSimple[]>(
|
||||
'/system/mail-template/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询邮件模版详情 */
|
||||
export function getMailTemplate(id: number) {
|
||||
return requestClient.get<SystemMailTemplateApi.MailTemplate>(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export namespace SystemNotifyTemplateApi {
|
|||
remark: string;
|
||||
}
|
||||
|
||||
/** 站内信模板精简信息 */
|
||||
export interface NotifyTemplateSimple {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** 发送站内信请求 */
|
||||
export interface NotifySendReqVO {
|
||||
userId: number;
|
||||
|
|
@ -33,6 +40,13 @@ export function getNotifyTemplatePage(params: PageParam) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 查询站内信模板精简列表 */
|
||||
export function getSimpleNotifyTemplateList() {
|
||||
return requestClient.get<SystemNotifyTemplateApi.NotifyTemplateSimple[]>(
|
||||
'/system/notify-template/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询站内信模板详情 */
|
||||
export function getNotifyTemplate(id: number) {
|
||||
return requestClient.get<SystemNotifyTemplateApi.NotifyTemplate>(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ export namespace SystemSmsTemplateApi {
|
|||
createTime?: Date;
|
||||
}
|
||||
|
||||
/** 短信模板精简信息 */
|
||||
export interface SmsTemplateSimple {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** 发送短信请求 */
|
||||
export interface SmsSendReqVO {
|
||||
mobile: string;
|
||||
|
|
@ -35,6 +42,13 @@ export function getSmsTemplatePage(params: PageParam) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 查询短信模板精简列表 */
|
||||
export function getSimpleSmsTemplateList() {
|
||||
return requestClient.get<SystemSmsTemplateApi.SmsTemplateSimple[]>(
|
||||
'/system/sms-template/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询短信模板详情 */
|
||||
export function getSmsTemplate(id: number) {
|
||||
return requestClient.get<SystemSmsTemplateApi.SmsTemplate>(
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><path fill="url(#b)" fill-rule="evenodd" d="M6.958.42C6.444.216 5.61.216 5.098.42L1.15 1.975c-.77.304-.77.797 0 1.1l3.947 1.558c.514.202 1.347.202 1.86 0l3.948-1.557c.77-.304.77-.797 0-1.1L6.958.418ZM4.715 11.788a.857.857 0 0 0 .3.056c.383 0 .671-.295.671-.7V6.404c0-.49-.364-1.007-.817-1.177L1.09 3.805a.808.808 0 0 0-.284-.056c-.353 0-.581.275-.581.7V9.19c0 .508.33 1.014.763 1.177l3.726 1.422Zm2.229-.024h-.02l.073.003c.074.004.154.009.227-.019L11 10.367c.45-.168.83-.686.83-1.177V4.45c0-.413-.29-.7-.673-.7a.965.965 0 0 0-.317.055l-3.72 1.422c-.44.165-.75.67-.75 1.177v4.74c0 .42.218.621.575.621Z" clip-rule="evenodd"/></g><defs><linearGradient id="b" x1=".226" x2="11.803" y1=".267" y2="11.871" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
|
|
@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
children: [
|
||||
{
|
||||
path: 'product/detail/:id',
|
||||
path: 'product/product/detail/:id',
|
||||
name: 'IoTProductDetail',
|
||||
meta: {
|
||||
title: '产品详情',
|
||||
|
|
@ -30,14 +30,13 @@ const routes: RouteRecordRaw[] = [
|
|||
component: () => import('#/views/iot/device/device/detail/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ota/firmware/detail/:id',
|
||||
path: 'ota/operation/firmware/detail/:id',
|
||||
name: 'IoTOtaFirmwareDetail',
|
||||
meta: {
|
||||
title: '固件详情',
|
||||
activePath: '/iot/ota',
|
||||
activePath: '/iot/operation/ota/firmware',
|
||||
},
|
||||
component: () =>
|
||||
import('#/views/iot/ota/modules/firmware-detail/index.vue'),
|
||||
component: () => import('#/views/iot/ota/firmware/detail/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,26 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AlertConfigApi } from '#/api/iot/alert/config';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import {
|
||||
CommonStatusEnum,
|
||||
DICT_TYPE,
|
||||
IotAlertReceiveTypeEnum,
|
||||
} 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';
|
||||
import { MailTemplateSelect } from '#/views/system/mail/template/components';
|
||||
import { NotifyTemplateSelect } from '#/views/system/notify/template/components';
|
||||
import { SmsTemplateSelect } from '#/views/system/sms/template/components';
|
||||
|
||||
function hasReceiveType(values: Partial<Record<string, any>>, type: number) {
|
||||
return Array.isArray(values.receiveTypes) && values.receiveTypes.includes(type);
|
||||
}
|
||||
|
||||
/** 新增/修改告警配置的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
|
|
@ -31,7 +45,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'description',
|
||||
label: '配置描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置描述',
|
||||
rows: 3,
|
||||
|
|
@ -56,6 +70,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: CommonStatusEnum.ENABLE,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -69,6 +84,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
mode: 'multiple',
|
||||
placeholder: '请选择关联的场景联动规则',
|
||||
},
|
||||
defaultValue: [],
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -82,6 +98,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
mode: 'multiple',
|
||||
placeholder: '请选择接收的用户',
|
||||
},
|
||||
defaultValue: [],
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -93,8 +110,63 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
mode: 'multiple',
|
||||
placeholder: '请选择接收类型',
|
||||
},
|
||||
defaultValue: [],
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'smsTemplateCode',
|
||||
label: '短信模板',
|
||||
component: markRaw(SmsTemplateSelect),
|
||||
dependencies: {
|
||||
triggerFields: ['receiveTypes'],
|
||||
show: (values) => hasReceiveType(values, IotAlertReceiveTypeEnum.SMS),
|
||||
trigger: async (values, formApi) => {
|
||||
if (
|
||||
!hasReceiveType(values, IotAlertReceiveTypeEnum.SMS) &&
|
||||
values.smsTemplateCode
|
||||
) {
|
||||
await formApi.setFieldValue('smsTemplateCode', undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'mailTemplateCode',
|
||||
label: '邮件模板',
|
||||
component: markRaw(MailTemplateSelect),
|
||||
dependencies: {
|
||||
triggerFields: ['receiveTypes'],
|
||||
show: (values) => hasReceiveType(values, IotAlertReceiveTypeEnum.MAIL),
|
||||
trigger: async (values, formApi) => {
|
||||
if (
|
||||
!hasReceiveType(values, IotAlertReceiveTypeEnum.MAIL) &&
|
||||
values.mailTemplateCode
|
||||
) {
|
||||
await formApi.setFieldValue('mailTemplateCode', undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'notifyTemplateCode',
|
||||
label: '站内信模板',
|
||||
component: markRaw(NotifyTemplateSelect),
|
||||
dependencies: {
|
||||
triggerFields: ['receiveTypes'],
|
||||
show: (values) => hasReceiveType(values, IotAlertReceiveTypeEnum.NOTIFY),
|
||||
trigger: async (values, formApi) => {
|
||||
if (
|
||||
!hasReceiveType(values, IotAlertReceiveTypeEnum.NOTIFY) &&
|
||||
values.notifyTemplateCode
|
||||
) {
|
||||
await formApi.setFieldValue('notifyTemplateCode', undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -133,9 +205,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<AlertConfigApi.AlertConfig>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '配置编号',
|
||||
|
|
@ -155,7 +226,10 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
field: 'level',
|
||||
title: '告警级别',
|
||||
minWidth: 100,
|
||||
slots: { default: 'level' },
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_ALERT_LEVEL },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
|
|
@ -170,7 +244,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
field: 'sceneRuleIds',
|
||||
title: '关联场景联动规则',
|
||||
minWidth: 150,
|
||||
slots: { default: 'sceneRules' },
|
||||
formatter: ({ cellValue }) => `${cellValue?.length || 0} 条`,
|
||||
},
|
||||
{
|
||||
field: 'receiveUserNames',
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AlertConfigApi } from '#/api/iot/alert/config';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { message, Tag } from 'antdv-next';
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteAlertConfig, getAlertConfigPage } from '#/api/iot/alert/config';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import AlertConfigForm from '../modules/alert-config-form.vue';
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'IoTAlertConfig' });
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: AlertConfigForm,
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
|
|
@ -25,42 +25,6 @@ function handleRefresh() {
|
|||
gridApi.query();
|
||||
}
|
||||
|
||||
// 获取告警级别文本
|
||||
function getLevelText(level?: number) {
|
||||
const levelMap: Record<number, string> = {
|
||||
1: '提示',
|
||||
2: '一般',
|
||||
3: '警告',
|
||||
4: '严重',
|
||||
5: '紧急',
|
||||
};
|
||||
return level ? levelMap[level] || `级别${level}` : '-';
|
||||
}
|
||||
|
||||
// 获取告警级别颜色
|
||||
function getLevelColor(level?: number) {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange',
|
||||
4: 'red',
|
||||
5: 'purple',
|
||||
};
|
||||
return level ? colorMap[level] || 'default' : 'default';
|
||||
}
|
||||
|
||||
// 获取接收类型文本
|
||||
function getReceiveTypeText(type?: number) {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '站内信',
|
||||
2: '邮箱',
|
||||
3: '短信',
|
||||
4: '微信',
|
||||
5: '钉钉',
|
||||
};
|
||||
return type ? typeMap[type] || `类型${type}` : '-';
|
||||
}
|
||||
|
||||
/** 创建告警配置 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
|
|
@ -78,10 +42,8 @@ async function handleDelete(row: AlertConfigApi.AlertConfig) {
|
|||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteAlertConfig(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
await deleteAlertConfig(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
|
|
@ -130,36 +92,21 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('ui.actionTitle.create', ['告警配置']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:alert-config:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 告警级别列 -->
|
||||
<template #level="{ row }">
|
||||
<Tag :color="getLevelColor(row.level)">
|
||||
{{ getLevelText(row.level) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 关联场景联动规则列 -->
|
||||
<template #sceneRules="{ row }">
|
||||
<span>{{ row.sceneRuleIds?.length || 0 }} 条</span>
|
||||
</template>
|
||||
|
||||
<!-- 接收类型列 -->
|
||||
<template #receiveTypes="{ row }">
|
||||
<Tag
|
||||
<DictTag
|
||||
v-for="(type, index) in row.receiveTypes"
|
||||
:key="index"
|
||||
:type="DICT_TYPE.IOT_ALERT_RECEIVE_TYPE"
|
||||
:value="type"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ getReceiveTypeText(type) }}
|
||||
</Tag>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
|
|
@ -167,6 +114,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:alert-config:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
|
@ -174,6 +122,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:alert-config:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { AlertConfigApi } from '#/api/iot/alert/config';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
|
@ -15,9 +15,7 @@ import {
|
|||
} from '#/api/iot/alert/config';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../config/data';
|
||||
|
||||
defineOptions({ name: 'IoTAlertConfigForm' });
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<AlertConfigApi.AlertConfig>();
|
||||
|
|
@ -32,8 +30,9 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 140,
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
|
|
@ -68,13 +67,6 @@ const [Modal, modalApi] = useVbenModal({
|
|||
// 加载数据
|
||||
const data = modalApi.getData<AlertConfigApi.AlertConfig>();
|
||||
if (!data || !data.id) {
|
||||
// 新增时设置默认值
|
||||
await formApi.setValues({
|
||||
status: 0,
|
||||
sceneRuleIds: [],
|
||||
receiveUserIds: [],
|
||||
receiveTypes: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AlertRecordApi } from '#/api/iot/alert/record';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
|
@ -9,6 +12,12 @@ import { getSimpleDeviceList } from '#/api/iot/device/device';
|
|||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 关联数据 */
|
||||
let productList: IotProductApi.Product[] = [];
|
||||
let deviceList: IotDeviceApi.Device[] = [];
|
||||
getSimpleProductList().then((data) => (productList = data));
|
||||
getSimpleDeviceList().then((data) => (deviceList = data));
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
|
|
@ -53,20 +62,34 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
label: '设备',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceList,
|
||||
api: (params?: { productId?: number }) =>
|
||||
getSimpleDeviceList(undefined, params?.productId),
|
||||
labelField: 'deviceName',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择设备',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['productId'],
|
||||
componentProps: (values) => {
|
||||
return {
|
||||
params: { productId: values.productId },
|
||||
};
|
||||
},
|
||||
trigger: (values, formApi) => {
|
||||
if (values.deviceId !== undefined) {
|
||||
void formApi.setFieldValue('deviceId', undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'processStatus',
|
||||
label: '是否处理',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING),
|
||||
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
|
||||
placeholder: '请选择是否处理',
|
||||
allowClear: true,
|
||||
},
|
||||
|
|
@ -84,9 +107,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<AlertRecordApi.AlertRecord>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '记录编号',
|
||||
|
|
@ -101,19 +123,24 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
field: 'configLevel',
|
||||
title: '告警级别',
|
||||
minWidth: 100,
|
||||
slots: { default: 'configLevel' },
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_ALERT_LEVEL },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '产品名称',
|
||||
minWidth: 120,
|
||||
slots: { default: 'product' },
|
||||
formatter: ({ cellValue }) =>
|
||||
productList.find((product) => product.id === cellValue)?.name || '-',
|
||||
},
|
||||
{
|
||||
field: 'deviceId',
|
||||
title: '设备名称',
|
||||
minWidth: 120,
|
||||
slots: { default: 'device' },
|
||||
formatter: ({ cellValue }) =>
|
||||
deviceList.find((device) => device.id === cellValue)?.deviceName || '-',
|
||||
},
|
||||
{
|
||||
field: 'deviceMessage',
|
||||
|
|
|
|||
|
|
@ -1,110 +1,58 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AlertRecord } from '#/api/iot/alert/record';
|
||||
import type { AlertRecordApi } from '#/api/iot/alert/record';
|
||||
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal, Popover, Tag } from 'antdv-next';
|
||||
import { Button, Input, message, Modal, Popover } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
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 stringifyDeviceMessage(deviceMessage: any) {
|
||||
if (!deviceMessage) {
|
||||
return '';
|
||||
}
|
||||
return typeof deviceMessage === 'object'
|
||||
? JSON.stringify(deviceMessage, null, 2)
|
||||
: String(deviceMessage);
|
||||
}
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 加载产品和设备列表
|
||||
async function loadData() {
|
||||
productList.value = await getSimpleProductList();
|
||||
deviceList.value = await getSimpleDeviceList();
|
||||
}
|
||||
|
||||
// 获取告警级别文本
|
||||
function getLevelText(level?: number) {
|
||||
const levelMap: Record<number, string> = {
|
||||
1: '提示',
|
||||
2: '一般',
|
||||
3: '警告',
|
||||
4: '严重',
|
||||
5: '紧急',
|
||||
};
|
||||
return level ? levelMap[level] || `级别${level}` : '-';
|
||||
}
|
||||
|
||||
// 获取告警级别颜色
|
||||
function getLevelColor(level?: number) {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange',
|
||||
4: 'red',
|
||||
5: 'purple',
|
||||
};
|
||||
return level ? colorMap[level] || 'default' : 'default';
|
||||
}
|
||||
|
||||
// 获取产品名称
|
||||
function getProductName(productId?: number) {
|
||||
if (!productId) return '-';
|
||||
const product = productList.value.find((p: any) => p.id === productId);
|
||||
return product?.name || '加载中...';
|
||||
}
|
||||
|
||||
// 获取设备名称
|
||||
function getDeviceName(deviceId?: number) {
|
||||
if (!deviceId) return '-';
|
||||
const device = deviceList.value.find((d: any) => d.id === deviceId);
|
||||
return device?.deviceName || '加载中...';
|
||||
}
|
||||
|
||||
// 处理告警记录
|
||||
async function handleProcess(row: AlertRecord) {
|
||||
/** 处理告警记录 */
|
||||
function handleProcess(row: AlertRecordApi.AlertRecord) {
|
||||
const processRemark = ref('');
|
||||
Modal.confirm({
|
||||
title: '处理告警记录',
|
||||
content: h('div', [
|
||||
h('p', '请输入处理原因:'),
|
||||
h('textarea', {
|
||||
id: 'processRemark',
|
||||
class: 'ant-input',
|
||||
rows: 3,
|
||||
placeholder: '请输入处理原因',
|
||||
}),
|
||||
]),
|
||||
content: () =>
|
||||
h('div', { class: 'space-y-2' }, [
|
||||
h('p', '请输入处理原因:'),
|
||||
h(Input.TextArea, {
|
||||
value: processRemark.value,
|
||||
'onUpdate:value': (val: string) => (processRemark.value = val),
|
||||
rows: 3,
|
||||
placeholder: '请输入处理原因',
|
||||
}),
|
||||
]),
|
||||
async onOk() {
|
||||
const textarea = document.querySelector(
|
||||
'#processRemark',
|
||||
) as HTMLTextAreaElement;
|
||||
const processRemark = textarea?.value || '';
|
||||
|
||||
if (!processRemark) {
|
||||
message.warning('请输入处理原因');
|
||||
throw new Error('请输入处理原因');
|
||||
}
|
||||
|
||||
const hideLoading = message.loading({
|
||||
content: '正在处理...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await processAlertRecord(row.id as number, processRemark);
|
||||
await processAlertRecord(row.id!, processRemark.value);
|
||||
message.success('处理成功');
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
console.error('处理失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
|
|
@ -112,45 +60,6 @@ async function handleProcess(row: AlertRecord) {
|
|||
});
|
||||
}
|
||||
|
||||
// 查看告警记录详情
|
||||
function 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(),
|
||||
|
|
@ -178,44 +87,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AlertRecord>,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
} as VxeTableGridOptions<AlertRecordApi.AlertRecord>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="告警记录列表">
|
||||
<!-- 告警级别列 -->
|
||||
<template #configLevel="{ row }">
|
||||
<Tag :color="getLevelColor(row.configLevel)">
|
||||
{{ getLevelText(row.configLevel) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 产品名称列 -->
|
||||
<template #product="{ row }">
|
||||
<span>{{ getProductName(row.productId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备名称列 -->
|
||||
<template #device="{ row }">
|
||||
<span>{{ getDeviceName(row.deviceId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备消息列 -->
|
||||
<template #deviceMessage="{ row }">
|
||||
<Popover
|
||||
v-if="row.deviceMessage"
|
||||
placement="topLeft"
|
||||
trigger="hover"
|
||||
:styles="{ root: { maxWidth: '600px' } }"
|
||||
:overlay-style="{ maxWidth: '600px' }"
|
||||
>
|
||||
<template #content>
|
||||
<pre class="text-xs">{{ row.deviceMessage }}</pre>
|
||||
<pre class="text-xs">{{
|
||||
stringifyDeviceMessage(row.deviceMessage)
|
||||
}}</pre>
|
||||
</template>
|
||||
<Button size="small" type="link">
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="mr-1" />
|
||||
|
|
@ -224,8 +113,6 @@ onMounted(() => {
|
|||
</Popover>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
|
|
@ -233,16 +120,10 @@ onMounted(() => {
|
|||
label: '处理',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:alert-record:process'],
|
||||
onClick: handleProcess.bind(null, row),
|
||||
ifShow: !row.processStatus,
|
||||
},
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleView.bind(null, row),
|
||||
ifShow: row.processStatus,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
|
@ -78,10 +79,18 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, '备注名称长度限制为 4~64 个字符')
|
||||
.max(64, '备注名称长度限制为 4~64 个字符')
|
||||
.regex(
|
||||
/^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/,
|
||||
.refine(
|
||||
(value) => {
|
||||
const length = value.replaceAll(
|
||||
/[\u4E00-\u9FA5\u3040-\u30FF]/g,
|
||||
'aa',
|
||||
).length;
|
||||
return length >= 4 && length <= 64;
|
||||
},
|
||||
'备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符',
|
||||
)
|
||||
.refine(
|
||||
(value) => /^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/.test(value),
|
||||
'备注名称只能包含中文、英文字母、日文、数字和下划线(_)',
|
||||
)
|
||||
.optional()
|
||||
|
|
@ -122,8 +131,8 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
label: '设备经度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入设备经度',
|
||||
class: 'w-full',
|
||||
min: -180,
|
||||
max: 180,
|
||||
precision: 6,
|
||||
|
|
@ -140,8 +149,8 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
label: '设备纬度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入设备纬度',
|
||||
class: 'w-full',
|
||||
min: -90,
|
||||
max: 90,
|
||||
precision: 6,
|
||||
|
|
@ -268,13 +277,14 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: 'DeviceName',
|
||||
minWidth: 150,
|
||||
slots: { default: 'deviceName' },
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
|
@ -9,7 +9,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||
import { Page } from '@vben/common-ui';
|
||||
import { DeviceTypeEnum } from '@vben/constants';
|
||||
|
||||
import { message, TabPane, Tabs } from 'antdv-next';
|
||||
import { message, Tabs } from 'antdv-next';
|
||||
|
||||
import { getDevice } from '#/api/iot/device/device';
|
||||
import { getProduct, ProtocolTypeEnum } from '#/api/iot/product/product';
|
||||
|
|
@ -24,8 +24,6 @@ import DeviceDetailsSimulator from './modules/simulator.vue';
|
|||
import DeviceDetailsSubDevice from './modules/sub-device.vue';
|
||||
import DeviceDetailsThingModel from './modules/thing-model.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -34,7 +32,7 @@ const loading = ref(true);
|
|||
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
||||
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
|
||||
const activeTab = ref('info');
|
||||
const thingModelList = ref<ThingModelData[]>([]);
|
||||
const thingModelList = ref<ThingModelApi.ThingModel[]>([]);
|
||||
|
||||
/** 获取设备详情 */
|
||||
async function getDeviceData(deviceId: number) {
|
||||
|
|
@ -97,52 +95,52 @@ onMounted(async () => {
|
|||
/>
|
||||
|
||||
<Tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<TabPane key="info" tab="设备信息">
|
||||
<Tabs.TabPane key="info" tab="设备信息">
|
||||
<DeviceDetailsInfo
|
||||
v-if="activeTab === 'info'"
|
||||
v-if="activeTab === 'info' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="model" tab="物模型数据">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="model" tab="物模型数据">
|
||||
<DeviceDetailsThingModel
|
||||
v-if="activeTab === 'model' && device.id"
|
||||
:device-id="device.id"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
|
||||
key="sub-device"
|
||||
key="subDevice"
|
||||
tab="子设备管理"
|
||||
>
|
||||
<DeviceDetailsSubDevice
|
||||
v-if="activeTab === 'sub-device' && device.id"
|
||||
v-if="activeTab === 'subDevice' && device.id"
|
||||
:device-id="device.id"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="log" tab="设备消息">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="log" tab="设备消息">
|
||||
<DeviceDetailsMessage
|
||||
v-if="activeTab === 'log' && device.id"
|
||||
:device-id="device.id"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="simulator" tab="模拟设备">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="simulator" tab="模拟设备">
|
||||
<DeviceDetailsSimulator
|
||||
v-if="activeTab === 'simulator'"
|
||||
v-if="activeTab === 'simulator' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="config" tab="设备配置">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="config" tab="设备配置">
|
||||
<DeviceDetailConfig
|
||||
v-if="activeTab === 'config'"
|
||||
v-if="activeTab === 'config' && device.id"
|
||||
:device="device"
|
||||
@success="() => getDeviceData(id)"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
v-if="
|
||||
[
|
||||
ProtocolTypeEnum.MODBUS_TCP_CLIENT,
|
||||
|
|
@ -153,12 +151,12 @@ onMounted(async () => {
|
|||
tab="Modbus 配置"
|
||||
>
|
||||
<DeviceModbusConfig
|
||||
v-if="activeTab === 'modbus'"
|
||||
v-if="activeTab === 'modbus' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@ import { computed, ref, watchEffect } from 'vue';
|
|||
|
||||
import { IotDeviceMessageMethodEnum } from '@vben/constants';
|
||||
|
||||
import { Alert, Button, message, TextArea } from 'antdv-next';
|
||||
import { Alert, Button, message, Popconfirm, TextArea } from 'antdv-next';
|
||||
|
||||
import { sendDeviceMessage, updateDevice } from '#/api/iot/device/device';
|
||||
|
||||
defineOptions({ name: 'DeviceDetailConfig' });
|
||||
|
||||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
}>();
|
||||
|
|
@ -78,7 +76,7 @@ async function saveConfig() {
|
|||
config.value = JSON.parse(configString.value);
|
||||
} catch (error) {
|
||||
console.error('JSON格式错误:', error);
|
||||
message.error({ content: 'JSON格式错误,请修正后再提交!' });
|
||||
message.error('JSON格式错误,请修正后再提交!');
|
||||
return;
|
||||
}
|
||||
saveLoading.value = true;
|
||||
|
|
@ -101,7 +99,7 @@ async function handleConfigPush() {
|
|||
params: config.value,
|
||||
});
|
||||
// 提示成功
|
||||
message.success({ content: '配置推送成功!' });
|
||||
message.success('配置推送成功!');
|
||||
} finally {
|
||||
pushLoading.value = false;
|
||||
}
|
||||
|
|
@ -116,7 +114,7 @@ async function updateDeviceConfig() {
|
|||
id: props.device.id,
|
||||
config: JSON.stringify(config.value),
|
||||
} as IotDeviceApi.Device);
|
||||
message.success({ content: '更新成功!' });
|
||||
message.success('更新成功!');
|
||||
// 触发 success 事件
|
||||
emit('success');
|
||||
} finally {
|
||||
|
|
@ -129,7 +127,7 @@ async function updateDeviceConfig() {
|
|||
<div>
|
||||
<!-- 使用说明提示 -->
|
||||
<Alert
|
||||
class="my-4"
|
||||
class="!mb-4"
|
||||
description="如需编辑文件,请点击下方编辑按钮"
|
||||
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
|
||||
show-icon
|
||||
|
|
@ -137,21 +135,22 @@ async function updateDeviceConfig() {
|
|||
/>
|
||||
|
||||
<!-- 代码视图 - 只读展示 -->
|
||||
<div v-if="!isEditing" class="json-viewer-container">
|
||||
<pre class="json-code"><code>{{ formattedConfig }}</code></pre>
|
||||
</div>
|
||||
<pre
|
||||
v-if="!isEditing"
|
||||
class="m-0 h-[460px] overflow-y-auto whitespace-pre-wrap break-words rounded border border-[#d9d9d9] bg-[#f5f5f5] p-3 font-mono text-[13px] leading-normal text-[#333] dark:border-[#3a3a3a] dark:bg-[#1a1a1a] dark:text-gray-300"
|
||||
v-text="formattedConfig"
|
||||
></pre>
|
||||
|
||||
<!-- 编辑器视图 - 可编辑 -->
|
||||
<TextArea
|
||||
v-else
|
||||
v-model:value="configString"
|
||||
:rows="20"
|
||||
class="json-editor"
|
||||
class="!h-[460px] font-mono text-[13px]"
|
||||
placeholder="请输入 JSON 格式的配置信息"
|
||||
/>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-5 text-center">
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
|
||||
<Button
|
||||
v-if="isEditing"
|
||||
|
|
@ -162,40 +161,13 @@ async function updateDeviceConfig() {
|
|||
保存
|
||||
</Button>
|
||||
<Button v-else @click="handleEdit">编辑</Button>
|
||||
<Button
|
||||
<Popconfirm
|
||||
v-if="!isEditing"
|
||||
:loading="pushLoading"
|
||||
type="primary"
|
||||
@click="handleConfigPush"
|
||||
title="确定要推送配置到设备吗?此操作将远程更新设备配置。"
|
||||
@confirm="handleConfigPush"
|
||||
>
|
||||
配置推送
|
||||
</Button>
|
||||
<Button :loading="pushLoading" type="primary"> 配置推送 </Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer-container {
|
||||
max-height: 600px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.json-code {
|
||||
margin: 0;
|
||||
font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ function openEditForm(row: IotDeviceApi.Device) {
|
|||
<div>
|
||||
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="product.status === 0"
|
||||
v-access:code="['iot:device:update']"
|
||||
|
|
@ -75,15 +75,15 @@ function openEditForm(row: IotDeviceApi.Device) {
|
|||
|
||||
<Card class="mt-4">
|
||||
<Descriptions :column="2">
|
||||
<DescriptionsItem label="产品">
|
||||
<Descriptions.Item label="产品">
|
||||
<a
|
||||
class="cursor-pointer text-blue-600"
|
||||
@click="goToProductDetail(product.id)"
|
||||
>
|
||||
{{ product.name }}
|
||||
</a>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="ProductKey">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
<Button
|
||||
class="ml-2"
|
||||
|
|
@ -92,7 +92,7 @@ function openEditForm(row: IotDeviceApi.Device) {
|
|||
>
|
||||
复制
|
||||
</Button>
|
||||
</DescriptionsItem>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
|
|||
);
|
||||
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
|
||||
|
||||
/** 是否有位置信息 */
|
||||
/** 是否有位置信息(合法经纬度 0 不应视为空) */
|
||||
const hasLocation = computed(() => {
|
||||
return !!(props.device.longitude && props.device.latitude);
|
||||
return props.device.longitude != null && props.device.latitude != null;
|
||||
});
|
||||
|
||||
/** 打开地图弹窗 */
|
||||
|
|
@ -77,40 +77,40 @@ function handleAuthInfoDialogClose() {
|
|||
<div>
|
||||
<Card title="设备信息">
|
||||
<Descriptions :column="3" bordered size="small">
|
||||
<DescriptionsItem label="产品名称">
|
||||
<Descriptions.Item label="产品名称">
|
||||
{{ product.name }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="ProductKey">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="设备类型">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="product.deviceType"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="DeviceName">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="DeviceName">
|
||||
{{ device.deviceName }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="备注名称">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注名称">
|
||||
{{ device.nickname || '--' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="当前状态">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前状态">
|
||||
<DictTag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ formatDateTime(device.createTime) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="激活时间">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="激活时间">
|
||||
{{ formatDateTime(device.activeTime) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="最后上线时间">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后上线时间">
|
||||
{{ formatDateTime(device.onlineTime) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="最后离线时间">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后离线时间">
|
||||
{{ formatDateTime(device.offlineTime) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="设备位置">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备位置">
|
||||
<template v-if="hasLocation">
|
||||
<span class="mr-2">
|
||||
{{ device.longitude }}, {{ device.latitude }}
|
||||
|
|
@ -121,12 +121,12 @@ function handleAuthInfoDialogClose() {
|
|||
</Button>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">暂无位置信息</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="MQTT 连接参数">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="MQTT 连接参数">
|
||||
<Button size="small" type="link" @click="handleAuthInfoDialogOpen">
|
||||
查看
|
||||
</Button>
|
||||
</DescriptionsItem>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ function handleAuthInfoDialogClose() {
|
|||
width="640px"
|
||||
>
|
||||
<Form :label-col="{ span: 6 }">
|
||||
<FormItem label="clientId">
|
||||
<Form.Item label="clientId">
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
v-model:value="authInfo.clientId"
|
||||
|
|
@ -149,8 +149,8 @@ function handleAuthInfoDialogClose() {
|
|||
<IconifyIcon icon="lucide:copy" />
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</FormItem>
|
||||
<FormItem label="username">
|
||||
</Form.Item>
|
||||
<Form.Item label="username">
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
v-model:value="authInfo.username"
|
||||
|
|
@ -161,8 +161,8 @@ function handleAuthInfoDialogClose() {
|
|||
<IconifyIcon icon="lucide:copy" />
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</FormItem>
|
||||
<FormItem label="password">
|
||||
</Form.Item>
|
||||
<Form.Item label="password">
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
v-model:value="authInfo.password"
|
||||
|
|
@ -182,7 +182,7 @@ function handleAuthInfoDialogClose() {
|
|||
<IconifyIcon icon="lucide:copy" />
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</FormItem>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div class="mt-4 text-right">
|
||||
<Button @click="handleAuthInfoDialogClose">关闭</Button>
|
||||
|
|
|
|||
|
|
@ -24,16 +24,14 @@ const props = defineProps<{
|
|||
deviceId: number;
|
||||
}>();
|
||||
|
||||
/** 查询参数 */
|
||||
const queryParams = reactive({
|
||||
method: undefined,
|
||||
upstream: undefined,
|
||||
});
|
||||
}); // 查询参数
|
||||
|
||||
/** 自动刷新开关 */
|
||||
const autoRefresh = ref(false);
|
||||
/** 自动刷新定时器 */
|
||||
let autoRefreshTimer: any = null;
|
||||
const autoRefresh = ref(false); // 自动刷新开关
|
||||
let autoRefreshTimer: any = null; // 自动刷新定时器
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 消息方法选项 */
|
||||
const methodOptions = computed(() => {
|
||||
|
|
@ -44,7 +42,7 @@ const methodOptions = computed(() => {
|
|||
});
|
||||
|
||||
/** Grid 列定义 */
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'ts',
|
||||
|
|
@ -153,6 +151,10 @@ onBeforeUnmount(() => {
|
|||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
|
|
@ -164,9 +166,14 @@ onMounted(() => {
|
|||
|
||||
/** 刷新消息列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => {
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
|
|
@ -189,14 +196,14 @@ defineExpose({
|
|||
placeholder="所有方法"
|
||||
style="width: 160px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="item in methodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="queryParams.upstream"
|
||||
|
|
@ -204,12 +211,12 @@ defineExpose({
|
|||
placeholder="上行/下行"
|
||||
style="width: 160px"
|
||||
>
|
||||
<SelectOption label="上行" value="true">上行</SelectOption>
|
||||
<SelectOption label="下行" value="false">下行</SelectOption>
|
||||
<Select.Option label="上行" value="true">上行</Select.Option>
|
||||
<Select.Option label="下行" value="false">下行</Select.Option>
|
||||
</Select>
|
||||
<Space>
|
||||
<Button type="primary" @click="handleQuery">
|
||||
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
|
||||
<IconifyIcon icon="ep:search" class="mr-[5px]" /> 搜索
|
||||
</Button>
|
||||
<Switch
|
||||
v-model:checked="autoRefresh"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ import { saveModbusConfig } from '#/api/iot/device/modbus/config';
|
|||
import { ProtocolTypeEnum } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'DeviceModbusConfigForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<IotDeviceModbusConfigApi.ModbusConfig>();
|
||||
|
|
@ -60,26 +58,32 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
placeholder: '请输入 Modbus 服务器 IP 地址',
|
||||
},
|
||||
// Client 模式专有字段:必填;Server 模式不显示也不校验
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:IP 地址
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value ? z.string().min(1, '请输入 IP 地址') : null,
|
||||
},
|
||||
rules: z.string().min(1, '请输入 IP 地址').optional(),
|
||||
},
|
||||
{
|
||||
fieldName: 'port',
|
||||
label: '端口',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入端口',
|
||||
min: 1,
|
||||
max: 65_535,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:端口
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入端口' }).min(1).max(65_535)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1).max(65_535).optional(),
|
||||
defaultValue: 502,
|
||||
},
|
||||
{
|
||||
|
|
@ -87,6 +91,7 @@ const [Form, formApi] = useVbenForm({
|
|||
label: '从站地址',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入从站地址,范围 1-247',
|
||||
min: 1,
|
||||
max: 247,
|
||||
|
|
@ -99,15 +104,19 @@ const [Form, formApi] = useVbenForm({
|
|||
label: '连接超时(ms)',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入连接超时时间',
|
||||
min: 1000,
|
||||
step: 1000,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:连接超时
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入连接超时时间' }).min(1000)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1000).optional(),
|
||||
defaultValue: 3000,
|
||||
},
|
||||
{
|
||||
|
|
@ -115,15 +124,19 @@ const [Form, formApi] = useVbenForm({
|
|||
label: '重试间隔(ms)',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入重试间隔',
|
||||
min: 1000,
|
||||
step: 1000,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:重试间隔
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入重试间隔' }).min(1000)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1000).optional(),
|
||||
defaultValue: 10_000,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
|||
import type { IotDeviceModbusConfigApi } from '#/api/iot/device/modbus/config';
|
||||
import type { IotDeviceModbusPointApi } from '#/api/iot/device/modbus/point';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
import type { DescriptionItemSchema } from '#/components/description';
|
||||
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
|
|
@ -14,7 +14,7 @@ import { computed, h, onMounted, ref } from 'vue';
|
|||
import { confirm, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
|
||||
|
||||
import { Button, message } from 'antdv-next';
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getModbusConfig } from '#/api/iot/device/modbus/config';
|
||||
|
|
@ -29,12 +29,10 @@ import { DictTag } from '#/components/dict-tag';
|
|||
import DeviceModbusConfigForm from './modbus-config-form.vue';
|
||||
import DeviceModbusPointForm from './modbus-point-form.vue';
|
||||
|
||||
defineOptions({ name: 'DeviceModbusConfig' });
|
||||
|
||||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
product: IotProductApi.Product;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
|
||||
// ======================= 连接配置 =======================
|
||||
|
|
@ -173,7 +171,7 @@ function usePointFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 点位列表列配置 */
|
||||
function usePointColumns(): VxeTableGridOptions['columns'] {
|
||||
function usePointColumns(): VxeTableGridOptions<IotDeviceModbusPointApi.ModbusPoint>['columns'] {
|
||||
return [
|
||||
{ field: 'name', title: '属性名称', minWidth: 100 },
|
||||
{
|
||||
|
|
@ -287,7 +285,7 @@ function handleEditPoint(row: IotDeviceModbusPointApi.ModbusPoint) {
|
|||
|
||||
/** 删除点位 */
|
||||
async function handleDeletePoint(row: IotDeviceModbusPointApi.ModbusPoint) {
|
||||
await confirm({ content: `确定要删除点位【${row.name}】吗?` });
|
||||
await confirm(`确定要删除点位【${row.name}】吗?`);
|
||||
await deleteModbusPoint(row.id!);
|
||||
message.success('删除成功');
|
||||
await gridApi.query();
|
||||
|
|
@ -309,7 +307,16 @@ onMounted(async () => {
|
|||
<!-- 连接配置区域 -->
|
||||
<ConfigDescriptions :data="modbusConfig" class="mb-4">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="handleEditConfig">编辑</Button>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'primary',
|
||||
auth: ['iot:device:create'],
|
||||
onClick: handleEditConfig,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</ConfigDescriptions>
|
||||
|
||||
|
|
@ -322,6 +329,7 @@ onMounted(async () => {
|
|||
label: '新增点位',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device:create'],
|
||||
onClick: handleAddPoint,
|
||||
},
|
||||
]"
|
||||
|
|
@ -333,12 +341,14 @@ onMounted(async () => {
|
|||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
auth: ['iot:device:update'],
|
||||
onClick: () => handleEditPoint(row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
auth: ['iot:device:delete'],
|
||||
popConfirm: {
|
||||
title: `确定要删除点位【${row.name}】吗?`,
|
||||
confirm: () => handleDeletePoint(row),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { IotDeviceModbusPointApi } from '#/api/iot/device/modbus/point';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
|
|
@ -27,8 +27,6 @@ import {
|
|||
} from '#/api/iot/device/modbus/point';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'DeviceModbusPointForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<IotDeviceModbusPointApi.ModbusPoint>();
|
||||
|
|
@ -36,7 +34,7 @@ const getTitle = computed(() => {
|
|||
return formData.value?.id ? '编辑点位' : '新增点位';
|
||||
});
|
||||
const deviceId = ref<number>(0);
|
||||
const thingModelList = ref<ThingModelData[]>([]);
|
||||
const thingModelList = ref<ThingModelApi.ThingModel[]>([]);
|
||||
|
||||
/** 筛选属性类型的物模型 */
|
||||
const propertyList = computed(() => {
|
||||
|
|
@ -112,6 +110,7 @@ function useFormSchema(): VbenFormSchema[] {
|
|||
label: '寄存器地址',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入寄存器地址',
|
||||
min: 0,
|
||||
max: 65_535,
|
||||
|
|
@ -134,6 +133,7 @@ function useFormSchema(): VbenFormSchema[] {
|
|||
label: '寄存器数量',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入寄存器数量',
|
||||
min: 1,
|
||||
max: 125,
|
||||
|
|
@ -178,6 +178,7 @@ function useFormSchema(): VbenFormSchema[] {
|
|||
label: '缩放因子',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入缩放因子',
|
||||
precision: 6,
|
||||
step: 0.1,
|
||||
|
|
@ -189,6 +190,7 @@ function useFormSchema(): VbenFormSchema[] {
|
|||
label: '轮询间隔(ms)',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入轮询间隔',
|
||||
min: 100,
|
||||
step: 1000,
|
||||
|
|
@ -221,10 +223,10 @@ const [Form, formApi] = useVbenForm({
|
|||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
handleValuesChange: async (_values, changedFields) => {
|
||||
handleValuesChange: async (values, changedFields) => {
|
||||
// 物模型属性变化:自动填充 identifier 和 name
|
||||
if (changedFields.includes('thingModelId')) {
|
||||
const thingModelId = await formApi.getFieldValue('thingModelId');
|
||||
const thingModelId = values.thingModelId;
|
||||
const thingModel = thingModelList.value.find(
|
||||
(item) => item.id === thingModelId,
|
||||
);
|
||||
|
|
@ -235,7 +237,7 @@ const [Form, formApi] = useVbenForm({
|
|||
}
|
||||
// 数据类型变化:自动设置寄存器数量和字节序
|
||||
if (changedFields.includes('rawDataType')) {
|
||||
const rawDataType = await formApi.getFieldValue('rawDataType');
|
||||
const rawDataType = values.rawDataType;
|
||||
if (rawDataType) {
|
||||
// 根据数据类型自动设置寄存器数量
|
||||
const option = ModbusRawDataTypeOptions.find(
|
||||
|
|
@ -244,10 +246,20 @@ const [Form, formApi] = useVbenForm({
|
|||
if (option && option.registerCount > 0) {
|
||||
await formApi.setFieldValue('registerCount', option.registerCount);
|
||||
}
|
||||
// 重置字节序为第一个选项
|
||||
// 字节序:仅在当前值为空或不属于新 rawDataType 时才重置为第一个选项;
|
||||
// 编辑回填时 setValues 会触发本回调,无条件重置会覆盖已保存字节序
|
||||
const byteOrderOptions = getByteOrderOptions(rawDataType);
|
||||
if (byteOrderOptions.length > 0) {
|
||||
await formApi.setFieldValue('byteOrder', byteOrderOptions[0]!.value);
|
||||
const currentByteOrder = values.byteOrder;
|
||||
const isCurrentValid =
|
||||
!!currentByteOrder &&
|
||||
byteOrderOptions.some((opt) => opt.value === currentByteOrder);
|
||||
if (!isCurrentValid) {
|
||||
await formApi.setFieldValue(
|
||||
'byteOrder',
|
||||
byteOrderOptions[0]!.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,7 +298,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
const data = modalApi.getData<{
|
||||
deviceId: number;
|
||||
id?: number;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
if (!data) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@ import type { TableColumnType } from 'antdv-next';
|
|||
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
import {
|
||||
DeviceStateEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
IoTDataSpecsDataTypeEnum,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
|
|
@ -21,8 +22,10 @@ import {
|
|||
Card,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Row,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
TextArea,
|
||||
|
|
@ -36,7 +39,7 @@ import DeviceDetailsMessage from './message.vue';
|
|||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
product: IotProductApi.Product;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
|
||||
// 消息弹窗
|
||||
|
|
@ -51,7 +54,7 @@ const debugCollapsed = ref(false); // 指令调试区域折叠状态
|
|||
const messageCollapsed = ref(false); // 设备消息区域折叠状态
|
||||
|
||||
// 表单数据:存储用户输入的模拟值
|
||||
const formData = ref<Record<string, string>>({});
|
||||
const formData = ref<Record<string, any>>({});
|
||||
|
||||
// 根据类型过滤物模型数据
|
||||
const getFilteredThingModelList = (type: number) => {
|
||||
|
|
@ -184,27 +187,107 @@ const serviceColumns = [
|
|||
|
||||
// 获取表单值
|
||||
function getFormValue(identifier: string) {
|
||||
return formData.value[identifier] || '';
|
||||
return formData.value[identifier] ?? '';
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
function setFormValue(identifier: string, value: string) {
|
||||
function setFormValue(identifier: string, value: any) {
|
||||
formData.value[identifier] = value;
|
||||
}
|
||||
|
||||
/** 获取属性数据类型 */
|
||||
function getPropertyDataType(row: ThingModelApi.ThingModel) {
|
||||
return row.property?.dataType;
|
||||
}
|
||||
|
||||
/** 判断属性是否为数值类型 */
|
||||
function isNumberProperty(row: ThingModelApi.ThingModel) {
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.DOUBLE,
|
||||
IoTDataSpecsDataTypeEnum.FLOAT,
|
||||
IoTDataSpecsDataTypeEnum.INT,
|
||||
].includes(getPropertyDataType(row) as any);
|
||||
}
|
||||
|
||||
/** 判断属性是否使用下拉选项 */
|
||||
function isSelectProperty(row: ThingModelApi.ThingModel) {
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.BOOL,
|
||||
IoTDataSpecsDataTypeEnum.ENUM,
|
||||
].includes(getPropertyDataType(row) as any);
|
||||
}
|
||||
|
||||
/** 获取属性选项 */
|
||||
function getPropertyOptions(row: ThingModelApi.ThingModel) {
|
||||
const list = row.property?.dataSpecsList || [];
|
||||
if (list.length > 0) {
|
||||
return list.map((item: any) => ({
|
||||
label: item.name || item.label || String(item.value),
|
||||
value: String(item.value),
|
||||
}));
|
||||
}
|
||||
if (getPropertyDataType(row) === IoTDataSpecsDataTypeEnum.BOOL) {
|
||||
return [
|
||||
{ label: '真 (true)', value: 'true' },
|
||||
{ label: '假 (false)', value: 'false' },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 获取物模型选项原始值 */
|
||||
function getMatchedPropertyOption(row: ThingModelApi.ThingModel, value: any) {
|
||||
return row.property?.dataSpecsList?.find(
|
||||
(item: any) => String(item.value) === String(value),
|
||||
);
|
||||
}
|
||||
|
||||
/** 按物模型数据类型转换属性值 */
|
||||
function normalizePropertyValue(row: ThingModelApi.ThingModel, value: any) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const dataType = getPropertyDataType(row);
|
||||
if (isNumberProperty(row)) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
[IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.ENUM].includes(
|
||||
dataType as any,
|
||||
)
|
||||
) {
|
||||
const option = getMatchedPropertyOption(row, value);
|
||||
if (option) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
if (dataType === IoTDataSpecsDataTypeEnum.BOOL) {
|
||||
if (String(value) === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (String(value) === 'false') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 属性上报
|
||||
async function handlePropertyPost() {
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!];
|
||||
if (value) {
|
||||
const value = normalizePropertyValue(
|
||||
item,
|
||||
formData.value[item.identifier!],
|
||||
);
|
||||
if (value !== undefined) {
|
||||
params[item.identifier!] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
message.warning({ content: '请至少输入一个属性值' });
|
||||
message.warning('请至少输入一个属性值');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -214,44 +297,48 @@ async function handlePropertyPost() {
|
|||
params,
|
||||
});
|
||||
|
||||
message.success({ content: '属性上报成功' });
|
||||
message.success('属性上报成功');
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
|
||||
} catch (error) {
|
||||
message.error({ content: '属性上报失败' });
|
||||
message.error('属性上报失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 事件上报
|
||||
async function handleEventPost(row: ThingModelData) {
|
||||
async function handleEventPost(row: ThingModelApi.ThingModel) {
|
||||
try {
|
||||
const valueStr = formData.value[row.identifier!];
|
||||
let params: any = {};
|
||||
let eventValue: any;
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
params = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error({ content: '事件参数格式错误,请输入有效的JSON格式' });
|
||||
return;
|
||||
}
|
||||
if (valueStr === undefined || valueStr === null || valueStr === '') {
|
||||
message.warning('请输入事件参数');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
eventValue = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('事件参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
|
||||
// 与后端 IotDeviceEventPostReqDTO 对齐 :{ identifier, value, time }
|
||||
await sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
params: {
|
||||
identifier: row.identifier,
|
||||
params,
|
||||
value: eventValue,
|
||||
time: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
message.success({ content: '事件上报成功' });
|
||||
message.success('事件上报成功');
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
|
||||
} catch (error) {
|
||||
message.error({ content: '事件上报失败' });
|
||||
message.error('事件上报失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -265,11 +352,11 @@ async function handleDeviceState(state: number) {
|
|||
params: { state },
|
||||
});
|
||||
|
||||
message.success({ content: '状态变更成功' });
|
||||
message.success('状态变更成功');
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
|
||||
} catch (error) {
|
||||
message.error({ content: '状态变更失败' });
|
||||
message.error('状态变更失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -279,14 +366,17 @@ async function handlePropertySet() {
|
|||
try {
|
||||
const params: Record<string, any> = {};
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!];
|
||||
if (value) {
|
||||
const value = normalizePropertyValue(
|
||||
item,
|
||||
formData.value[item.identifier!],
|
||||
);
|
||||
if (value !== undefined) {
|
||||
params[item.identifier!] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
message.warning({ content: '请至少输入一个属性值' });
|
||||
message.warning('请至少输入一个属性值');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -296,47 +386,63 @@ async function handlePropertySet() {
|
|||
params,
|
||||
});
|
||||
|
||||
message.success({ content: '属性设置成功' });
|
||||
message.success('属性设置成功');
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
|
||||
} catch (error) {
|
||||
message.error({ content: '属性设置失败' });
|
||||
message.error('属性设置失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 服务调用
|
||||
async function handleServiceInvoke(row: ThingModelData) {
|
||||
async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
|
||||
try {
|
||||
const valueStr = formData.value[row.identifier!];
|
||||
let params: any = {};
|
||||
let inputParams: any = {};
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
params = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error({ content: '服务参数格式错误,请输入有效的JSON格式' });
|
||||
return;
|
||||
}
|
||||
if (valueStr === undefined || valueStr === null || valueStr === '') {
|
||||
message.warning('请输入服务参数');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inputParams = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('服务参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof inputParams !== 'object' ||
|
||||
inputParams === null ||
|
||||
Array.isArray(inputParams)
|
||||
) {
|
||||
message.error('服务参数必须是 JSON 对象');
|
||||
return;
|
||||
}
|
||||
|
||||
// 与后端 IotDeviceServiceInvokeReqDTO 对齐 :{ identifier, inputParams }
|
||||
await sendDeviceMessage({
|
||||
deviceId: props.device.id!,
|
||||
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
|
||||
params: {
|
||||
identifier: row.identifier,
|
||||
params,
|
||||
inputParams,
|
||||
},
|
||||
});
|
||||
|
||||
message.success({ content: '服务调用成功' });
|
||||
message.success('服务调用成功');
|
||||
// 延迟刷新设备消息列表
|
||||
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
|
||||
} catch (error) {
|
||||
message.error({ content: '服务调用失败' });
|
||||
message.error('服务调用失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换调试方法时清空输入,避免不同方法之间串台提交 */
|
||||
watch([activeTab, upstreamTab, downstreamTab], () => {
|
||||
formData.value = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -361,14 +467,14 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
<div v-show="!debugCollapsed">
|
||||
<Tabs v-model:active-key="activeTab" size="small">
|
||||
<!-- 上行指令调试 -->
|
||||
<TabPane key="upstream" tab="上行指令调试">
|
||||
<Tabs.TabPane key="upstream" tab="上行指令调试">
|
||||
<Tabs
|
||||
v-if="activeTab === 'upstream'"
|
||||
v-model:active-key="upstreamTab"
|
||||
size="small"
|
||||
>
|
||||
<!-- 属性上报 -->
|
||||
<TabPane
|
||||
<Tabs.TabPane
|
||||
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
|
||||
tab="属性上报"
|
||||
>
|
||||
|
|
@ -389,7 +495,29 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<InputNumber
|
||||
v-if="isNumberProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="isSelectProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
:options="getPropertyOptions(record)"
|
||||
placeholder="请选择值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
|
|
@ -409,10 +537,10 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
</Button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 事件上报 -->
|
||||
<TabPane
|
||||
<Tabs.TabPane
|
||||
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
|
||||
tab="事件上报"
|
||||
>
|
||||
|
|
@ -455,10 +583,10 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
</template>
|
||||
</Table>
|
||||
</ContentWrap>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 状态变更 -->
|
||||
<TabPane
|
||||
<Tabs.TabPane
|
||||
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
|
||||
tab="状态变更"
|
||||
>
|
||||
|
|
@ -478,19 +606,19 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
</Button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 下行指令调试 -->
|
||||
<TabPane key="downstream" tab="下行指令调试">
|
||||
<Tabs.TabPane key="downstream" tab="下行指令调试">
|
||||
<Tabs
|
||||
v-if="activeTab === 'downstream'"
|
||||
v-model:active-key="downstreamTab"
|
||||
size="small"
|
||||
>
|
||||
<!-- 属性调试 -->
|
||||
<TabPane
|
||||
<Tabs.TabPane
|
||||
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
|
||||
tab="属性设置"
|
||||
>
|
||||
|
|
@ -511,7 +639,29 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<InputNumber
|
||||
v-if="isNumberProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="isSelectProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
:options="getPropertyOptions(record)"
|
||||
placeholder="请选择值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
|
|
@ -531,10 +681,10 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
</Button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 服务调用 -->
|
||||
<TabPane
|
||||
<Tabs.TabPane
|
||||
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
|
||||
tab="设备服务调用"
|
||||
>
|
||||
|
|
@ -574,9 +724,9 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
</template>
|
||||
</Table>
|
||||
</ContentWrap>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -595,7 +745,7 @@ async function handleServiceInvoke(row: ThingModelData) {
|
|||
>
|
||||
<IconifyIcon
|
||||
v-if="!messageCollapsed"
|
||||
icon="lucide:chevron-down"
|
||||
icon="lucide:chevron-up"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-if="messageCollapsed"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const props = defineProps<Props>();
|
|||
const router = useRouter();
|
||||
|
||||
/** 子设备列表表格列配置 */
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
|
|
@ -126,7 +126,7 @@ function handleRowCheckboxChange({
|
|||
|
||||
/** 解绑单个设备 */
|
||||
async function handleUnbind(row: IotDeviceApi.Device) {
|
||||
await confirm({ content: `确定要解绑子设备【${row.deviceName}】吗?` });
|
||||
await confirm(`确定要解绑子设备【${row.deviceName}】吗?`);
|
||||
const hideLoading = message.loading({
|
||||
content: `正在解绑【${row.deviceName}】...`,
|
||||
duration: 0,
|
||||
|
|
@ -142,9 +142,7 @@ async function handleUnbind(row: IotDeviceApi.Device) {
|
|||
|
||||
/** 批量解绑 */
|
||||
async function handleUnbindBatch() {
|
||||
await confirm({
|
||||
content: `确定要解绑选中的 ${checkedIds.value.length} 个子设备吗?`,
|
||||
});
|
||||
await confirm(`确定要解绑选中的 ${checkedIds.value.length} 个子设备吗?`);
|
||||
const hideLoading = message.loading({
|
||||
content: '正在批量解绑...',
|
||||
duration: 0,
|
||||
|
|
@ -190,7 +188,7 @@ function useAddGridFormSchema(): VbenFormSchema[] {
|
|||
];
|
||||
}
|
||||
|
||||
function useAddGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useAddGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
|
|
@ -317,6 +315,7 @@ watch(
|
|||
label: '添加子设备',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device:update'],
|
||||
onClick: openAddModal,
|
||||
},
|
||||
{
|
||||
|
|
@ -324,6 +323,7 @@ watch(
|
|||
type: 'primary',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:update'],
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleUnbindBatch,
|
||||
},
|
||||
|
|
@ -342,6 +342,7 @@ watch(
|
|||
label: '解绑',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
auth: ['iot:device:update'],
|
||||
onClick: () => handleUnbind(row),
|
||||
},
|
||||
]"
|
||||
|
|
|
|||
|
|
@ -1,45 +1,48 @@
|
|||
<!-- 设备事件管理 -->
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import {
|
||||
getEventTypeLabel,
|
||||
IoTThingModelTypeEnum,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { Button, DateRangePicker, Select, Space, Tag } from 'antdv-next';
|
||||
import { Button, DatePicker, Select, Space, Tag } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
/** 查询参数 */
|
||||
const queryParams = reactive({
|
||||
identifier: '',
|
||||
times: undefined as [string, string] | undefined,
|
||||
});
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 事件类型的物模型数据 */
|
||||
const eventThingModels = computed(() => {
|
||||
return props.thingModelList.filter(
|
||||
(item: ThingModelData) =>
|
||||
(item: ThingModelApi.ThingModel) =>
|
||||
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
|
||||
);
|
||||
});
|
||||
|
||||
/** Grid 列定义 */
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'reportTime',
|
||||
|
|
@ -123,7 +126,7 @@ function resetQuery() {
|
|||
function getEventName(identifier: string | undefined) {
|
||||
if (!identifier) return '-';
|
||||
const event = eventThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier,
|
||||
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
|
||||
);
|
||||
return event?.name || identifier;
|
||||
}
|
||||
|
|
@ -132,7 +135,7 @@ function getEventName(identifier: string | undefined) {
|
|||
function getEventType(identifier: string | undefined) {
|
||||
if (!identifier) return '-';
|
||||
const event = eventThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier,
|
||||
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
|
||||
);
|
||||
if (!event?.event?.type) return '-';
|
||||
return getEventTypeLabel(event.event.type) || '-';
|
||||
|
|
@ -153,8 +156,15 @@ function parseParams(params: string) {
|
|||
|
||||
/** 刷新列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => gridApi.query(), delay);
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
}
|
||||
|
|
@ -177,6 +187,14 @@ onMounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
/** 组件卸载时清除延迟刷新定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
refresh,
|
||||
|
|
@ -195,18 +213,18 @@ defineExpose({
|
|||
placeholder="请选择事件标识符"
|
||||
style="width: 240px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="event in eventThingModels"
|
||||
:key="event.identifier"
|
||||
:value="event.identifier!"
|
||||
>
|
||||
{{ event.name }}({{ event.identifier }})
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>时间范围:</span>
|
||||
<DateRangePicker
|
||||
<RangePicker
|
||||
v-model:value="queryParams.times"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
show-time
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
|
|||
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { formatDate, formatDateTime } from '@vben/utils';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -28,16 +28,12 @@ import dayjs from 'dayjs';
|
|||
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
|
||||
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
|
||||
|
||||
/** IoT 设备属性历史数据详情 */
|
||||
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' });
|
||||
|
||||
defineProps<{ deviceId: number }>();
|
||||
|
||||
const dialogVisible = ref(false); // 弹窗的是否展示
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const viewMode = ref<'chart' | 'list'>('chart'); // 视图模式状态
|
||||
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
|
||||
const list = ref<Array<IotDeviceApi.DeviceProperty & { _rowKey: string }>>([]); // 列表的数据
|
||||
const total = ref(0); // 总数据量
|
||||
const thingModelDataType = ref<string>(''); // 物模型数据类型
|
||||
const propertyIdentifier = ref<string>(''); // 属性标识符
|
||||
|
|
@ -89,7 +85,7 @@ const maxValue = computed(() => {
|
|||
if (!canShowChart.value || list.value.length === 0) return '-';
|
||||
const values = list.value
|
||||
.map((item) => Number(item.value))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
|
||||
});
|
||||
|
||||
|
|
@ -98,7 +94,7 @@ const minValue = computed(() => {
|
|||
if (!canShowChart.value || list.value.length === 0) return '-';
|
||||
const values = list.value
|
||||
.map((item) => Number(item.value))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
return values.length > 0 ? Math.min(...values).toFixed(2) : '-';
|
||||
});
|
||||
|
||||
|
|
@ -107,9 +103,9 @@ const avgValue = computed(() => {
|
|||
if (!canShowChart.value || list.value.length === 0) return '-';
|
||||
const values = list.value
|
||||
.map((item) => Number(item.value))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
if (values.length === 0) return '-';
|
||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||
const sum = values.reduce((total, value) => total + value, 0);
|
||||
return (sum / values.length).toFixed(2);
|
||||
});
|
||||
|
||||
|
|
@ -124,7 +120,7 @@ const tableColumns = computed(() => [
|
|||
key: 'index',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: ({ index }: { index: number }) => index + 1,
|
||||
customRender: ({ index }: { index: number }) => index + 1,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
|
|
@ -141,25 +137,15 @@ const tableColumns = computed(() => [
|
|||
},
|
||||
]); // 表格列配置
|
||||
|
||||
const paginationConfig = computed(() => ({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total: number) => `共 ${total} 条数据`,
|
||||
})); // 分页配置
|
||||
|
||||
/** 获得设备历史数据 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getHistoryDevicePropertyList(queryParams);
|
||||
// 后端直接返回数组,不是 { list: [] } 格式
|
||||
list.value = (
|
||||
Array.isArray(data) ? data : data?.list || []
|
||||
) as IotDeviceApi.DevicePropertyDetail[];
|
||||
list.value = (data || []).map((item, idx) => ({
|
||||
...item,
|
||||
_rowKey: `${item.updateTime ?? ''}-${idx}`, // 后端直接返回数组,仅含 value/updateTime,给每行补 _rowKey 保证唯一
|
||||
}));
|
||||
total.value = list.value.length;
|
||||
|
||||
// 如果是图表模式且支持图表展示,等待渲染图表
|
||||
|
|
@ -335,54 +321,6 @@ function handleRefresh() {
|
|||
getList();
|
||||
}
|
||||
|
||||
/** 导出数据 */
|
||||
async function handleExport() {
|
||||
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,
|
||||
formatDateTime(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.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
message.error('导出失败');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function handleClose() {
|
||||
dialogVisible.value = false;
|
||||
|
|
@ -434,20 +372,8 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||
刷新
|
||||
</Button>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<Button
|
||||
:disabled="list.length === 0"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ant-design:export-outlined" />
|
||||
</template>
|
||||
导出
|
||||
</Button>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space class="ml-auto">
|
||||
<Button.Group class="ml-auto">
|
||||
<Button
|
||||
:disabled="!canShowChart"
|
||||
:type="viewMode === 'chart' ? 'primary' : 'default'"
|
||||
|
|
@ -467,7 +393,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||
</template>
|
||||
列表
|
||||
</Button>
|
||||
</Space>
|
||||
</Button.Group>
|
||||
</Space>
|
||||
|
||||
<!-- 数据统计信息 -->
|
||||
|
|
@ -502,9 +428,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||
<Table
|
||||
:columns="tableColumns"
|
||||
:data-source="list"
|
||||
:pagination="paginationConfig"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 500 }"
|
||||
row-key="updateTime"
|
||||
row-key="_rowKey"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ let autoRefreshTimer: any = null; // 定时器
|
|||
const viewMode = ref<'card' | 'list'>('card'); // 视图模式状态
|
||||
|
||||
/** Grid 列定义 */
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useGridColumns(): VxeTableGridOptions<IotDeviceApi.DevicePropertyDetail>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'identifier',
|
||||
|
|
@ -207,6 +207,13 @@ function handleQuery() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 搜索关键词变化 */
|
||||
function handleKeywordChange(event: Event) {
|
||||
if (!(event.target as HTMLInputElement).value) {
|
||||
handleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
/** 视图切换 */
|
||||
async function handleViewModeChange(mode: 'card' | 'list') {
|
||||
if (viewMode.value === mode) {
|
||||
|
|
@ -281,16 +288,17 @@ onBeforeUnmount(() => {
|
|||
allow-clear
|
||||
placeholder="请输入属性名称、标识符"
|
||||
style="width: 240px"
|
||||
@change="handleKeywordChange"
|
||||
@press-enter="handleQuery"
|
||||
/>
|
||||
<Switch
|
||||
v-model:checked="autoRefresh"
|
||||
checked-children="定时刷新"
|
||||
class="ml-20px"
|
||||
class="ml-[20px]"
|
||||
un-checked-children="定时刷新"
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button.Group>
|
||||
<Button
|
||||
:type="viewMode === 'card' ? 'primary' : 'default'"
|
||||
@click="handleViewModeChange('card')"
|
||||
|
|
@ -303,7 +311,7 @@ onBeforeUnmount(() => {
|
|||
>
|
||||
<IconifyIcon icon="ep:list" />
|
||||
</Button>
|
||||
</Space>
|
||||
</Button.Group>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
|
|
@ -322,7 +330,7 @@ onBeforeUnmount(() => {
|
|||
class="mb-4"
|
||||
>
|
||||
<Card
|
||||
:styles="{ body: { padding: '0' } }"
|
||||
:body-style="{ padding: '0' }"
|
||||
class="relative h-full overflow-hidden transition-colors"
|
||||
>
|
||||
<!-- 添加渐变背景层 -->
|
||||
|
|
|
|||
|
|
@ -1,45 +1,48 @@
|
|||
<!-- 设备服务调用 -->
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import {
|
||||
getThingModelServiceCallTypeLabel,
|
||||
IoTThingModelTypeEnum,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { Button, DateRangePicker, Select, Space, Tag } from 'antdv-next';
|
||||
import { Button, DatePicker, Select, Space, Tag } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
/** 查询参数 */
|
||||
const queryParams = reactive({
|
||||
identifier: '',
|
||||
times: undefined as [string, string] | undefined,
|
||||
});
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 服务类型的物模型数据 */
|
||||
const serviceThingModels = computed(() => {
|
||||
return props.thingModelList.filter(
|
||||
(item: ThingModelData) =>
|
||||
(item: ThingModelApi.ThingModel) =>
|
||||
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
|
||||
);
|
||||
});
|
||||
|
||||
/** Grid 列定义 */
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'requestTime',
|
||||
|
|
@ -136,7 +139,7 @@ function resetQuery() {
|
|||
function getServiceName(identifier: string | undefined) {
|
||||
if (!identifier) return '-';
|
||||
const service = serviceThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier,
|
||||
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
|
||||
);
|
||||
return service?.name || identifier;
|
||||
}
|
||||
|
|
@ -145,7 +148,7 @@ function getServiceName(identifier: string | undefined) {
|
|||
function getCallType(identifier: string | undefined) {
|
||||
if (!identifier) return '-';
|
||||
const service = serviceThingModels.value.find(
|
||||
(item: ThingModelData) => item.identifier === identifier,
|
||||
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
|
||||
);
|
||||
if (!service?.service?.callType) return '-';
|
||||
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
|
||||
|
|
@ -167,8 +170,15 @@ function parseParams(params: string) {
|
|||
|
||||
/** 刷新列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => gridApi.query(), delay);
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
}
|
||||
|
|
@ -191,6 +201,14 @@ onMounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
/** 组件卸载时清除延迟刷新定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
refresh,
|
||||
|
|
@ -209,18 +227,18 @@ defineExpose({
|
|||
placeholder="请选择服务标识符"
|
||||
style="width: 240px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="service in serviceThingModels"
|
||||
:key="service.identifier"
|
||||
:value="service.identifier!"
|
||||
>
|
||||
{{ service.name }}({{ service.identifier }})
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>时间范围:</span>
|
||||
<DateRangePicker
|
||||
<RangePicker
|
||||
v-model:value="queryParams.times"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
show-time
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
||||
<script lang="ts" setup>
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
|
||||
import { TabPane, Tabs } from 'antdv-next';
|
||||
import { Tabs } from 'antdv-next';
|
||||
|
||||
import DeviceDetailsThingModelEvent from './thing-model-event.vue';
|
||||
import DeviceDetailsThingModelProperty from './thing-model-property.vue';
|
||||
|
|
@ -14,7 +14,7 @@ import DeviceDetailsThingModelService from './thing-model-service.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
thingModelList: ThingModelData[];
|
||||
thingModelList: ThingModelApi.ThingModel[];
|
||||
}>();
|
||||
|
||||
const activeTab = ref('property'); // 默认选中设备属性
|
||||
|
|
@ -22,26 +22,26 @@ const activeTab = ref('property'); // 默认选中设备属性
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<Tabs v-model:active-key="activeTab" class="!h-auto !p-0">
|
||||
<TabPane key="property" tab="设备属性(运行状态)">
|
||||
<Tabs.TabPane key="property" tab="设备属性(运行状态)">
|
||||
<DeviceDetailsThingModelProperty
|
||||
v-if="activeTab === 'property'"
|
||||
:device-id="deviceId"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="event" tab="设备事件上报">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="event" tab="设备事件上报">
|
||||
<DeviceDetailsThingModelEvent
|
||||
v-if="activeTab === 'event'"
|
||||
:device-id="props.deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane key="service" tab="设备服务调用">
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="service" tab="设备服务调用">
|
||||
<DeviceDetailsThingModelService
|
||||
v-if="activeTab === 'service'"
|
||||
:device-id="deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { PageParam } from '@vben/request';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
|
@ -8,13 +9,21 @@ import type { IotProductApi } from '#/api/iot/product/product';
|
|||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { confirm, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
|
||||
|
||||
import { Button, Card, Input, message, Select, Space, Tag } from 'antdv-next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
} from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
|
|
@ -33,9 +42,6 @@ import DeviceForm from './modules/form.vue';
|
|||
import DeviceGroupForm from './modules/group-form.vue';
|
||||
import DeviceImportForm from './modules/import-form.vue';
|
||||
|
||||
/** IoT 设备列表 */
|
||||
defineOptions({ name: 'IoTDevice' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const products = ref<IotProductApi.Product[]>([]);
|
||||
|
|
@ -44,9 +50,6 @@ const viewMode = ref<'card' | 'list'>('card');
|
|||
const cardViewRef = ref();
|
||||
const checkedIds = ref<number[]>([]);
|
||||
|
||||
/** 判断是否为列表视图 */
|
||||
const isListView = () => viewMode.value === 'list';
|
||||
|
||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
|
|
@ -166,6 +169,7 @@ async function handleDeleteBatch() {
|
|||
message.warning('请选择要删除的设备');
|
||||
return;
|
||||
}
|
||||
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deletingBatch'),
|
||||
duration: 0,
|
||||
|
|
@ -211,6 +215,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
pageSize: 12,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
|
|
@ -276,7 +283,7 @@ onMounted(async () => {
|
|||
<DeviceImportFormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 统一搜索工具栏 -->
|
||||
<Card :styles="{ body: { padding: '16px' } }" class="mb-4">
|
||||
<Card :body-style="{ padding: '16px' }" class="!mb-2">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
|
|
@ -285,13 +292,13 @@ onMounted(async () => {
|
|||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Input
|
||||
v-model:value="queryParams.deviceName"
|
||||
|
|
@ -313,7 +320,7 @@ onMounted(async () => {
|
|||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="dict in getDictOptions(
|
||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
||||
'number',
|
||||
|
|
@ -322,7 +329,7 @@ onMounted(async () => {
|
|||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="queryParams.status"
|
||||
|
|
@ -330,13 +337,13 @@ onMounted(async () => {
|
|||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="queryParams.groupId"
|
||||
|
|
@ -344,13 +351,13 @@ onMounted(async () => {
|
|||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<SelectOption
|
||||
<Select.Option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</SelectOption>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
|
|
@ -392,7 +399,6 @@ onMounted(async () => {
|
|||
type: 'primary',
|
||||
icon: 'ant-design:folder-add-outlined',
|
||||
auth: ['iot:device:update'],
|
||||
ifShow: isListView,
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleAddToGroup,
|
||||
},
|
||||
|
|
@ -402,7 +408,6 @@ onMounted(async () => {
|
|||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:delete'],
|
||||
ifShow: isListView,
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleDeleteBatch,
|
||||
},
|
||||
|
|
@ -428,12 +433,20 @@ onMounted(async () => {
|
|||
|
||||
<!-- 列表视图 -->
|
||||
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
||||
<template #deviceName="{ row }">
|
||||
<a class="cursor-pointer text-primary" @click="openDetail(row.id!)">
|
||||
{{ row.deviceName }}
|
||||
</a>
|
||||
</template>
|
||||
<template #product="{ row }">
|
||||
<a
|
||||
class="cursor-pointer text-primary"
|
||||
@click="openProductDetail(row.productId)"
|
||||
>
|
||||
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
|
||||
{{
|
||||
products.find((product) => product.id === row.productId)?.name ||
|
||||
'-'
|
||||
}}
|
||||
</a>
|
||||
</template>
|
||||
<template #groups="{ row }">
|
||||
|
|
@ -444,7 +457,7 @@ onMounted(async () => {
|
|||
size="small"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ deviceGroups.find((g) => g.id === groupId)?.name }}
|
||||
{{ deviceGroups.find((group) => group.id === groupId)?.name }}
|
||||
</Tag>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
|
|
@ -455,17 +468,20 @@ onMounted(async () => {
|
|||
{
|
||||
label: $t('common.detail'),
|
||||
type: 'link',
|
||||
auth: ['iot:device:query'],
|
||||
onClick: openDetail.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: '日志',
|
||||
type: 'link',
|
||||
auth: ['iot:device:query'],
|
||||
onClick: openModel.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:device:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
|
@ -473,6 +489,7 @@ onMounted(async () => {
|
|||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.deviceName]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
|||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
|
|
@ -47,6 +48,8 @@ const emit = defineEmits<{
|
|||
productDetail: [productId: number];
|
||||
}>();
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<IotDeviceApi.Device[]>([]);
|
||||
const total = ref(0);
|
||||
|
|
@ -102,7 +105,7 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-card-view">
|
||||
<div>
|
||||
<!-- 设备卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-96">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
|
|
@ -115,30 +118,43 @@ onMounted(() => {
|
|||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:styles="{ body: { padding: '16px' } }"
|
||||
class="device-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:body-style="{
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}"
|
||||
class="h-full overflow-hidden rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="mb-3 flex items-center">
|
||||
<div class="device-icon">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
|
||||
>
|
||||
<IconifyIcon icon="mdi:chip" class="text-xl" />
|
||||
</div>
|
||||
<div class="ml-3 min-w-0 flex-1">
|
||||
<div class="device-title">{{ item.deviceName }}</div>
|
||||
<div
|
||||
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
|
||||
>
|
||||
{{ item.deviceName }}
|
||||
</div>
|
||||
</div>
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
||||
:value="item.state"
|
||||
class="status-tag"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="mb-3 flex items-start">
|
||||
<div class="info-list flex-1">
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属产品</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
所属产品
|
||||
</span>
|
||||
<a
|
||||
class="info-value cursor-pointer text-primary"
|
||||
class="cursor-pointer truncate font-medium text-primary"
|
||||
@click="
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -149,25 +165,36 @@ onMounted(() => {
|
|||
{{ getProductName(item.productId) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">设备类型</span>
|
||||
<div class="mb-2 flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
设备类型
|
||||
</span>
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="item.deviceType"
|
||||
class="info-tag m-0"
|
||||
class="m-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Deviceid</span>
|
||||
<Tooltip :title="String(item.id)" placement="top">
|
||||
<span class="info-value device-id cursor-pointer">
|
||||
{{ item.id }}
|
||||
<div class="flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
备注名称
|
||||
</span>
|
||||
<Tooltip
|
||||
:title="item.nickname || item.deviceName"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[150px] cursor-pointer truncate align-middle text-xs opacity-85 dark:text-white/75"
|
||||
>
|
||||
{{ item.nickname || item.deviceName }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 设备图片 -->
|
||||
<div class="device-image">
|
||||
<div
|
||||
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
|
||||
>
|
||||
<Image
|
||||
v-if="item.picUrl"
|
||||
:src="item.picUrl"
|
||||
|
|
@ -182,43 +209,51 @@ onMounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
<!-- 按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<div class="mt-auto flex gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:device:update'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-edit"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:edit" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:device:query'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-detail"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
|
||||
@click="emit('detail', item.id!)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:eye" class="mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:device:query'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-data"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
|
||||
@click="emit('model', item.id!)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:database" class="mr-1" />
|
||||
数据
|
||||
</Button>
|
||||
<Popconfirm
|
||||
:title="`确认删除设备 ${item.deviceName} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="action-btn action-btn-delete !w-8"
|
||||
<template v-if="hasAccessByCodes(['iot:device:delete'])">
|
||||
<div
|
||||
class="h-5 w-px self-center bg-[#dcdfe6] dark:bg-[#3a3a3a]"
|
||||
></div>
|
||||
<Popconfirm
|
||||
:title="`确认删除设备 ${item.deviceName} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="!h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -242,187 +277,3 @@ onMounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 设备图标
|
||||
.device-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 设备标题
|
||||
.device-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 设备图片
|
||||
.device-image {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #1890ff;
|
||||
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--ant-color-split);
|
||||
|
||||
.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 {
|
||||
color: white;
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-detail {
|
||||
color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-data {
|
||||
color: #fa8c16;
|
||||
border-color: #fa8c16;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 夜间模式适配
|
||||
html.dark {
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
.device-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
.info-label {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.device-id {
|
||||
color: rgb(255 255 255 / 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.device-image {
|
||||
color: #69c0ff;
|
||||
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
|
|
@ -16,8 +16,6 @@ import { $t } from '#/locales';
|
|||
|
||||
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<IotDeviceApi.Device>();
|
||||
const products = ref<IotProductApi.Product[]>([]);
|
||||
|
|
@ -35,8 +33,9 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
layout: 'horizontal',
|
||||
schema: useBasicFormSchema(),
|
||||
showDefaultActions: false,
|
||||
|
|
@ -49,7 +48,7 @@ const [Form, formApi] = useVbenForm({
|
|||
return;
|
||||
}
|
||||
// 从产品列表中查找产品
|
||||
const product = products.value.find((p) => p.id === productId);
|
||||
const product = products.value.find((item) => item.id === productId);
|
||||
if (product?.deviceType !== undefined) {
|
||||
await formApi.setFieldValue('deviceType', product.deviceType);
|
||||
}
|
||||
|
|
@ -62,8 +61,9 @@ const [AdvancedForm, advancedFormApi] = useVbenForm({
|
|||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
layout: 'horizontal',
|
||||
schema: useAdvancedFormSchema(),
|
||||
showDefaultActions: false,
|
||||
|
|
@ -118,6 +118,28 @@ const [Modal, modalApi] = useVbenModal({
|
|||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
// 高级表单:先单独校验,再做经纬度成对填写的跨字段检查
|
||||
if (advancedFormApi.isMounted) {
|
||||
const { valid: advancedValid } = await advancedFormApi.validate();
|
||||
if (!advancedValid) {
|
||||
return;
|
||||
}
|
||||
const advValues = await advancedFormApi.getValues();
|
||||
const hasLongitude =
|
||||
advValues.longitude !== undefined &&
|
||||
advValues.longitude !== null &&
|
||||
advValues.longitude !== '';
|
||||
const hasLatitude =
|
||||
advValues.latitude !== undefined &&
|
||||
advValues.latitude !== null &&
|
||||
advValues.latitude !== '';
|
||||
if (hasLongitude !== hasLatitude) {
|
||||
message.warning(
|
||||
hasLongitude ? '请同时填写设备纬度' : '请同时填写设备经度',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
modalApi.lock();
|
||||
// 合并两个表单的值(字段不冲突,可直接合并)
|
||||
const basicValues = await formApi.getValues();
|
||||
|
|
@ -186,12 +208,12 @@ onMounted(async () => {
|
|||
<div class="mx-4">
|
||||
<Form />
|
||||
<Collapse v-model:active-key="activeKey" class="mt-4">
|
||||
<CollapsePanel key="advanced" header="更多设置">
|
||||
<Collapse.Panel key="advanced" header="更多设置">
|
||||
<AdvancedForm />
|
||||
<Space class="mt-2">
|
||||
<Button type="primary" @click="openMapDialog">坐标拾取</Button>
|
||||
</Space>
|
||||
</CollapsePanel>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import { $t } from '#/locales';
|
|||
|
||||
import { useGroupFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroupForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const deviceIds = ref<number[]>([]);
|
||||
const getTitle = computed(() => '添加设备到分组');
|
||||
|
|
@ -24,6 +22,8 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useGroupFormSchema(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts" setup>
|
||||
import type { FileType } from 'antdv-next/es/upload/interface';
|
||||
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { alert, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message, Upload } from 'antdv-next';
|
||||
|
|
@ -11,13 +13,14 @@ import { importDevice, importDeviceTemplate } from '#/api/iot/device/device';
|
|||
import { $t } from '#/locales';
|
||||
|
||||
import { useImportFormSchema } from '../data';
|
||||
type FileType = any;
|
||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
|
|
@ -58,7 +61,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
await alert(text, '导入结果');
|
||||
}
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
|
|
@ -89,18 +92,18 @@ async function handleDownload() {
|
|||
<template #file>
|
||||
<div class="w-full">
|
||||
<Upload
|
||||
:before-upload="beforeUpload"
|
||||
:max-count="1"
|
||||
accept=".xls,.xlsx"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<Button type="primary"> 选择 Excel 文件</Button>
|
||||
<Button type="primary"> 选择 Excel 文件 </Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
<template #prepend-footer>
|
||||
<div class="flex flex-auto items-center">
|
||||
<Button @click="handleDownload"> 下载导入模板</Button>
|
||||
<Button @click="handleDownload"> 下载导入模板 </Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
|
|
@ -25,10 +26,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分组名称不能为空')
|
||||
.max(64, '分组名称长度不能超过 64 个字符'),
|
||||
rules: z.string().min(1, '分组名称不能为空'),
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
|
|
@ -39,12 +37,12 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '分组描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组描述',
|
||||
rows: 3,
|
||||
|
|
@ -78,7 +76,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<IotDeviceGroupApi.DeviceGroup>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import { $t } from '#/locales';
|
|||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroup' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
|
|
@ -37,12 +35,16 @@ function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
|
|||
|
||||
/** 删除设备分组 */
|
||||
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
if (row.deviceCount && row.deviceCount > 0) {
|
||||
message.warning(`分组「${row.name}」下存在 ${row.deviceCount} 台设备,无法删除`);
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDeviceGroup(row.id as number);
|
||||
await deleteDeviceGroup(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function getMessageTrendChartOptions(
|
|||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['上行消息', '下行消息'],
|
||||
data: ['上行消息量', '下行消息量'],
|
||||
top: '5%',
|
||||
},
|
||||
grid: {
|
||||
|
|
@ -40,11 +40,21 @@ export function getMessageTrendChartOptions(
|
|||
],
|
||||
series: [
|
||||
{
|
||||
name: '上行消息',
|
||||
name: '上行消息量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
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)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
@ -55,11 +65,21 @@ export function getMessageTrendChartOptions(
|
|||
},
|
||||
},
|
||||
{
|
||||
name: '下行消息',
|
||||
name: '下行消息量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(82, 196, 26, 0)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
@ -73,9 +93,7 @@ export function getMessageTrendChartOptions(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备状态仪表盘图表配置
|
||||
*/
|
||||
/** 设备状态仪表盘图表配置 */
|
||||
export function getDeviceStateGaugeChartOptions(
|
||||
value: number,
|
||||
max: number,
|
||||
|
|
@ -86,8 +104,8 @@ export function getDeviceStateGaugeChartOptions(
|
|||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle: 225,
|
||||
endAngle: -45,
|
||||
startAngle: 360,
|
||||
endAngle: 0,
|
||||
min: 0,
|
||||
max,
|
||||
center: ['50%', '50%'],
|
||||
|
|
@ -129,9 +147,7 @@ export function getDeviceStateGaugeChartOptions(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备数量饼图配置
|
||||
*/
|
||||
/** 设备数量饼图配置 */
|
||||
export function getDeviceCountPieChartOptions(
|
||||
data: Array<{ name: string; value: number }>,
|
||||
): any {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import type { IotStatisticsApi } from '#/api/iot/statistics';
|
|||
/** 统计数据 */
|
||||
export type StatsData = IotStatisticsApi.StatisticsSummaryRespVO;
|
||||
|
||||
/** 默认统计数据 */
|
||||
/** 默认统计数据;用 -1 作为「未加载」哨兵,避免与「真 0 设备」混淆 */
|
||||
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,
|
||||
productCategoryCount: -1,
|
||||
productCount: -1,
|
||||
deviceCount: -1,
|
||||
deviceMessageCount: -1,
|
||||
productCategoryTodayCount: -1,
|
||||
productTodayCount: -1,
|
||||
deviceTodayCount: -1,
|
||||
deviceMessageTodayCount: -1,
|
||||
deviceOnlineCount: -1,
|
||||
deviceOfflineCount: -1,
|
||||
deviceInactiveCount: -1,
|
||||
productCategoryDeviceCounts: {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { StatsData } from './data';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
|
@ -15,27 +15,20 @@ import DeviceMapCard from './modules/device-map-card.vue';
|
|||
import DeviceStateCountCard from './modules/device-state-count-card.vue';
|
||||
import MessageTrendCard from './modules/message-trend-card.vue';
|
||||
|
||||
defineOptions({ name: 'IoTHome' });
|
||||
|
||||
const loading = ref(true);
|
||||
const statsData = ref<StatsData>(defaultStatsData);
|
||||
|
||||
/** 加载统计数据 */
|
||||
async function loadStatisticsData(): Promise<StatsData> {
|
||||
return await getStatisticsSummary();
|
||||
}
|
||||
|
||||
/** 加载数据 */
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
statsData.value = await loadStatisticsData();
|
||||
statsData.value = await getStatisticsSummary();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件挂载时加载数据 */
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
|
@ -21,11 +21,13 @@ const { renderEcharts } = useEcharts(deviceCountChartRef);
|
|||
|
||||
/** 是否有数据 */
|
||||
const hasData = computed(() => {
|
||||
if (!props.statsData) return false;
|
||||
if (!props.statsData) {
|
||||
return false;
|
||||
}
|
||||
const categories = Object.entries(
|
||||
props.statsData.productCategoryDeviceCounts || {},
|
||||
);
|
||||
return categories.length > 0 && props.statsData.deviceCount !== 0;
|
||||
return categories.length > 0 && props.statsData.deviceCount !== -1;
|
||||
});
|
||||
|
||||
/** 初始化图表 */
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import type { NumberDictDataType } from '@vben/hooks';
|
||||
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { DeviceStateEnum } from '@vben/constants';
|
||||
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel, getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Card, Empty, Spin } from 'antdv-next';
|
||||
|
||||
|
|
@ -14,30 +17,30 @@ import { loadBaiduMapSdk } from '#/components/map';
|
|||
defineOptions({ name: 'DeviceMapCard' });
|
||||
|
||||
const router = useRouter();
|
||||
const mapContainerRef = ref<HTMLElement>();
|
||||
let mapInstance: any = null;
|
||||
const loading = ref(true);
|
||||
const deviceList = ref<IotDeviceApi.Device[]>([]);
|
||||
const mapContainerRef = ref<HTMLElement>(); // 地图容器节点
|
||||
let mapInstance: any = null; // 百度地图实例
|
||||
const loading = ref(true); // 加载状态
|
||||
const deviceList = ref<IotDeviceApi.Device[]>([]); // 设备分布列表
|
||||
|
||||
/** 是否有数据 */
|
||||
const hasData = computed(() => deviceList.value.length > 0);
|
||||
const hasData = computed(() => deviceList.value.length > 0); // 是否有数据
|
||||
|
||||
const stateOptions = computed(() =>
|
||||
getDictOptions(
|
||||
DICT_TYPE.IOT_DEVICE_STATE,
|
||||
'number',
|
||||
) as NumberDictDataType[],
|
||||
); // 状态图例列表(从字典获取)
|
||||
|
||||
/** 设备状态颜色映射 */
|
||||
const stateColorMap: Record<number, string> = {
|
||||
[DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
|
||||
[DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
|
||||
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 离线 - 灰色
|
||||
};
|
||||
}; // 设备状态颜色映射
|
||||
|
||||
/** 获取设备状态配置 */
|
||||
/** 获取设备状态配置;名称走字典,颜色用本地映射 */
|
||||
function getStateConfig(state: number): { color: string; name: string } {
|
||||
const stateNames: Record<number, string> = {
|
||||
[DeviceStateEnum.INACTIVE]: '待激活',
|
||||
[DeviceStateEnum.ONLINE]: '在线',
|
||||
[DeviceStateEnum.OFFLINE]: '离线',
|
||||
};
|
||||
return {
|
||||
name: stateNames[state] || '未知',
|
||||
name: getDictLabel(DICT_TYPE.IOT_DEVICE_STATE, state) || '未知',
|
||||
color: stateColorMap[state] || '#909399',
|
||||
};
|
||||
}
|
||||
|
|
@ -115,19 +118,22 @@ function initMap() {
|
|||
// 信息窗口打开后绑定链接点击事件
|
||||
infoWindow.addEventListener('open', () => {
|
||||
setTimeout(() => {
|
||||
const link = document.querySelector('.device-link');
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const deviceId = e.target as HTMLElement.dataset.id;
|
||||
if (deviceId) {
|
||||
router.push({
|
||||
name: 'IoTDeviceDetail',
|
||||
params: { id: deviceId },
|
||||
});
|
||||
}
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'.BMap_bubble_content .device-link',
|
||||
);
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (device.id === undefined || device.id === null) {
|
||||
return;
|
||||
}
|
||||
router.push({
|
||||
name: 'IoTDeviceDetail',
|
||||
params: { id: device.id },
|
||||
});
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
|
|
@ -155,6 +161,8 @@ async function init() {
|
|||
return;
|
||||
}
|
||||
await loadBaiduMapSdk();
|
||||
// 等待 v-show 容器渲染完成;SDK 缓存命中时上一行会同步 resolve,DOM 来不及切换
|
||||
await nextTick();
|
||||
initMap();
|
||||
}
|
||||
|
||||
|
|
@ -176,28 +184,18 @@ onUnmounted(() => {
|
|||
<Card class="h-full" title="设备分布地图">
|
||||
<template #extra>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.ONLINE] }"
|
||||
></span>
|
||||
<span class="text-gray-500">在线</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE] }"
|
||||
></span>
|
||||
<span class="text-gray-500">离线</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="item in stateOptions"
|
||||
:key="item.value"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
|
||||
backgroundColor: stateColorMap[item.value],
|
||||
}"
|
||||
></span>
|
||||
<span class="text-gray-500">待激活</span>
|
||||
<span class="text-gray-500">{{ item.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
|
@ -29,7 +29,7 @@ const { renderEcharts: renderInactiveChart } = useEcharts(
|
|||
/** 是否有数据 */
|
||||
const hasData = computed(() => {
|
||||
if (!props.statsData) return false;
|
||||
return props.statsData.deviceCount !== 0;
|
||||
return props.statsData.deviceCount !== -1;
|
||||
});
|
||||
|
||||
/** 初始化图表 */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
|
@ -22,16 +22,17 @@ defineOptions({ name: 'MessageTrendCard' });
|
|||
const messageChartRef = ref();
|
||||
const { renderEcharts } = useEcharts(messageChartRef);
|
||||
|
||||
const loading = ref(false);
|
||||
const loading = ref(false); // 加载状态
|
||||
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>(
|
||||
[],
|
||||
);
|
||||
); // 消息趋势数据
|
||||
const isFirstMount = ref(true); // 首次挂载标记,用于跳过子组件 mount 时的默认 emit
|
||||
|
||||
/** 时间范围(仅日期,不包含时分秒) */
|
||||
const dateRange = ref<[string, string]>([
|
||||
// 默认显示最近一周的数据(包含今天和前六天)
|
||||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
// 默认显示最近一周的数据(截至昨天)
|
||||
dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||
]);
|
||||
|
||||
/** 将日期范围转换为带时分秒的格式 */
|
||||
|
|
@ -74,6 +75,11 @@ function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
|
|||
];
|
||||
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00,结束日期 23:59:59)
|
||||
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||
if (isFirstMount.value) {
|
||||
// 子组件 ShortcutDateRangePicker mount 时会 emit 一次默认日期范围,跳过 fetch;
|
||||
// 首次请求统一由父组件 onMounted 发起,避免双请求
|
||||
return;
|
||||
}
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +97,8 @@ async function fetchMessageData() {
|
|||
loading.value = true;
|
||||
try {
|
||||
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
|
||||
} catch {
|
||||
messageData.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
await renderChartWhenReady();
|
||||
|
|
@ -122,10 +130,12 @@ async function renderChartWhenReady() {
|
|||
await nextTick();
|
||||
initChart();
|
||||
}
|
||||
|
||||
/** 组件挂载时查询数据 */
|
||||
// 父组件挂载后统一发起首次请求;
|
||||
// 原因:子组件 ShortcutDateRangePicker 早期 emit 触发的请求落在 useEcharts isActiveRef = false 阶段,会被 renderEcharts 静默丢弃;
|
||||
// 通过 handleDateRangeChange 在 isFirstMount=true 时跳过 fetch,由这里统一发起一次,避免双请求
|
||||
onMounted(() => {
|
||||
fetchMessageData();
|
||||
isFirstMount.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
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(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1,9 +1,44 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { DescriptionItemSchema } from '#/components/description';
|
||||
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 关联数据 */
|
||||
let productList: IotProductApi.Product[] = [];
|
||||
getSimpleProductList().then((data) => (productList = data));
|
||||
|
||||
/** 根据产品 ID 取产品名称 */
|
||||
export function getProductName(productId?: number): string {
|
||||
if (!productId) {
|
||||
return '-';
|
||||
}
|
||||
return productList.find((product) => product.id === productId)?.name || '-';
|
||||
}
|
||||
|
||||
/** 固件详情的描述字段 */
|
||||
export function useDetailSchema(): DescriptionItemSchema[] {
|
||||
return [
|
||||
{ field: 'name', label: '固件名称' },
|
||||
{
|
||||
field: 'productName',
|
||||
label: '所属产品',
|
||||
render: (val) => val || '-',
|
||||
},
|
||||
{ field: 'version', label: '固件版本' },
|
||||
{
|
||||
field: 'createTime',
|
||||
label: '创建时间',
|
||||
render: (val) => (val ? (formatDateTime(val) as string) : '-'),
|
||||
},
|
||||
{ field: 'description', label: '固件描述', span: 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增/修改固件的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
|
|
@ -34,7 +69,13 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'version',
|
||||
|
|
@ -43,12 +84,18 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
componentProps: {
|
||||
placeholder: '请输入版本号',
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '固件描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入固件描述',
|
||||
rows: 3,
|
||||
|
|
@ -60,11 +107,17 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
maxNumber: 1,
|
||||
accept: ['bin', 'hex', 'zip'],
|
||||
accept: ['bin', 'zip', 'pdf'],
|
||||
maxSize: 50,
|
||||
helpText: '支持上传 .bin、.hex、.zip 格式的固件文件,最大 50MB',
|
||||
helpText: '支持上传 .bin、.zip、.pdf 格式的固件文件,最大 50MB',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -108,7 +161,6 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '固件编号',
|
||||
|
|
@ -133,7 +185,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
field: 'productId',
|
||||
title: '所属产品',
|
||||
minWidth: 150,
|
||||
slots: { default: 'product' },
|
||||
slots: { default: 'productName' },
|
||||
},
|
||||
{
|
||||
field: 'fileUrl',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { getOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskList from '../../task/modules/list.vue';
|
||||
import UpgradeStatistics from '../../task/modules/statistics.vue';
|
||||
import FirmwareInfo from './modules/info.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const firmwareId = ref(Number(route.params.id));
|
||||
const firmwareLoading = ref(false);
|
||||
const firmware = ref<IoTOtaFirmwareApi.Firmware>();
|
||||
|
||||
const firmwareStatisticsLoading = ref(false);
|
||||
const firmwareStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
async function getFirmwareInfo() {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取升级统计 */
|
||||
async function getStatistics() {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
firmwareId.value,
|
||||
);
|
||||
} finally {
|
||||
firmwareStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getFirmwareInfo();
|
||||
getStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<!-- 固件信息 -->
|
||||
<FirmwareInfo :firmware="firmware" :loading="firmwareLoading" />
|
||||
<!-- 升级设备统计 -->
|
||||
<div class="mt-4">
|
||||
<UpgradeStatistics
|
||||
:loading="firmwareStatisticsLoading"
|
||||
:statistics="firmwareStatistics"
|
||||
/>
|
||||
</div>
|
||||
<!-- 任务管理 -->
|
||||
<div v-if="firmware?.productId" class="mt-4">
|
||||
<OtaTaskList
|
||||
:firmware-id="firmwareId"
|
||||
:product-id="firmware.productId"
|
||||
@success="getStatistics"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { Card } from 'antdv-next';
|
||||
|
||||
import { useDescription } from '#/components/description';
|
||||
|
||||
import { useDetailSchema } from '../../data';
|
||||
|
||||
defineProps<{
|
||||
firmware?: IoTOtaFirmwareApi.Firmware;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const [Description] = useDescription({
|
||||
bordered: true,
|
||||
column: 3,
|
||||
schema: useDetailSchema(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="固件信息" :loading="loading">
|
||||
<Description :data="firmware" />
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -13,10 +13,12 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
|||
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import OtaFirmwareForm from '../modules/ota-firmware-form.vue';
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmware' });
|
||||
import {
|
||||
getProductName,
|
||||
useGridColumns,
|
||||
useGridFormSchema,
|
||||
} from './data';
|
||||
import OtaFirmwareForm from './modules/form.vue';
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
|
|||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteOtaFirmware(row.id as number);
|
||||
await deleteOtaFirmware(row.id!);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
|
|
@ -62,6 +64,11 @@ function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
|
|||
push({ name: 'IoTOtaFirmwareDetail', params: { id: row.id } });
|
||||
}
|
||||
|
||||
/** 跳转到产品详情 */
|
||||
function handleOpenProductDetail(productId: number) {
|
||||
push({ name: 'IoTProductDetail', params: { id: productId } });
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
|
|
@ -104,17 +111,23 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('ui.actionTitle.create', ['固件']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:ota-firmware:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 产品名称列 -->
|
||||
<template #product="{ row }">
|
||||
<span class="text-gray-700">{{ row.productName || '未知产品' }}</span>
|
||||
<!-- 所属产品列:点击跳产品详情 -->
|
||||
<template #productName="{ row }">
|
||||
<a
|
||||
v-if="row.productId && getProductName(row.productId) !== '-'"
|
||||
class="cursor-pointer text-primary hover:underline"
|
||||
@click="handleOpenProductDetail(row.productId)"
|
||||
>
|
||||
{{ getProductName(row.productId) }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 固件文件列 -->
|
||||
<template #fileUrl="{ row }">
|
||||
<div
|
||||
|
|
@ -136,8 +149,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
</div>
|
||||
<span v-else class="text-gray-400">无文件</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
|
|
@ -145,12 +156,14 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('common.detail'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
auth: ['iot:ota-firmware:query'],
|
||||
onClick: handleDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:ota-firmware:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
|
@ -158,6 +171,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
|
@ -15,13 +15,9 @@ import {
|
|||
} from '#/api/iot/ota/firmware';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../firmware/data';
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmwareForm' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<IoTOtaFirmwareApi.Firmware>();
|
||||
|
||||
|
|
@ -32,13 +28,15 @@ const getTitle = computed(() => {
|
|||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
|
|
@ -49,8 +47,15 @@ const [Modal, modalApi] = useVbenModal({
|
|||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IoTOtaFirmwareApi.Firmware;
|
||||
// 提交表单:编辑态只提交 id / name / description,其它字段固定不变
|
||||
const values = (await formApi.getValues()) as IoTOtaFirmwareApi.Firmware;
|
||||
const data: IoTOtaFirmwareApi.Firmware = formData.value?.id
|
||||
? {
|
||||
id: formData.value.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
}
|
||||
: values;
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateOtaFirmware(data)
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
<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 'antdv-next';
|
||||
|
||||
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/ota-firmware-form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTOtaFirmware' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: OtaFirmwareForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
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]),
|
||||
});
|
||||
handleRefresh();
|
||||
} 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="handleRefresh" />
|
||||
<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>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import { Card, Col, Descriptions, Row } from 'antdv-next';
|
||||
|
||||
import { getOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskList from '../task/ota-task-list.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>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
async function getFirmwareInfo() {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取升级统计 */
|
||||
async function getStatistics() {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
firmwareId.value,
|
||||
);
|
||||
} finally {
|
||||
firmwareStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getFirmwareInfo();
|
||||
getStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- 固件信息 -->
|
||||
<Card title="固件信息" class="mb-3" :loading="firmwareLoading">
|
||||
<Descriptions :column="3" bordered size="small">
|
||||
<DescriptionsItem label="固件名称">
|
||||
{{ firmware?.name }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="所属产品">
|
||||
{{ firmware?.productName }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="固件版本">
|
||||
{{ firmware?.version }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
{{
|
||||
firmware?.createTime
|
||||
? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss')
|
||||
: '-'
|
||||
}}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="固件描述" :span="2">
|
||||
{{ firmware?.description }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 升级设备统计 -->
|
||||
<Card
|
||||
title="升级设备统计"
|
||||
class="mb-3"
|
||||
:loading="firmwareStatisticsLoading"
|
||||
>
|
||||
<Row :gutter="20" class="py-3">
|
||||
<Col :span="6">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold 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="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold text-gray-400">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold text-blue-400">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold text-yellow-500">
|
||||
{{
|
||||
firmwareStatistics[
|
||||
IoTOtaTaskRecordStatusEnum.UPGRADING.value
|
||||
] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold text-green-500">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold text-red-500">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
|
||||
>
|
||||
<div class="mb-1 text-3xl font-bold 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>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import { Card, Col, Descriptions, Row } from 'antdv-next';
|
||||
|
||||
import { getOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskList from '../task/ota-task-list.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>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
async function getFirmwareInfo() {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取升级统计 */
|
||||
async function getStatistics() {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await 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>
|
||||
<DescriptionsItem label="固件名称">
|
||||
{{ firmware?.name }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="所属产品">
|
||||
{{ firmware?.productName }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="固件版本">
|
||||
{{ firmware?.version }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
{{
|
||||
firmware?.createTime
|
||||
? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss')
|
||||
: '-'
|
||||
}}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="固件描述" :span="2">
|
||||
{{ firmware?.description }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 升级设备统计 -->
|
||||
<Card
|
||||
title="升级设备统计"
|
||||
class="mb-5"
|
||||
:loading="firmwareStatisticsLoading"
|
||||
>
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col :span="6">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold 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="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-gray-400">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-blue-400">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-yellow-500">
|
||||
{{
|
||||
firmwareStatistics[
|
||||
IoTOtaTaskRecordStatusEnum.UPGRADING.value
|
||||
] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-green-500">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-red-500">
|
||||
{{
|
||||
firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold 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>
|
||||
|
|
@ -1,411 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { TableColumnsType } from 'antdv-next';
|
||||
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
import type { OtaTaskRecord } from '#/api/iot/ota/task/record';
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from 'antdv-next';
|
||||
|
||||
import { getOtaTask } from '#/api/iot/ota/task';
|
||||
import {
|
||||
cancelOtaTaskRecord,
|
||||
getOtaTaskRecordPage,
|
||||
getOtaTaskRecordStatusStatistics,
|
||||
} from '#/api/iot/ota/task/record';
|
||||
|
||||
/** 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,
|
||||
render: ({ text }: any) => formatDate(text, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center' as const,
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
|
||||
const [ModalComponent, modalApi] = useVbenModal();
|
||||
|
||||
/** 获取任务详情 */
|
||||
async function getTaskInfo() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
task.value = await getOtaTask(taskId.value);
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取统计数据 */
|
||||
async function getStatistics() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskStatisticsLoading.value = true;
|
||||
try {
|
||||
taskStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
undefined,
|
||||
taskId.value,
|
||||
);
|
||||
} finally {
|
||||
taskStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取升级记录列表 */
|
||||
async function getRecordList() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
recordLoading.value = true;
|
||||
try {
|
||||
queryParams.taskId = taskId.value;
|
||||
const data = await getOtaTaskRecordPage(queryParams);
|
||||
recordList.value = data.list || [];
|
||||
recordTotal.value = data.total || 0;
|
||||
} finally {
|
||||
recordLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换标签 */
|
||||
function handleTabChange(tabKey: number | string) {
|
||||
activeTab.value = String(tabKey);
|
||||
queryParams.pageNo = 1;
|
||||
queryParams.status =
|
||||
activeTab.value === '' ? undefined : Number.parseInt(String(tabKey));
|
||||
getRecordList();
|
||||
}
|
||||
|
||||
/** 分页变化 */
|
||||
function handleTableChange(pagination: any) {
|
||||
queryParams.pageNo = pagination.current;
|
||||
queryParams.pageSize = pagination.pageSize;
|
||||
getRecordList();
|
||||
}
|
||||
|
||||
/** 取消升级 */
|
||||
async function handleCancelUpgrade(record: OtaTaskRecord) {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确认要取消该设备的升级任务吗?',
|
||||
async onOk() {
|
||||
try {
|
||||
await cancelOtaTaskRecord(record.id!);
|
||||
message.success('取消成功');
|
||||
await getRecordList();
|
||||
await getStatistics();
|
||||
await getTaskInfo();
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
console.error('取消升级失败', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
function 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>
|
||||
<DescriptionsItem label="任务编号">{{ task.id }}</DescriptionsItem>
|
||||
<DescriptionsItem label="任务名称">
|
||||
{{ task.name }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem 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>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem 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>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
{{
|
||||
task.createTime
|
||||
? formatDate(task.createTime, 'YYYY-MM-DD HH:mm:ss')
|
||||
: '-'
|
||||
}}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="任务描述" :span="3">
|
||||
{{ task.description }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<!-- 任务升级设备统计 -->
|
||||
<Card title="升级设备统计" class="mb-5" :loading="taskStatisticsLoading">
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col :span="6">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold 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="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-gray-400">
|
||||
{{
|
||||
taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">待推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-blue-400">
|
||||
{{
|
||||
taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">已推送</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-yellow-500">
|
||||
{{
|
||||
taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] ||
|
||||
0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">正在升级</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-green-500">
|
||||
{{
|
||||
taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级成功</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold text-red-500">
|
||||
{{
|
||||
taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">升级失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="3">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold 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:active-key="activeTab"
|
||||
@change="handleTabChange"
|
||||
class="mb-4"
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IoTOtaTaskDeviceScopeEnum } from '@vben/constants';
|
||||
|
||||
import { Form, Input, message, Select, Spin } from 'antdv-next';
|
||||
|
||||
import { getDeviceListByProductId } from '#/api/iot/device/device';
|
||||
import { createOtaTask } from '#/api/iot/ota/task';
|
||||
|
||||
/** 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<IotDeviceApi.Device[]>([]);
|
||||
|
||||
/** 设备选项 */
|
||||
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 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 getDeviceListByProductId(props.productId)) || [];
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 重置表单 */
|
||||
function resetForm() {
|
||||
formData.value = {
|
||||
name: '',
|
||||
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
|
||||
firmwareId: props.firmwareId,
|
||||
description: '',
|
||||
deviceIds: [],
|
||||
};
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
async function open() {
|
||||
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"
|
||||
>
|
||||
<FormItem label="任务名称" name="name">
|
||||
<Input v-model:value="formData.name" placeholder="请输入任务名称" />
|
||||
</FormItem>
|
||||
<FormItem label="任务描述" name="description">
|
||||
<Input.TextArea
|
||||
v-model:value="formData.description"
|
||||
:rows="3"
|
||||
placeholder="请输入任务描述"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="升级范围" name="deviceScope">
|
||||
<Select
|
||||
v-model:value="formData.deviceScope"
|
||||
placeholder="请选择升级范围"
|
||||
:options="deviceScopeOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
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
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { TableColumnsType } from 'antdv-next';
|
||||
|
||||
import type { OtaTask } from '#/api/iot/ota/task';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { IoTOtaTaskStatusEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antdv-next';
|
||||
|
||||
import { getOtaTaskPage } from '#/api/iot/ota/task';
|
||||
|
||||
import OtaTaskDetail from './ota-task-detail.vue';
|
||||
import OtaTaskForm from './ota-task-form.vue';
|
||||
|
||||
/** 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(); // 任务详情引用
|
||||
|
||||
/** 获取任务列表 */
|
||||
async function getTaskList() {
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
const data = await getOtaTaskPage(queryParams);
|
||||
taskList.value = data.list;
|
||||
taskTotal.value = data.total;
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getTaskList();
|
||||
}
|
||||
|
||||
/** 打开任务表单 */
|
||||
function openTaskForm() {
|
||||
taskFormRef.value?.open();
|
||||
}
|
||||
|
||||
/** 处理任务创建成功 */
|
||||
function handleTaskCreateSuccess() {
|
||||
getTaskList();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/** 查看任务详情 */
|
||||
function handleTaskDetail(id: number) {
|
||||
taskDetailRef.value?.open(id);
|
||||
}
|
||||
|
||||
/** 取消任务 */
|
||||
async function handleCancelTask(id: number) {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确认要取消该升级任务吗?',
|
||||
async onOk() {
|
||||
try {
|
||||
await IoTOtaTaskApi.cancelOtaTask(id);
|
||||
message.success('取消成功');
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新数据 */
|
||||
async function refresh() {
|
||||
await getTaskList();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/** 分页变化 */
|
||||
function 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,
|
||||
render: ({ 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 items-center justify-between">
|
||||
<Button type="primary" @click="openTaskForm">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</Button>
|
||||
<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>
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DescriptionItemSchema } from '#/components/description';
|
||||
|
||||
import { DICT_TYPE, IoTOtaTaskDeviceScopeEnum } from '@vben/constants';
|
||||
import { getDictLabel, getDictOptions } from '@vben/hooks';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
/** 任务详情的描述字段 */
|
||||
export function useDetailSchema(): DescriptionItemSchema[] {
|
||||
return [
|
||||
{ field: 'id', label: '任务编号' },
|
||||
{ field: 'name', label: '任务名称' },
|
||||
{
|
||||
field: 'deviceScope',
|
||||
label: '升级范围',
|
||||
render: (val) => getDictLabel(DICT_TYPE.IOT_OTA_TASK_DEVICE_SCOPE, val),
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '任务状态',
|
||||
render: (val) => getDictLabel(DICT_TYPE.IOT_OTA_TASK_STATUS, val),
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
label: '创建时间',
|
||||
render: (val) => (val ? (formatDateTime(val) as string) : '-'),
|
||||
},
|
||||
{ field: 'description', label: '任务描述', span: 3 },
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增升级任务的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'firmwareId',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入任务名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '任务描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入任务描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceScope',
|
||||
label: '升级范围',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_OTA_TASK_DEVICE_SCOPE, 'number'),
|
||||
placeholder: '请选择升级范围',
|
||||
},
|
||||
defaultValue: IoTOtaTaskDeviceScopeEnum.ALL.value,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceIds',
|
||||
label: '选择设备',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择设备',
|
||||
showSearch: true,
|
||||
filterOption: true,
|
||||
optionFilterProp: 'label',
|
||||
},
|
||||
defaultValue: [],
|
||||
dependencies: {
|
||||
triggerFields: ['deviceScope'],
|
||||
show: (values) =>
|
||||
values.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value,
|
||||
rules: (values) =>
|
||||
values.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value
|
||||
? 'required'
|
||||
: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 任务列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '任务编号',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '任务名称',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'deviceScope',
|
||||
title: '升级范围',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_OTA_TASK_DEVICE_SCOPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: '升级进度',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: ({ row }) =>
|
||||
`${row.deviceSuccessCount || 0}/${row.deviceTotalCount || 0}`,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '任务描述',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '任务状态',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_OTA_TASK_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { getOtaTask } from '#/api/iot/ota/task';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskRecordList from '../record/modules/list.vue';
|
||||
import TaskInfo from './info.vue';
|
||||
import UpgradeStatistics from './statistics.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const taskId = ref<number>();
|
||||
const taskLoading = ref(false);
|
||||
const task = ref<IoTOtaTaskApi.Task>();
|
||||
|
||||
const taskStatisticsLoading = ref(false);
|
||||
const taskStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取任务详情 */
|
||||
async function getTaskInfo() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
task.value = await getOtaTask(taskId.value);
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取统计数据 */
|
||||
async function getStatistics() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskStatisticsLoading.value = true;
|
||||
try {
|
||||
taskStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
undefined,
|
||||
taskId.value,
|
||||
);
|
||||
} finally {
|
||||
taskStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条记录取消后,刷新任务信息和统计 */
|
||||
async function handleRecordSuccess() {
|
||||
await getStatistics();
|
||||
await getTaskInfo();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ id: number }>();
|
||||
if (!data?.id) {
|
||||
return;
|
||||
}
|
||||
taskId.value = data.id;
|
||||
await Promise.all([getTaskInfo(), getStatistics()]);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
title="升级任务详情"
|
||||
class="w-5/6"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<!-- 任务信息 -->
|
||||
<TaskInfo :task="task" :loading="taskLoading" />
|
||||
<!-- 升级设备统计 -->
|
||||
<div class="mt-4">
|
||||
<UpgradeStatistics
|
||||
:loading="taskStatisticsLoading"
|
||||
:statistics="taskStatistics"
|
||||
/>
|
||||
</div>
|
||||
<!-- 升级设备记录 -->
|
||||
<div class="mt-4">
|
||||
<OtaTaskRecordList :task-id="taskId" @success="handleRecordSuccess" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getDeviceListByProductId } from '#/api/iot/device/device';
|
||||
import { createOtaTask } from '#/api/iot/ota/task';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
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 IoTOtaTaskApi.Task;
|
||||
try {
|
||||
await createOtaTask(data);
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<{ firmwareId: number; productId: number }>();
|
||||
if (!data?.firmwareId || !data?.productId) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
// 设置 firmwareId
|
||||
await formApi.setValues({ firmwareId: data.firmwareId });
|
||||
// 加载产品下的设备列表
|
||||
const devices = (await getDeviceListByProductId(data.productId)) || [];
|
||||
// 注入到 deviceIds 字段的 options
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'deviceIds',
|
||||
componentProps: {
|
||||
options: devices.map((device) => ({
|
||||
label: device.nickname
|
||||
? `${device.deviceName} (${device.nickname})`
|
||||
: device.deviceName,
|
||||
value: device.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="$t('ui.actionTitle.create', ['升级任务'])">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { Card } from 'antdv-next';
|
||||
|
||||
import { useDescription } from '#/components/description';
|
||||
|
||||
import { useDetailSchema } from '../data';
|
||||
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
task?: IoTOtaTaskApi.Task;
|
||||
}>();
|
||||
|
||||
const [Description] = useDescription({
|
||||
bordered: true,
|
||||
column: 3,
|
||||
schema: useDetailSchema(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="任务信息" :loading="loading">
|
||||
<Description :data="task" />
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IoTOtaTaskStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Input, message } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { cancelOtaTask, getOtaTaskPage } from '#/api/iot/ota/task';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns } from '../data';
|
||||
import OtaTaskDetail from './detail.vue';
|
||||
import OtaTaskForm from './form.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
firmwareId: number;
|
||||
productId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const searchName = ref('');
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: OtaTaskForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DetailModal, detailModalApi] = useVbenModal({
|
||||
connectedComponent: OtaTaskDetail,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
async function handleRefresh() {
|
||||
await gridApi.query();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/** 按任务名称搜索 */
|
||||
async function handleSearch() {
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 新增任务 */
|
||||
function handleCreate() {
|
||||
formModalApi
|
||||
.setData({ firmwareId: props.firmwareId, productId: props.productId })
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 查看任务详情 */
|
||||
function handleDetail(row: IoTOtaTaskApi.Task) {
|
||||
detailModalApi.setData({ id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 取消任务 */
|
||||
async function handleCancel(row: IoTOtaTaskApi.Task) {
|
||||
await cancelOtaTask(row.id!);
|
||||
message.success('取消成功');
|
||||
await handleRefresh();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
maxHeight: 500,
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getOtaTaskPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
firmwareId: props.firmwareId,
|
||||
name: searchName.value || undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
} as VxeTableGridOptions<IoTOtaTaskApi.Task>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<DetailModal @success="handleRefresh" />
|
||||
|
||||
<Grid table-title="升级任务管理">
|
||||
<template #toolbar-tools>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model:value="searchName"
|
||||
placeholder="请输入任务名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.search'),
|
||||
type: 'default',
|
||||
icon: 'ant-design:search-outlined',
|
||||
onClick: handleSearch,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['升级任务']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:ota-task:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.detail'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
auth: ['iot:ota-task:query'],
|
||||
onClick: handleDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.cancel'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:ota-task:cancel'],
|
||||
ifShow: row.status === IoTOtaTaskStatusEnum.IN_PROGRESS.value,
|
||||
popConfirm: {
|
||||
title: '确认要取消该升级任务吗?',
|
||||
confirm: handleCancel.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { DICT_TYPE, IoTOtaTaskRecordStatusEnum } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
|
||||
import { Card, Col, Row } from 'antdv-next';
|
||||
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
statistics: Record<string, number>;
|
||||
}>();
|
||||
|
||||
/** 取字典标签(同步) */
|
||||
function dictLabel(value: number) {
|
||||
return getDictLabel(DICT_TYPE.IOT_OTA_TASK_RECORD_STATUS, value);
|
||||
}
|
||||
|
||||
/** 统计项配置 */
|
||||
const items = computed(() => [
|
||||
{
|
||||
label: '升级设备总数',
|
||||
span: 6,
|
||||
color: 'text-blue-500',
|
||||
value: Object.values(props.statistics).reduce(
|
||||
(sum, count) => sum + (count || 0),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.PENDING.value),
|
||||
span: 3,
|
||||
color: 'text-gray-400',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0,
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.PUSHED.value),
|
||||
span: 3,
|
||||
color: 'text-blue-400',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0,
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.UPGRADING.value),
|
||||
span: 3,
|
||||
color: 'text-yellow-500',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0,
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.SUCCESS.value),
|
||||
span: 3,
|
||||
color: 'text-green-500',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0,
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.FAILURE.value),
|
||||
span: 3,
|
||||
color: 'text-red-500',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0,
|
||||
},
|
||||
{
|
||||
label: dictLabel(IoTOtaTaskRecordStatusEnum.CANCELED.value),
|
||||
span: 3,
|
||||
color: 'text-gray-400',
|
||||
value: props.statistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="升级设备统计" :loading="loading">
|
||||
<Row :gutter="20" class="py-5">
|
||||
<Col v-for="item in items" :key="item.label" :span="item.span">
|
||||
<div
|
||||
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
|
||||
>
|
||||
<div class="mb-2 text-3xl font-bold" :class="item.color">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{{ item.label }}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
/** 升级记录的列表字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: '设备名称',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'fromFirmwareVersion',
|
||||
title: '当前版本',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '升级状态',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_OTA_TASK_RECORD_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: '升级进度',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
formatter: ({ row }) => `${row.progress || 0}%`,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '状态描述',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'updateTime',
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IoTOtaTaskRecordApi } from '#/api/iot/ota/task/record';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Card, message, Tabs } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
cancelOtaTaskRecord,
|
||||
getOtaTaskRecordPage,
|
||||
} from '#/api/iot/ota/task/record';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns } from '../data';
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
/** 切换标签 */
|
||||
async function handleTabChange(tabKey: number | string) {
|
||||
activeTab.value = String(tabKey);
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 取消单条记录的升级 */
|
||||
async function handleCancelUpgrade(record: IoTOtaTaskRecordApi.TaskRecord) {
|
||||
await cancelOtaTaskRecord(record.id!);
|
||||
message.success('取消成功');
|
||||
await gridApi.query();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 400,
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
if (!props.taskId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return await getOtaTaskRecordPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
taskId: props.taskId,
|
||||
status:
|
||||
activeTab.value === '' ? undefined : Number(activeTab.value),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<IoTOtaTaskRecordApi.TaskRecord>,
|
||||
});
|
||||
|
||||
/** taskId 变化时重新查询 */
|
||||
watch(
|
||||
() => props.taskId,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
activeTab.value = '';
|
||||
await gridApi.reload();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="升级设备记录">
|
||||
<Tabs v-model:active-key="activeTab" @change="handleTabChange" class="mb-4">
|
||||
<Tabs.TabPane v-for="tab in statusTabs" :key="tab.key" :tab="tab.label" />
|
||||
</Tabs>
|
||||
<Grid>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.cancel'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:ota-task-record:cancel'],
|
||||
ifShow: [
|
||||
IoTOtaTaskRecordStatusEnum.PENDING.value,
|
||||
IoTOtaTaskRecordStatusEnum.PUSHED.value,
|
||||
IoTOtaTaskRecordStatusEnum.UPGRADING.value,
|
||||
].includes(row.status!),
|
||||
popConfirm: {
|
||||
title: '确认要取消该设备的升级任务吗?',
|
||||
confirm: handleCancelUpgrade.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
|
@ -35,8 +36,8 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
label: '分类排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
placeholder: '请输入分类排序',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
precision: 0,
|
||||
},
|
||||
|
|
@ -57,7 +58,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'description',
|
||||
label: '描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类描述',
|
||||
rows: 3,
|
||||
|
|
@ -91,10 +92,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<IotProductCategoryApi.ProductCategory>['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'seq',
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ import { $t } from '#/locales';
|
|||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductCategory' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
|
|
@ -87,7 +85,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid>
|
||||
<Grid table-title="产品分类列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
|
|
@ -95,6 +93,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('ui.actionTitle.create', ['分类']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:product-category:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
|
|
@ -107,6 +106,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:product-category:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
|
@ -114,6 +114,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:product-category:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ const [Modal, modalApi] = useVbenModal({
|
|||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
// 编辑模式:加载数据
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getProductCategory(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ const loading = ref(false);
|
|||
const productList = ref<IotProductApi.Product[]>([]);
|
||||
|
||||
/** 处理选择变化 */
|
||||
function handleChange(value?: number) {
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
function handleChange(value: any) {
|
||||
emit('update:modelValue', value as number | undefined);
|
||||
emit('change', value as number | undefined);
|
||||
}
|
||||
|
||||
/** 获取产品列表 */
|
||||
|
|
@ -47,11 +47,18 @@ onMounted(() => {
|
|||
<template>
|
||||
<Select
|
||||
:value="modelValue"
|
||||
:options="productList.map((p) => ({ label: p.name, value: p.id }))"
|
||||
:options="
|
||||
productList.map((product) => ({
|
||||
label: product.name,
|
||||
value: product.id,
|
||||
}))
|
||||
"
|
||||
:loading="loading"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
class="w-full"
|
||||
option-filter-prop="label"
|
||||
show-search
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Button } from 'antdv-next';
|
||||
|
|
@ -12,13 +12,9 @@ import { Button } from 'antdv-next';
|
|||
import { z } from '#/adapter/form';
|
||||
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
|
||||
|
||||
/** 产品分类列表缓存 */
|
||||
let categoryList: IotProductCategoryApi.ProductCategory[] = [];
|
||||
getSimpleProductCategoryList().then((data) => (categoryList = data));
|
||||
|
||||
/** 基础表单字段(不含图标、图片、描述) */
|
||||
export function useBasicFormSchema(
|
||||
formApi?: any,
|
||||
formApi?: VbenFormApi,
|
||||
generateProductKey?: () => string,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
|
|
@ -114,6 +110,13 @@ export function useBasicFormSchema(
|
|||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => ({
|
||||
// 编辑时设备类型不可改
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -124,6 +127,14 @@ export function useBasicFormSchema(
|
|||
options: getDictOptions(DICT_TYPE.IOT_NET_TYPE, 'number'),
|
||||
placeholder: '请选择联网方式',
|
||||
},
|
||||
// 网关子设备走网关联网,不需要联网方式
|
||||
dependencies: {
|
||||
triggerFields: ['deviceType'],
|
||||
show: (values) =>
|
||||
[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(
|
||||
values.deviceType,
|
||||
),
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -134,6 +145,7 @@ export function useBasicFormSchema(
|
|||
options: getDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE, 'string'),
|
||||
placeholder: '请选择协议类型',
|
||||
},
|
||||
defaultValue: 'mqtt',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
|
|
@ -144,22 +156,10 @@ export function useBasicFormSchema(
|
|||
options: getDictOptions(DICT_TYPE.IOT_SERIALIZE_TYPE, 'string'),
|
||||
placeholder: '请选择序列化类型',
|
||||
},
|
||||
defaultValue: 'json',
|
||||
help: 'iot-gateway-server 默认根据接入的协议类型确定数据格式,仅 MQTT、EMQX 协议支持自定义序列化类型',
|
||||
rules: 'required',
|
||||
},
|
||||
// TODO @haohao:这个貌似不需要?!
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '产品状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 0,
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -180,11 +180,7 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'icon',
|
||||
label: '产品图标',
|
||||
component: 'IconPicker',
|
||||
componentProps: {
|
||||
placeholder: '请选择产品图标',
|
||||
prefix: 'carbon',
|
||||
},
|
||||
component: 'ImageUpload',
|
||||
},
|
||||
{
|
||||
fieldName: 'picUrl',
|
||||
|
|
@ -194,7 +190,7 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'description',
|
||||
label: '产品描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品描述',
|
||||
rows: 3,
|
||||
|
|
@ -204,7 +200,7 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<IotProductApi.Product>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
|
|
@ -217,11 +213,10 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'categoryId',
|
||||
field: 'categoryName',
|
||||
title: '品类',
|
||||
minWidth: 120,
|
||||
formatter: ({ cellValue }) =>
|
||||
categoryList.find((c) => c.id === cellValue)?.name || '未分类',
|
||||
formatter: ({ row }) => row.categoryName || '未分类',
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
|
|
@ -248,15 +243,6 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '产品状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import { onMounted, provide, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { IOT_PROVIDE_KEY } from '@vben/constants';
|
||||
|
||||
import { message, TabPane, Tabs } from 'antdv-next';
|
||||
import { message, Tabs } from 'antdv-next';
|
||||
|
||||
import { getDeviceCount } from '#/api/iot/device/device';
|
||||
import { getProduct } from '#/api/iot/product/product';
|
||||
|
|
@ -15,8 +16,6 @@ import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
|
|||
import ProductDetailsHeader from './modules/header.vue';
|
||||
import ProductDetailsInfo from './modules/info.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -25,7 +24,8 @@ const loading = ref(true);
|
|||
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
||||
const activeTab = ref('info');
|
||||
|
||||
provide('product', product); // 提供产品信息给子组件
|
||||
/** 向子组件提供产品信息 */
|
||||
provide(IOT_PROVIDE_KEY.PRODUCT, product);
|
||||
|
||||
/** 获取产品详情 */
|
||||
async function getProductData(productId: number) {
|
||||
|
|
@ -78,15 +78,12 @@ onMounted(async () => {
|
|||
@refresh="() => getProductData(id)"
|
||||
/>
|
||||
<Tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<TabPane key="info" tab="产品信息">
|
||||
<Tabs.TabPane key="info" tab="产品信息">
|
||||
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
|
||||
</TabPane>
|
||||
<TabPane key="thingModel" tab="物模型(功能定义)">
|
||||
<IoTProductThingModel
|
||||
v-if="activeTab === 'thingModel'"
|
||||
:product-id="id"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="thingModel" tab="物模型(功能定义)">
|
||||
<IoTProductThingModel v-if="activeTab === 'thingModel'" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,22 @@ import type { IotProductApi } from '#/api/iot/product/product';
|
|||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { ProductStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Button, Card, Descriptions, message, Modal } from 'antdv-next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from 'antdv-next';
|
||||
|
||||
import { updateProductStatus } from '#/api/iot/product/product';
|
||||
import {
|
||||
syncProductPropertyTable,
|
||||
updateProductStatus,
|
||||
} from '#/api/iot/product/product';
|
||||
|
||||
import Form from '../../modules/form.vue';
|
||||
|
||||
|
|
@ -26,6 +36,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
|
|
@ -45,7 +56,7 @@ async function copyToClipboard(text: string) {
|
|||
/** 跳转到设备管理 */
|
||||
function goToDeviceList(productId: number) {
|
||||
router.push({
|
||||
path: '/iot/device/device',
|
||||
name: 'IoTDevice',
|
||||
query: { productId: String(productId) },
|
||||
});
|
||||
}
|
||||
|
|
@ -56,29 +67,23 @@ function openEditForm(row: IotProductApi.Product) {
|
|||
}
|
||||
|
||||
/** 发布产品 */
|
||||
function handlePublish(product: IotProductApi.Product) {
|
||||
Modal.confirm({
|
||||
title: '确认发布',
|
||||
content: `确认要发布产品「${product.name}」吗?`,
|
||||
async onOk() {
|
||||
await updateProductStatus(product.id!, ProductStatusEnum.PUBLISHED);
|
||||
message.success('发布成功');
|
||||
emit('refresh');
|
||||
},
|
||||
});
|
||||
async function handlePublish(product: IotProductApi.Product) {
|
||||
await updateProductStatus(product.id!, ProductStatusEnum.PUBLISHED);
|
||||
message.success('发布成功');
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
/** 撤销发布 */
|
||||
function handleUnpublish(product: IotProductApi.Product) {
|
||||
Modal.confirm({
|
||||
title: '确认撤销发布',
|
||||
content: `确认要撤销发布产品「${product.name}」吗?`,
|
||||
async onOk() {
|
||||
await updateProductStatus(product.id!, ProductStatusEnum.UNPUBLISHED);
|
||||
message.success('撤销发布成功');
|
||||
emit('refresh');
|
||||
},
|
||||
});
|
||||
async function handleUnpublish(product: IotProductApi.Product) {
|
||||
await updateProductStatus(product.id!, ProductStatusEnum.UNPUBLISHED);
|
||||
message.success('撤销发布成功');
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
/** 同步物模型超级表结构 */
|
||||
async function handleSyncPropertyTable(product: IotProductApi.Product) {
|
||||
await syncProductPropertyTable(product.id!);
|
||||
message.success('同步成功');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -90,33 +95,47 @@ function handleUnpublish(product: IotProductApi.Product) {
|
|||
<div>
|
||||
<h2 class="text-xl font-bold">{{ product.name }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:product:update'])"
|
||||
:disabled="product.status === ProductStatusEnum.PUBLISHED"
|
||||
@click="openEditForm(product)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
v-if="product.status === ProductStatusEnum.UNPUBLISHED"
|
||||
type="primary"
|
||||
@click="handlePublish(product)"
|
||||
<Popconfirm
|
||||
v-if="
|
||||
product.status === ProductStatusEnum.UNPUBLISHED &&
|
||||
hasAccessByCodes(['iot:product:update'])
|
||||
"
|
||||
:title="`确认要发布产品「${product.name}」吗?`"
|
||||
@confirm="handlePublish(product)"
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
<Button
|
||||
v-if="product.status === ProductStatusEnum.PUBLISHED"
|
||||
danger
|
||||
@click="handleUnpublish(product)"
|
||||
<Button type="primary">发布</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
v-if="
|
||||
product.status === ProductStatusEnum.PUBLISHED &&
|
||||
hasAccessByCodes(['iot:product:update'])
|
||||
"
|
||||
:title="`确认要撤销发布产品「${product.name}」吗?`"
|
||||
@confirm="handleUnpublish(product)"
|
||||
>
|
||||
撤销发布
|
||||
</Button>
|
||||
<Button danger>撤销发布</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
v-if="hasAccessByCodes(['iot:product:update'])"
|
||||
:title="`确认要同步产品「${product.name}」的物模型超级表结构吗?`"
|
||||
@confirm="handleSyncPropertyTable(product)"
|
||||
>
|
||||
<Button>同步物模型表结构</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="mt-4">
|
||||
<Descriptions :column="1">
|
||||
<DescriptionsItem label="ProductKey">
|
||||
<Descriptions.Item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
<Button
|
||||
class="ml-2"
|
||||
|
|
@ -125,15 +144,15 @@ function handleUnpublish(product: IotProductApi.Product) {
|
|||
>
|
||||
复制
|
||||
</Button>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="设备总数">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备总数">
|
||||
<span class="ml-5 mr-2">
|
||||
{{ product.deviceCount ?? '加载中...' }}
|
||||
</span>
|
||||
<Button size="small" @click="goToDeviceList(product.id!)">
|
||||
前往管理
|
||||
</Button>
|
||||
</DescriptionsItem>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ const showProductSecret = ref(false); // 是否显示产品密钥
|
|||
|
||||
/** 格式化日期 */
|
||||
function formatDate(date?: Date | string) {
|
||||
if (!date) return '-';
|
||||
if (!date) {
|
||||
return '-';
|
||||
}
|
||||
return new Date(date).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
|
|
@ -42,66 +44,67 @@ async function copyToClipboard(text: string) {
|
|||
<template>
|
||||
<Card title="产品信息">
|
||||
<Descriptions :column="3" bordered size="small">
|
||||
<DescriptionsItem label="产品名称">
|
||||
<Descriptions.Item label="产品名称">
|
||||
{{ product.name }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="所属分类">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属分类">
|
||||
{{ product.categoryName || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="设备类型">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="product.deviceType"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ formatDate(product.createTime) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="协议类型">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="协议类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PROTOCOL_TYPE"
|
||||
:value="product.protocolType"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="序列化类型">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="序列化类型">
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_SERIALIZE_TYPE"
|
||||
:value="product.serializeType"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="产品状态">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="产品状态">
|
||||
<DictTag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="
|
||||
[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(
|
||||
product.deviceType!,
|
||||
)
|
||||
(
|
||||
[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY] as number[]
|
||||
).includes(product.deviceType!)
|
||||
"
|
||||
label="联网方式"
|
||||
>
|
||||
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem v-if="product.productSecret" label="ProductSecret">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="产品密钥">
|
||||
<span v-if="showProductSecret">{{ product.productSecret }}</span>
|
||||
<span v-else>********</span>
|
||||
<Button class="ml-2" size="small" @click="toggleProductSecretVisible">
|
||||
{{ showProductSecret ? '隐藏' : '显示' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showProductSecret && product.productSecret"
|
||||
class="ml-2"
|
||||
size="small"
|
||||
@click="copyToClipboard(product.productSecret || '')"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="动态注册">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="动态注册">
|
||||
{{ product.registerEnabled ? '已开启' : '未开启' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :span="3" label="产品描述">
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item :span="3" label="产品描述">
|
||||
{{ product.description || '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import { useGridColumns } from './data';
|
|||
import ProductCardView from './modules/card-view.vue';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProduct' });
|
||||
|
||||
const router = useRouter();
|
||||
const categoryList = ref<IotProductCategoryApi.ProductCategory[]>([]);
|
||||
const viewMode = ref<'card' | 'list'>('card');
|
||||
|
|
@ -81,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
|
|||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportProduct(queryParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
|
||||
downloadFileFromBlobPart({ fileName: '物联网产品.xls', source: data });
|
||||
}
|
||||
|
||||
/** 打开产品详情 */
|
||||
|
|
@ -175,7 +173,7 @@ onMounted(() => {
|
|||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 统一搜索工具栏 -->
|
||||
<Card :styles="{ body: { padding: '16px' } }" class="mb-4">
|
||||
<Card :body-style="{ padding: '16px' }" class="!mb-2">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<Input
|
||||
|
|
@ -217,12 +215,14 @@ onMounted(() => {
|
|||
label: $t('ui.actionTitle.create', ['产品']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:product:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.export'),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
auth: ['iot:product:export'],
|
||||
onClick: handleExport,
|
||||
},
|
||||
]"
|
||||
|
|
@ -252,17 +252,20 @@ onMounted(() => {
|
|||
{
|
||||
label: $t('common.detail'),
|
||||
type: 'link',
|
||||
auth: ['iot:product:query'],
|
||||
onClick: openProductDetail.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: '物模型',
|
||||
type: 'link',
|
||||
auth: ['iot:thing-model:query'],
|
||||
onClick: openThingModel.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:product:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
|
@ -270,6 +273,7 @@ onMounted(() => {
|
|||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:product:delete'],
|
||||
disabled: row.status === ProductStatusEnum.PUBLISHED,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { useAccess } from '@vben/access';
|
||||
import { DICT_TYPE, ProductStatusEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isHttpUrl } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -17,6 +19,8 @@ import {
|
|||
} from 'antdv-next';
|
||||
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
import defaultPicUrl from '#/assets/imgs/iot/device.png';
|
||||
import defaultIconUrl from '#/assets/svgs/iot/cube.svg';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -37,6 +41,8 @@ const emit = defineEmits<{
|
|||
thingModel: [productId: number];
|
||||
}>();
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
|
|
@ -46,9 +52,27 @@ const queryParams = ref({
|
|||
});
|
||||
|
||||
/** 获取分类名称 */
|
||||
function getCategoryName(categoryId: number) {
|
||||
const category = props.categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
function getCategoryName(item: any) {
|
||||
const category = props.categoryList.find((c: any) => c.id === item.categoryId);
|
||||
return item.categoryName || category?.name || '未分类';
|
||||
}
|
||||
|
||||
/** 是否按图片 URL 渲染产品图标 */
|
||||
function isImageIcon(icon?: string) {
|
||||
if (!icon) {
|
||||
return true;
|
||||
}
|
||||
return isHttpUrl(icon);
|
||||
}
|
||||
|
||||
/** 产品图标 fallback */
|
||||
function getProductIcon(icon?: string) {
|
||||
return icon || defaultIconUrl;
|
||||
}
|
||||
|
||||
/** 产品图片 fallback */
|
||||
function getProductPic(picUrl?: string) {
|
||||
return picUrl || defaultPicUrl;
|
||||
}
|
||||
|
||||
/** 获取产品列表 */
|
||||
|
|
@ -88,7 +112,7 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-card-view">
|
||||
<div>
|
||||
<!-- 产品卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-96">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
|
|
@ -101,116 +125,144 @@ onMounted(() => {
|
|||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:styles="{ body: { padding: '16px' } }"
|
||||
class="product-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:body-style="{
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}"
|
||||
class="h-full overflow-hidden rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="mb-3 flex items-center">
|
||||
<div class="product-icon">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
|
||||
>
|
||||
<img
|
||||
v-if="isImageIcon(item.icon)"
|
||||
:src="getProductIcon(item.icon)"
|
||||
alt=""
|
||||
class="size-6 object-contain"
|
||||
/>
|
||||
<IconifyIcon
|
||||
:icon="item.icon || 'lucide:box'"
|
||||
v-else
|
||||
:icon="item.icon"
|
||||
class="text-xl"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 min-w-0 flex-1">
|
||||
<div class="product-title">{{ item.name }}</div>
|
||||
<div
|
||||
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_STATUS"
|
||||
:value="item.status"
|
||||
class="status-tag"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="mb-3 flex items-start">
|
||||
<div class="info-list flex-1">
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品分类</span>
|
||||
<span class="info-value text-primary">
|
||||
{{ getCategoryName(item.categoryId) }}
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
产品分类
|
||||
</span>
|
||||
<span class="truncate font-medium text-primary">
|
||||
{{ getCategoryName(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品类型</span>
|
||||
<div class="mb-2 flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
产品类型
|
||||
</span>
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||
:value="item.deviceType"
|
||||
class="info-tag m-0"
|
||||
class="m-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品标识</span>
|
||||
<div class="flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
产品标识
|
||||
</span>
|
||||
<Tooltip :title="item.productKey || item.id" placement="top">
|
||||
<span class="info-value product-key cursor-pointer">
|
||||
<span
|
||||
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
|
||||
>
|
||||
{{ item.productKey || item.id }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 产品图片 -->
|
||||
<div class="product-image">
|
||||
<div
|
||||
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
|
||||
>
|
||||
<Image
|
||||
v-if="item.picUrl"
|
||||
:src="item.picUrl"
|
||||
:src="getProductPic(item.picUrl)"
|
||||
:preview="true"
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="lucide:image"
|
||||
class="text-2xl opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<div class="mt-auto flex gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:product:update'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-edit"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:edit" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:product:query'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-detail"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
|
||||
@click="emit('detail', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:eye" class="mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:thing-model:query'])"
|
||||
size="small"
|
||||
class="action-btn action-btn-model"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
|
||||
@click="emit('thingModel', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:git-branch" class="mr-1" />
|
||||
物模型
|
||||
</Button>
|
||||
<Tooltip v-if="item.status === 1" title="已发布的产品不能删除">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
disabled
|
||||
class="action-btn action-btn-delete !w-8"
|
||||
<template v-if="hasAccessByCodes(['iot:product:delete'])">
|
||||
<div
|
||||
class="h-5 w-px self-center bg-[#dcdfe6] dark:bg-[#3a3a3a]"
|
||||
></div>
|
||||
<Tooltip
|
||||
v-if="item.status === ProductStatusEnum.PUBLISHED"
|
||||
title="已发布的产品不能删除"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
v-else
|
||||
:title="`确认删除产品 ${item.name} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="action-btn action-btn-delete !w-8"
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
disabled
|
||||
class="!h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
v-else
|
||||
:title="`确认删除产品 ${item.name} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="!h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -234,187 +286,3 @@ onMounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-card-view {
|
||||
.product-card {
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 产品图标
|
||||
.product-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 产品标题
|
||||
.product-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.product-key {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 产品图片
|
||||
.product-image {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #1890ff;
|
||||
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--ant-color-split);
|
||||
|
||||
.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 {
|
||||
color: white;
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-detail {
|
||||
color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-model {
|
||||
color: #fa8c16;
|
||||
border-color: #fa8c16;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 夜间模式适配
|
||||
html.dark {
|
||||
.product-card-view {
|
||||
.product-card {
|
||||
.product-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
.info-label {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.product-key {
|
||||
color: rgb(255 255 255 / 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.product-image {
|
||||
color: #69c0ff;
|
||||
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -41,26 +41,25 @@ const getTitle = computed(() => {
|
|||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
const [AdvancedForm, advancedFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useAdvancedFormSchema(),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
/** 基础表单需要 formApi 引用,所以通过 setState 设置 schema */
|
||||
formApi.setState({ schema: useBasicFormSchema(formApi, generateProductKey) });
|
||||
|
||||
/** 获取高级表单的值(如果表单未挂载,则从 formData 中获取) */
|
||||
async function getAdvancedFormValues() {
|
||||
if (advancedFormApi.isMounted) {
|
||||
|
|
@ -104,6 +103,9 @@ const [Modal, modalApi] = useVbenModal({
|
|||
activeKey.value = [];
|
||||
return;
|
||||
}
|
||||
formApi.setState({
|
||||
schema: useBasicFormSchema(formApi, generateProductKey),
|
||||
});
|
||||
// 加载数据
|
||||
const data = modalApi.getData<IotProductApi.Product>();
|
||||
if (!data || !data.id) {
|
||||
|
|
@ -146,9 +148,9 @@ const [Modal, modalApi] = useVbenModal({
|
|||
<div class="mx-4">
|
||||
<Form />
|
||||
<Collapse v-model:active-key="activeKey" class="mt-4">
|
||||
<CollapsePanel key="advanced" header="更多设置">
|
||||
<Collapse.Panel key="advanced" header="更多设置">
|
||||
<AdvancedForm />
|
||||
</CollapsePanel>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
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(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
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 },
|
||||
},
|
||||
},
|
||||
// TODO @haohao:这里是【数据源】【数据目的】
|
||||
{
|
||||
field: 'sinkCount',
|
||||
title: '数据流转数',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1,128 +1,25 @@
|
|||
<script lang="ts" setup>
|
||||
// TODO @haohao:应该先有【规则】【目的】两个 tab;然后,在进行管理操作;类似,apps/web-antd/src/views/ai/chat/manager
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
import { Tabs } from 'antdv-next';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDataRule, getDataRulePage } from '#/api/iot/rule/data/rule';
|
||||
import { $t } from '#/locales';
|
||||
import DataRuleList from './rule/index.vue';
|
||||
import DataSinkList from './sink/index.vue';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DataRuleForm from './rule/data-rule-form.vue';
|
||||
|
||||
/** IoT 数据流转规则列表 */
|
||||
defineOptions({ name: 'IoTDataRule' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DataRuleForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建规则 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑规则 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData({ 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($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} 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,
|
||||
});
|
||||
const activeTabName = ref('rule');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<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>
|
||||
<Tabs v-model:active-key="activeTabName">
|
||||
<Tabs.TabPane key="rule" tab="规则">
|
||||
<DataRuleList />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="sink" tab="目的">
|
||||
<DataSinkList />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,311 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
IoTThingModelTypeEnum,
|
||||
IotDeviceMessageMethodEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Form, Select, Table } from 'antdv-next';
|
||||
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
||||
|
||||
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 过滤设备 */
|
||||
function getFilteredDevices(productId: number) {
|
||||
if (!productId) return [];
|
||||
return deviceList.value.filter(
|
||||
(device: any) => device.productId === productId,
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断是否需要显示标识符选择器 */
|
||||
function shouldShowIdentifierSelect(row: any) {
|
||||
return [
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.method,
|
||||
].includes(row.method);
|
||||
}
|
||||
|
||||
/** 获取物模型选项 */
|
||||
function 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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 加载产品列表 */
|
||||
async function loadProductList() {
|
||||
try {
|
||||
productList.value = await getSimpleProductList();
|
||||
} catch (error) {
|
||||
console.error('加载产品列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载设备列表 */
|
||||
async function loadDeviceList() {
|
||||
try {
|
||||
deviceList.value = await getSimpleDeviceList();
|
||||
} catch (error) {
|
||||
console.error('加载设备列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载物模型数据 */
|
||||
async function loadThingModel(productId: number) {
|
||||
// 已缓存,无需重复加载
|
||||
if (thingModelCache.value.has(productId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const thingModels = await getThingModelListByProductId(productId);
|
||||
thingModelCache.value.set(productId, thingModels);
|
||||
} catch (error) {
|
||||
console.error('加载物模型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 产品变化时处理 */
|
||||
async function handleProductChange(row: any, _index: number) {
|
||||
row.deviceId = 0;
|
||||
row.method = undefined;
|
||||
row.identifier = undefined;
|
||||
row.identifierLoading = false;
|
||||
}
|
||||
|
||||
/** 消息方法变化时处理 */
|
||||
async function handleMethodChange(row: any, _index: number) {
|
||||
// 清空标识符
|
||||
row.identifier = undefined;
|
||||
// 如果需要加载物模型数据
|
||||
if (shouldShowIdentifierSelect(row) && row.productId) {
|
||||
row.identifierLoading = true;
|
||||
await loadThingModel(row.productId);
|
||||
row.identifierLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增按钮操作 */
|
||||
function handleAdd() {
|
||||
const row = {
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
method: undefined,
|
||||
identifier: undefined,
|
||||
identifierLoading: false,
|
||||
};
|
||||
formData.value.push(row);
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
function handleDelete(index: number) {
|
||||
formData.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 表单校验 */
|
||||
function validate() {
|
||||
return formRef.value.validate();
|
||||
}
|
||||
|
||||
/** 表单值 */
|
||||
function getData() {
|
||||
return formData.value;
|
||||
}
|
||||
|
||||
/** 设置表单值 */
|
||||
function 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',
|
||||
},
|
||||
];
|
||||
|
||||
defineExpose({ validate, getData, setData });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form ref="formRef" :model="{ data: formData }">
|
||||
<!-- TODO @haohao:貌似有告警。 -->
|
||||
<!-- TODO @haohao:是不是搞成 web-antd/src/views/erp/finance/receipt/modules/item-form.vue 的做法,通过 Grid;或 apps/web-antd/src/views/infra/demo/demo03/erp/modules/demo03-grade-list.vue;目的:后续 ele 通用性更好! -->
|
||||
<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">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { getDataSinkSimpleList } from '#/api/iot/rule/data/sink';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
|
|
@ -44,11 +46,11 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
export function useRuleFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -63,7 +65,7 @@ export function useRuleFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'description',
|
||||
label: '规则描述',
|
||||
component: 'TextArea',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入规则描述',
|
||||
rows: 3,
|
||||
|
|
@ -82,20 +84,58 @@ export function useRuleFormSchema(): VbenFormSchema[] {
|
|||
{
|
||||
fieldName: 'sinkIds',
|
||||
label: '数据目的',
|
||||
component: 'Select',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
placeholder: '请选择数据目的',
|
||||
api: getDataSinkSimpleList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
allowClear: true,
|
||||
options: [],
|
||||
placeholder: '请选择数据目的',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 数据源配置(行编辑表)的字段 */
|
||||
export function useSourceConfigColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'productId',
|
||||
title: '产品',
|
||||
minWidth: 200,
|
||||
slots: { default: 'productId' },
|
||||
},
|
||||
{
|
||||
field: 'deviceId',
|
||||
title: '设备',
|
||||
minWidth: 200,
|
||||
slots: { default: 'deviceId' },
|
||||
},
|
||||
{
|
||||
field: 'method',
|
||||
title: '消息',
|
||||
minWidth: 200,
|
||||
slots: { default: 'method' },
|
||||
},
|
||||
{
|
||||
field: 'identifier',
|
||||
title: '标识符',
|
||||
minWidth: 250,
|
||||
slots: { default: 'identifier' },
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
export function useGridColumns(): VxeTableGridOptions<DataRuleApi.DataRule>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
|
|
@ -126,13 +166,13 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||
field: 'sourceConfigs',
|
||||
title: '数据源',
|
||||
minWidth: 100,
|
||||
formatter: ({ cellValue }: any) => `${cellValue?.length || 0} 个`,
|
||||
formatter: ({ cellValue }) => `${cellValue?.length || 0} 个`,
|
||||
},
|
||||
{
|
||||
field: 'sinkIds',
|
||||
title: '数据目的',
|
||||
minWidth: 100,
|
||||
formatter: ({ cellValue }: any) => `${cellValue?.length || 0} 个`,
|
||||
formatter: ({ cellValue }) => `${cellValue?.length || 0} 个`,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
|
|
@ -10,15 +11,10 @@ import { deleteDataRule, getDataRulePage } from '#/api/iot/rule/data/rule';
|
|||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DataRuleForm from './data-rule-form.vue';
|
||||
|
||||
// TODO @haohao:貌似和 apps/web-antd/src/views/iot/rule/data/index.vue 重复的。可能这个是对的。然后把 apps/web-antd/src/views/iot/rule/data/index.vue 搞成 tabs;
|
||||
|
||||
/** IoT 数据流转规则列表 */
|
||||
defineOptions({ name: 'IotDataRule' });
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DataRuleForm,
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
|
|
@ -33,18 +29,18 @@ function handleCreate() {
|
|||
}
|
||||
|
||||
/** 编辑规则 */
|
||||
function handleEdit(row: any) {
|
||||
function handleEdit(row: DataRuleApi.DataRule) {
|
||||
formModalApi.setData({ id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 删除规则 */
|
||||
async function handleDelete(row: any) {
|
||||
async function handleDelete(row: DataRuleApi.DataRule) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDataRule(row.id);
|
||||
await deleteDataRule(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
|
|
@ -79,7 +75,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
} as VxeTableGridOptions<DataRuleApi.DataRule>,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
|
||||
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
|
@ -11,17 +13,14 @@ import {
|
|||
getDataRule,
|
||||
updateDataRule,
|
||||
} from '#/api/iot/rule/data/rule';
|
||||
import { getDataSinkSimpleList } from '#/api/iot/rule/data/sink';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import SourceConfigForm from './components/source-config-form.vue';
|
||||
import { useRuleFormSchema } from './data';
|
||||
import { useRuleFormSchema } from '../data';
|
||||
import SourceConfigForm from './source-config-form.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const sourceConfigRef = ref();
|
||||
|
||||
// TODO @haohao:应该放到 modules
|
||||
const formData = ref<DataRuleApi.DataRule>();
|
||||
const sourceConfigRef = ref<InstanceType<typeof SourceConfigForm>>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['数据规则'])
|
||||
|
|
@ -41,22 +40,22 @@ const [Form, formApi] = useVbenForm({
|
|||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
// TODO @haohao:这里需要优化下,参考别的模块写法;
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验数据源配置
|
||||
await sourceConfigRef.value?.validate();
|
||||
|
||||
try {
|
||||
await sourceConfigRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as any;
|
||||
const data = (await formApi.getValues()) as DataRuleApi.DataRule;
|
||||
data.sourceConfigs = sourceConfigRef.value?.getData() || [];
|
||||
|
||||
try {
|
||||
await (formData.value?.id ? updateDataRule(data) : createDataRule(data));
|
||||
// 关闭并提示
|
||||
|
|
@ -74,22 +73,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const data = modalApi.getData<DataRuleApi.DataRule>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, message, Select } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
||||
|
||||
import { useSourceConfigColumns } from '../data';
|
||||
|
||||
const formData = ref<any[]>([]);
|
||||
const productList = ref<any[]>([]); // 产品列表
|
||||
const deviceList = ref<any[]>([]); // 设备列表
|
||||
const thingModelCache = ref<Map<number, any[]>>(new Map()); // 缓存物模型数据,key 为 productId
|
||||
|
||||
/** 上行消息方法列表 */
|
||||
const upstreamMethods = computed(() => {
|
||||
return Object.values(IotDeviceMessageMethodEnum).filter(
|
||||
(item) => item.upstream,
|
||||
);
|
||||
});
|
||||
|
||||
/** 根据产品 ID 过滤设备 */
|
||||
function getFilteredDevices(productId: number) {
|
||||
if (!productId) return [];
|
||||
return deviceList.value.filter(
|
||||
(device: any) => device.productId === productId,
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断是否需要显示标识符选择器 */
|
||||
function shouldShowIdentifierSelect(row: any) {
|
||||
return [
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.method,
|
||||
].includes(row.method);
|
||||
}
|
||||
|
||||
/** 获取物模型选项 */
|
||||
function 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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 加载产品列表 */
|
||||
async function loadProductList() {
|
||||
try {
|
||||
productList.value = await getSimpleProductList();
|
||||
} catch (error) {
|
||||
console.error('加载产品列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载设备列表 */
|
||||
async function loadDeviceList() {
|
||||
try {
|
||||
deviceList.value = await getSimpleDeviceList();
|
||||
} catch (error) {
|
||||
console.error('加载设备列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载物模型数据 */
|
||||
async function loadThingModel(productId: number) {
|
||||
if (thingModelCache.value.has(productId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const thingModels = await getThingModelListByProductId(productId);
|
||||
thingModelCache.value.set(productId, thingModels);
|
||||
} catch (error) {
|
||||
console.error('加载物模型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 产品变化时清空设备 / 消息 / 标识符 */
|
||||
function handleProductChange(rowIndex: number) {
|
||||
const row = formData.value[rowIndex];
|
||||
row.deviceId = 0;
|
||||
row.method = undefined;
|
||||
row.identifier = undefined;
|
||||
row.identifierLoading = false;
|
||||
}
|
||||
|
||||
/** 消息方法变化时清空标识符 + 按需加载物模型 */
|
||||
async function handleMethodChange(rowIndex: number) {
|
||||
const row = formData.value[rowIndex];
|
||||
row.identifier = undefined;
|
||||
if (shouldShowIdentifierSelect(row) && row.productId) {
|
||||
row.identifierLoading = true;
|
||||
await loadThingModel(row.productId);
|
||||
row.identifierLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格配置 */
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useSourceConfigColumns(),
|
||||
data: formData.value,
|
||||
minHeight: 160,
|
||||
border: true,
|
||||
showOverflow: false,
|
||||
rowConfig: { isHover: true, height: 64 },
|
||||
pagerConfig: { enabled: false },
|
||||
toolbarConfig: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
/** 同步 formData 到 vxe-grid */
|
||||
async function reloadGrid() {
|
||||
await nextTick();
|
||||
await gridApi.grid?.reloadData(formData.value);
|
||||
}
|
||||
|
||||
/** 新增一行数据源 */
|
||||
async function handleAdd() {
|
||||
formData.value.push({
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
method: undefined,
|
||||
identifier: undefined,
|
||||
identifierLoading: false,
|
||||
});
|
||||
await reloadGrid();
|
||||
}
|
||||
|
||||
/** 删除一行数据源 */
|
||||
async function handleDelete(rowIndex: number) {
|
||||
formData.value.splice(rowIndex, 1);
|
||||
await reloadGrid();
|
||||
}
|
||||
|
||||
/** 校验全部行;返回 Promise,失败时 reject 第一条错误信息 */
|
||||
function validate() {
|
||||
if (formData.value.length === 0) {
|
||||
message.error('请至少添加一条数据源配置');
|
||||
return Promise.reject(new Error('数据源配置不能为空'));
|
||||
}
|
||||
for (let i = 0; i < formData.value.length; i++) {
|
||||
const row = formData.value[i];
|
||||
if (!row.productId) {
|
||||
message.error(`第 ${i + 1} 行:产品不能为空`);
|
||||
return Promise.reject(new Error('产品不能为空'));
|
||||
}
|
||||
if (row.deviceId === undefined || row.deviceId === null) {
|
||||
message.error(`第 ${i + 1} 行:设备不能为空`);
|
||||
return Promise.reject(new Error('设备不能为空'));
|
||||
}
|
||||
if (!row.method) {
|
||||
message.error(`第 ${i + 1} 行:消息方法不能为空`);
|
||||
return Promise.reject(new Error('消息方法不能为空'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** 取当前所有行的值(剔除 identifierLoading 等仅供 UI 使用的临时字段) */
|
||||
function getData() {
|
||||
return formData.value.map(
|
||||
({ identifierLoading: _identifierLoading, ...rest }) => rest,
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置初始数据 */
|
||||
async function setData(data: any[]) {
|
||||
formData.value = (data || []).map((item) => ({
|
||||
...item,
|
||||
identifierLoading: false,
|
||||
}));
|
||||
// 为已有数据预加载物模型;并行加载,不阻塞 reloadGrid
|
||||
await Promise.all(
|
||||
(data || [])
|
||||
.filter((item) => item.productId && shouldShowIdentifierSelect(item))
|
||||
.map((item) => loadThingModel(item.productId)),
|
||||
);
|
||||
await reloadGrid();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProductList(), loadDeviceList()]);
|
||||
});
|
||||
|
||||
defineExpose({ validate, getData, setData });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Grid>
|
||||
<template #productId="{ rowIndex }">
|
||||
<Select
|
||||
v-model:value="formData[rowIndex].productId"
|
||||
placeholder="请选择产品"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="
|
||||
productList.map((p: any) => ({ label: p.name, value: p.id }))
|
||||
"
|
||||
class="w-full"
|
||||
@change="() => handleProductChange(rowIndex)"
|
||||
/>
|
||||
</template>
|
||||
<template #deviceId="{ rowIndex }">
|
||||
<Select
|
||||
v-model:value="formData[rowIndex].deviceId"
|
||||
placeholder="请选择设备"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="[
|
||||
{ label: '全部设备', value: 0 },
|
||||
...getFilteredDevices(formData[rowIndex].productId).map(
|
||||
(d: any) => ({
|
||||
label: d.deviceName,
|
||||
value: d.id,
|
||||
}),
|
||||
),
|
||||
]"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
<template #method="{ rowIndex }">
|
||||
<Select
|
||||
v-model:value="formData[rowIndex].method"
|
||||
placeholder="请选择消息"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="
|
||||
upstreamMethods.map((m: any) => ({
|
||||
label: m.name,
|
||||
value: m.method,
|
||||
}))
|
||||
"
|
||||
class="w-full"
|
||||
@change="() => handleMethodChange(rowIndex)"
|
||||
/>
|
||||
</template>
|
||||
<template #identifier="{ rowIndex }">
|
||||
<Select
|
||||
v-if="shouldShowIdentifierSelect(formData[rowIndex])"
|
||||
v-model:value="formData[rowIndex].identifier"
|
||||
placeholder="请选择标识符"
|
||||
show-search
|
||||
allow-clear
|
||||
:loading="formData[rowIndex].identifierLoading"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
:options="getThingModelOptions(formData[rowIndex])"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-xs text-muted-foreground">-</span>
|
||||
</template>
|
||||
<template #actions="{ rowIndex }">
|
||||
<Button danger type="link" @click="handleDelete(rowIndex)">删除</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
<div class="mt-3 text-center">
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -16,15 +16,18 @@ const props = defineProps<{
|
|||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
interface KeyValueItem {
|
||||
_uid: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
const items = ref<KeyValueItem[]>([]); // 内部 key-value 项列表
|
||||
|
||||
/** 添加项目 */
|
||||
function addItem() {
|
||||
items.value.push({ key: '', value: '' });
|
||||
uidCounter += 1;
|
||||
items.value.push({ _uid: uidCounter, key: '', value: '' });
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
|
|
@ -54,24 +57,24 @@ watch(
|
|||
if (isEmpty(val) || !isEmpty(items.value)) {
|
||||
return;
|
||||
}
|
||||
items.value = Object.entries(props.modelValue).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
items.value = Object.entries(props.modelValue).map(([key, value]) => {
|
||||
uidCounter += 1;
|
||||
return { _uid: uidCounter, key, value };
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(item, index) in items" :key="index" class="mb-2 flex w-full">
|
||||
<Input v-model="item.key" class="mr-2" placeholder="键" />
|
||||
<Input v-model="item.value" placeholder="值" />
|
||||
<Button class="ml-2" text danger @click="removeItem(index)">
|
||||
<div v-for="(item, index) in items" :key="item._uid" class="mb-2 flex w-full">
|
||||
<Input v-model:value="item.key" class="mr-2" placeholder="键" />
|
||||
<Input v-model:value="item.value" placeholder="值" />
|
||||
<Button class="ml-2" type="link" danger @click="removeItem(index)">
|
||||
<IconifyIcon icon="ant-design:delete-outlined" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<Button text type="primary" @click="addItem">
|
||||
<Button type="link" @click="addItem">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" />
|
||||
{{ addButtonText }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useClipboard, useVModel } from '@vueuse/core';
|
||||
import { Button, Form, Input, message } from 'antdv-next';
|
||||
|
||||
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
|
||||
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const TABLE_SQL = `CREATE TABLE iot_device_message_sink (
|
||||
id VARCHAR(64) NOT NULL COMMENT '消息ID',
|
||||
device_id BIGINT NOT NULL COMMENT '设备编号',
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
method VARCHAR(128) COMMENT '请求方法',
|
||||
report_time DATETIME COMMENT '上报时间',
|
||||
data TEXT COMMENT '完整消息JSON',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (id) USING BTREE,
|
||||
INDEX idx_create_time (create_time ASC) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'IoT 设备消息流转目标表';`;
|
||||
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
const showSqlTip = ref(false);
|
||||
const copied = ref(false);
|
||||
const { copy } = useClipboard();
|
||||
let copyResetTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
async function handleCopySql() {
|
||||
await copy(TABLE_SQL);
|
||||
copied.value = true;
|
||||
message.success('建表 SQL 已复制到剪贴板');
|
||||
if (copyResetTimer) {
|
||||
clearTimeout(copyResetTimer);
|
||||
}
|
||||
copyResetTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
copyResetTimer = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyResetTimer) {
|
||||
clearTimeout(copyResetTimer);
|
||||
copyResetTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
type: `${IotDataSinkTypeEnum.DATABASE}`,
|
||||
jdbcUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
tableName: 'iot_device_message_sink',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form.Item
|
||||
:name="['config', 'jdbcUrl']"
|
||||
:rules="[
|
||||
{ required: true, message: 'JDBC 连接地址不能为空', trigger: 'blur' },
|
||||
]"
|
||||
label="JDBC 地址"
|
||||
>
|
||||
<Input
|
||||
v-model:value="config.jdbcUrl"
|
||||
placeholder="请输入 JDBC 连接地址,如:jdbc:mysql://localhost:3306/iot_data"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'username']"
|
||||
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
|
||||
label="用户名"
|
||||
>
|
||||
<Input v-model:value="config.username" placeholder="请输入数据库用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'password']"
|
||||
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
|
||||
label="密码"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="config.password"
|
||||
placeholder="请输入数据库密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'tableName']"
|
||||
:rules="[{ required: true, message: '目标表名不能为空', trigger: 'blur' }]"
|
||||
label="目标表名"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
v-model:value="config.tableName"
|
||||
placeholder="目标表名"
|
||||
class="w-[240px]"
|
||||
/>
|
||||
<Button type="link" @click="showSqlTip = !showSqlTip">
|
||||
<IconifyIcon
|
||||
:icon="showSqlTip ? 'lucide:chevron-up' : 'lucide:file-text'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ showSqlTip ? '收起表结构提示' : '查看表结构提示' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<div
|
||||
v-if="showSqlTip"
|
||||
class="mt-2 overflow-hidden rounded border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between bg-gray-100 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||
目标数据库需包含以下结构的表,才能正常接收数据流转的消息
|
||||
</span>
|
||||
<Button size="small" @click="handleCopySql">
|
||||
<IconifyIcon
|
||||
:icon="copied ? 'lucide:check' : 'lucide:copy'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ copied ? '已复制' : '复制 SQL' }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="m-0 overflow-x-auto bg-gray-50 p-3 font-mono text-[12px] leading-normal text-gray-800 dark:bg-gray-900 dark:text-gray-200"
|
||||
><code>{{ TABLE_SQL }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,53 +1,55 @@
|
|||
<!--suppress HttpUrlsUsage -->
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { FormItem, Input, Select } from 'antdv-next';
|
||||
import { Form, Input, Select } from 'antdv-next';
|
||||
|
||||
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
|
||||
|
||||
import KeyValueEditor from './components/key-value-editor.vue';
|
||||
|
||||
defineOptions({ name: 'HttpConfigForm' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as any;
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
// noinspection HttpUrlsUsage
|
||||
/** URL处理 */
|
||||
const urlPrefix = ref('http://');
|
||||
const urlPrefix = ref<'http://' | 'https://'>('http://');
|
||||
const urlPath = ref('');
|
||||
const fullUrl = computed(() => {
|
||||
return urlPath.value ? urlPrefix.value + urlPath.value : '';
|
||||
});
|
||||
const fullUrl = computed(() =>
|
||||
urlPath.value ? urlPrefix.value + urlPath.value : '',
|
||||
);
|
||||
|
||||
function syncUrlFields(url?: string) {
|
||||
if (url?.startsWith('https://')) {
|
||||
urlPrefix.value = 'https://';
|
||||
urlPath.value = url.slice(8);
|
||||
} else if (url?.startsWith('http://')) {
|
||||
urlPrefix.value = 'http://';
|
||||
urlPath.value = url.slice(7);
|
||||
} else {
|
||||
urlPath.value = url ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听 URL 变化 */
|
||||
watch([urlPrefix, urlPath], () => {
|
||||
config.value.url = fullUrl.value;
|
||||
});
|
||||
|
||||
/** 组件初始化 */
|
||||
watch(
|
||||
() => config.value?.url,
|
||||
(url) => syncUrlFields(url),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
// 初始化 URL
|
||||
if (config.value.url) {
|
||||
if (config.value.url.startsWith('https://')) {
|
||||
urlPrefix.value = 'https://';
|
||||
urlPath.value = config.value.url.slice(8);
|
||||
} else if (config.value.url.startsWith('http://')) {
|
||||
urlPrefix.value = 'http://';
|
||||
urlPath.value = config.value.url.slice(7);
|
||||
} else {
|
||||
urlPath.value = config.value.url;
|
||||
}
|
||||
}
|
||||
syncUrlFields(config.value.url);
|
||||
return;
|
||||
}
|
||||
|
||||
config.value = {
|
||||
type: `${IotDataSinkTypeEnum.HTTP}`,
|
||||
url: '',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
|
|
@ -55,51 +57,48 @@ onMounted(() => {
|
|||
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>
|
||||
<Form.Item
|
||||
:name="['config', 'url']"
|
||||
:rules="[{ required: true, message: '请求地址不能为空', trigger: 'blur' }]"
|
||||
label="请求地址"
|
||||
>
|
||||
<Input v-model:value="urlPath" placeholder="请输入请求地址">
|
||||
<template #addonBefore>
|
||||
<Select v-model:value="urlPrefix" class="w-[100px]">
|
||||
<Select.Option value="http://">http://</Select.Option>
|
||||
<Select.Option value="https://">https://</Select.Option>
|
||||
</Select>
|
||||
</template>
|
||||
</Input>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'method']"
|
||||
:rules="[
|
||||
{ required: true, message: '请求方法不能为空', trigger: 'change' },
|
||||
]"
|
||||
label="请求方法"
|
||||
>
|
||||
<Select v-model:value="config.method" placeholder="请选择请求方法">
|
||||
<Select.Option value="GET">GET</Select.Option>
|
||||
<Select.Option value="POST">POST</Select.Option>
|
||||
<Select.Option value="PUT">PUT</Select.Option>
|
||||
<Select.Option value="DELETE">DELETE</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="请求头">
|
||||
<KeyValueEditor v-model="config.headers" add-button-text="添加请求头" />
|
||||
</Form.Item>
|
||||
<Form.Item label="请求参数">
|
||||
<KeyValueEditor v-model="config.query" add-button-text="添加参数" />
|
||||
</Form.Item>
|
||||
<Form.Item label="请求体">
|
||||
<Input.TextArea
|
||||
v-model:value="config.body"
|
||||
placeholder="请输入内容"
|
||||
:rows="4"
|
||||
/>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
export { default as DatabaseConfigForm } from './database-config-form.vue';
|
||||
export { default as HttpConfigForm } from './http-config-form.vue';
|
||||
export { default as KafkaMqConfigForm } from './kafka-mq-config-form.vue';
|
||||
export { default as MqttConfigForm } from './mqtt-config-form.vue';
|
||||
export { default as RabbitMqConfigForm } from './rabbit-mq-config-form.vue';
|
||||
export { default as RedisStreamConfigForm } from './redis-stream-config-form.vue';
|
||||
export { default as RocketMqConfigForm } from './rocket-mq-config-form.vue';
|
||||
export { default as TcpConfigForm } from './tcp-config-form.vue';
|
||||
export { default as WebSocketConfigForm } from './websocket-config-form.vue';
|
||||
|
|
|
|||
|
|
@ -4,22 +4,20 @@ import { onMounted } from 'vue';
|
|||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { FormItem, Input, Switch } from 'antdv-next';
|
||||
import { Form, Input, Switch } from 'antdv-next';
|
||||
|
||||
defineOptions({ name: 'KafkaMQConfigForm' });
|
||||
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as any;
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
type: `${IotDataSinkTypeEnum.KAFKA}`,
|
||||
bootstrapServers: '',
|
||||
username: '',
|
||||
password: '',
|
||||
|
|
@ -30,27 +28,38 @@ onMounted(() => {
|
|||
</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>
|
||||
<Form.Item
|
||||
:name="['config', 'bootstrapServers']"
|
||||
:rules="[{ required: true, message: '服务地址不能为空', trigger: 'blur' }]"
|
||||
label="服务地址"
|
||||
>
|
||||
<Input
|
||||
v-model:value="config.bootstrapServers"
|
||||
placeholder="请输入服务地址,如:localhost:9092"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'username']"
|
||||
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
|
||||
label="用户名"
|
||||
>
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'password']"
|
||||
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
|
||||
label="密码"
|
||||
>
|
||||
<Input.Password v-model:value="config.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item :name="['config', 'ssl']" label="启用 SSL">
|
||||
<Switch v-model:checked="config.ssl" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'topic']"
|
||||
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
|
||||
label="主题"
|
||||
>
|
||||
<Input v-model:value="config.topic" placeholder="请输入主题" />
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,22 +4,20 @@ import { onMounted } from 'vue';
|
|||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { FormItem, Input } from 'antdv-next';
|
||||
import { Form, Input } from 'antdv-next';
|
||||
|
||||
defineOptions({ name: 'MqttConfigForm' });
|
||||
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as any;
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
type: `${IotDataSinkTypeEnum.MQTT}`,
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
|
|
@ -30,27 +28,44 @@ onMounted(() => {
|
|||
</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>
|
||||
<Form.Item
|
||||
:name="['config', 'url']"
|
||||
:rules="[{ required: true, message: '服务地址不能为空', trigger: 'blur' }]"
|
||||
label="服务地址"
|
||||
>
|
||||
<Input
|
||||
v-model:value="config.url"
|
||||
placeholder="请输入 MQTT 服务地址,如:mqtt://localhost:1883"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'username']"
|
||||
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
|
||||
label="用户名"
|
||||
>
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'password']"
|
||||
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
|
||||
label="密码"
|
||||
>
|
||||
<Input.Password v-model:value="config.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'clientId']"
|
||||
:rules="[
|
||||
{ required: true, message: '客户端 ID 不能为空', trigger: 'blur' },
|
||||
]"
|
||||
label="客户端 ID"
|
||||
>
|
||||
<Input v-model:value="config.clientId" placeholder="请输入客户端 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'topic']"
|
||||
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
|
||||
label="主题"
|
||||
>
|
||||
<Input v-model:value="config.topic" placeholder="请输入主题" />
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,27 +4,25 @@ import { onMounted } from 'vue';
|
|||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { FormItem, Input, InputNumber } from 'antdv-next';
|
||||
import { Form, Input, InputNumber } from 'antdv-next';
|
||||
|
||||
defineOptions({ name: 'RabbitMQConfigForm' });
|
||||
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
}>();
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit) as any;
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
/** 组件初始化 */
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
type: `${IotDataSinkTypeEnum.RABBITMQ}`,
|
||||
host: '',
|
||||
port: 5672,
|
||||
virtualHost: '/',
|
||||
username: '',
|
||||
password: '',
|
||||
virtualHost: '/',
|
||||
exchange: '',
|
||||
routingKey: '',
|
||||
queue: '',
|
||||
|
|
@ -33,45 +31,78 @@ onMounted(() => {
|
|||
</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>
|
||||
<Form.Item
|
||||
:name="['config', 'host']"
|
||||
:rules="[{ required: true, message: '主机地址不能为空', trigger: 'blur' }]"
|
||||
label="主机地址"
|
||||
>
|
||||
<Input
|
||||
v-model:value="config.host"
|
||||
placeholder="请输入主机地址,如:localhost"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'port']"
|
||||
:rules="[
|
||||
{ required: true, message: '端口不能为空', trigger: 'blur' },
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 65_535,
|
||||
message: '端口号范围 1-65535',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
label="端口"
|
||||
>
|
||||
<InputNumber
|
||||
v-model:value="config.port"
|
||||
:max="65535"
|
||||
:min="1"
|
||||
placeholder="请输入端口"
|
||||
class="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'virtualHost']"
|
||||
:rules="[{ required: true, message: '虚拟主机不能为空', trigger: 'blur' }]"
|
||||
label="虚拟主机"
|
||||
>
|
||||
<Input v-model:value="config.virtualHost" placeholder="请输入虚拟主机" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'username']"
|
||||
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
|
||||
label="用户名"
|
||||
>
|
||||
<Input v-model:value="config.username" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'password']"
|
||||
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
|
||||
label="密码"
|
||||
>
|
||||
<Input.Password v-model:value="config.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'exchange']"
|
||||
:rules="[{ required: true, message: '交换机不能为空', trigger: 'blur' }]"
|
||||
label="交换机"
|
||||
>
|
||||
<Input v-model:value="config.exchange" placeholder="请输入交换机" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'routingKey']"
|
||||
:rules="[{ required: true, message: '路由键不能为空', trigger: 'blur' }]"
|
||||
label="路由键"
|
||||
>
|
||||
<Input v-model:value="config.routingKey" placeholder="请输入路由键" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
:name="['config', 'queue']"
|
||||
:rules="[{ required: true, message: '队列不能为空', trigger: 'blur' }]"
|
||||
label="队列"
|
||||
>
|
||||
<Input v-model:value="config.queue" placeholder="请输入队列" />
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue