feat(web-antdv-next): sync IoT module

pull/355/head
XuZhiqiang 2026-06-04 16:17:45 +08:00
parent 6315055c08
commit 09970d89a4
158 changed files with 7620 additions and 7378 deletions

View File

@ -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',
);
}

View File

@ -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,
});
}

View File

@ -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 },
);

View File

@ -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 } },
);
}

View File

@ -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}`);
}

View File

@ -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,

View File

@ -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; // 创建时间
}
}

View File

@ -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}`,
);
}

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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`, {

View File

@ -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; // 时间轴

View File

@ -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 },
});
}
*/

View File

@ -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>(

View File

@ -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>(

View File

@ -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

View File

@ -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

View File

@ -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'),
},
],
},

View File

@ -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',

View File

@ -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),

View File

@ -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();

View File

@ -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',

View File

@ -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>

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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,
},
{

View File

@ -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),

View File

@ -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;

View File

@ -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"

View File

@ -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),
},
]"

View File

@ -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

View File

@ -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 }">

View File

@ -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"
>
<!-- 添加渐变背景层 -->

View File

@ -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

View File

@ -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>

View File

@ -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),

View File

@ -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>

View File

@ -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>

View File

@ -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(),

View File

@ -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>

View File

@ -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',

View File

@ -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 {

View File

@ -30,7 +30,10 @@ const [Form, formApi] = useVbenForm({
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});

View File

@ -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 {

View File

@ -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: {},
};

View File

@ -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();
});

View File

@ -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;
});
/** 初始化图表 */

View File

@ -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 resolveDOM
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>

View File

@ -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;
});
/** 初始化图表 */

View File

@ -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>

View File

@ -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' },
},
];
}

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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),

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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' },
},
];
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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' },
},
];
}

View File

@ -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>

View File

@ -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,
},

View File

@ -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),

View File

@ -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();

View File

@ -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>

View File

@ -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: '创建时间',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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]),

View File

@ -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>

View File

@ -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>

View File

@ -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' },
},
];
}

View File

@ -1,128 +1,25 @@
<script lang="ts" setup>
// TODO @haohao tabapps/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>

View File

@ -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>

View File

@ -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',

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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