feat(iot): 迁移 ele 的 alert、device、product、ota、home、thingmodel 的实现

pull/345/head
YunaiV 2026-05-20 13:31:27 +08:00
parent f1f8f4e64a
commit e816288b82
78 changed files with 13325 additions and 54 deletions

View File

@ -0,0 +1,205 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AlertConfigApi } from '#/api/iot/alert/config';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleRuleSceneList } from '#/api/iot/rule/scene';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改告警配置的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '配置名称',
component: 'Input',
componentProps: {
placeholder: '请输入配置名称',
},
rules: 'required',
},
{
fieldName: 'description',
label: '配置描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入配置描述',
rows: 3,
},
},
{
fieldName: 'level',
label: '告警级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_ALERT_LEVEL, 'number'),
placeholder: '请选择告警级别',
},
rules: 'required',
},
{
fieldName: 'status',
label: '配置状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
defaultValue: CommonStatusEnum.ENABLE,
rules: 'required',
},
{
fieldName: 'sceneRuleIds',
label: '关联场景联动规则',
component: 'ApiSelect',
componentProps: {
api: getSimpleRuleSceneList,
labelField: 'name',
valueField: 'id',
multiple: true,
placeholder: '请选择关联的场景联动规则',
},
defaultValue: [],
rules: 'required',
},
{
fieldName: 'receiveUserIds',
label: '接收的用户',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
multiple: true,
placeholder: '请选择接收的用户',
},
defaultValue: [],
rules: 'required',
},
{
fieldName: 'receiveTypes',
label: '接收类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_ALERT_RECEIVE_TYPE, 'number'),
multiple: true,
placeholder: '请选择接收类型',
},
defaultValue: [],
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '配置名称',
component: 'Input',
componentProps: {
placeholder: '请输入配置名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '配置状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择配置状态',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<AlertConfigApi.AlertConfig>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '配置编号',
minWidth: 80,
},
{
field: 'name',
title: '配置名称',
minWidth: 150,
},
{
field: 'description',
title: '配置描述',
minWidth: 200,
},
{
field: 'level',
title: '告警级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_ALERT_LEVEL },
},
},
{
field: 'status',
title: '配置状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'sceneRuleIds',
title: '关联场景联动规则',
minWidth: 150,
formatter: ({ cellValue }) => `${cellValue?.length || 0}`,
},
{
field: 'receiveUserNames',
title: '接收人',
minWidth: 150,
},
{
field: 'receiveTypes',
title: '接收类型',
minWidth: 150,
slots: { default: 'receiveTypes' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,136 @@
<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 { ElLoading, ElMessage } from 'element-plus';
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 { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建告警配置 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑告警配置 */
function handleEdit(row: AlertConfigApi.AlertConfig) {
formModalApi.setData(row).open();
}
/** 删除告警配置 */
async function handleDelete(row: AlertConfigApi.AlertConfig) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteAlertConfig(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getAlertConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<AlertConfigApi.AlertConfig>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['告警配置']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:alert-config:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #receiveTypes="{ row }">
<DictTag
v-for="(type, index) in row.receiveTypes"
:key="index"
:type="DICT_TYPE.IOT_ALERT_RECEIVE_TYPE"
:value="type"
class="mr-1"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:alert-config:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:alert-config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { AlertConfigApi } from '#/api/iot/alert/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createAlertConfig,
getAlertConfig,
updateAlertConfig,
} from '#/api/iot/alert/config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AlertConfigApi.AlertConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['告警配置'])
: $t('ui.actionTitle.create', ['告警配置']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as AlertConfigApi.AlertConfig;
try {
await (formData.value?.id
? updateAlertConfig(data)
: createAlertConfig(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AlertConfigApi.AlertConfig>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getAlertConfig(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,165 @@
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';
import { getSimpleAlertConfigList } from '#/api/iot/alert/config';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getRangePickerDefaultProps } from '#/utils';
/** 关联数据 */
let productList: IotProductApi.Product[] = [];
let deviceList: IotDeviceApi.Device[] = [];
getSimpleProductList().then((data) => (productList = data));
getSimpleDeviceList().then((data) => (deviceList = data));
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'configId',
label: '告警配置',
component: 'ApiSelect',
componentProps: {
api: getSimpleAlertConfigList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择告警配置',
clearable: true,
filterable: true,
},
},
{
fieldName: 'configLevel',
label: '告警级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_ALERT_LEVEL, 'number'),
placeholder: '请选择告警级别',
clearable: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
clearable: true,
filterable: true,
},
},
{
fieldName: 'deviceId',
label: '设备',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceList,
labelField: 'deviceName',
valueField: 'id',
placeholder: '请选择设备',
clearable: true,
filterable: true,
},
},
{
fieldName: 'processStatus',
label: '是否处理',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING),
placeholder: '请选择是否处理',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<AlertRecordApi.AlertRecord>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '记录编号',
minWidth: 80,
},
{
field: 'configName',
title: '告警名称',
minWidth: 150,
},
{
field: 'configLevel',
title: '告警级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_ALERT_LEVEL },
},
},
{
field: 'productId',
title: '产品名称',
minWidth: 120,
formatter: ({ cellValue }) =>
productList.find((p) => p.id === cellValue)?.name || '-',
},
{
field: 'deviceId',
title: '设备名称',
minWidth: 120,
formatter: ({ cellValue }) =>
deviceList.find((d) => d.id === cellValue)?.deviceName || '-',
},
{
field: 'deviceMessage',
title: '触发的设备消息',
minWidth: 150,
slots: { default: 'deviceMessage' },
},
{
field: 'processStatus',
title: '是否处理',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'processRemark',
title: '处理结果',
minWidth: 150,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,136 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AlertRecordApi } from '#/api/iot/alert/record';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElLoading,
ElMessage,
ElMessageBox,
ElPopover,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAlertRecordPage, processAlertRecord } from '#/api/iot/alert/record';
import { useGridColumns, useGridFormSchema } from './data';
/** 把设备消息序列化成可读字符串 */
function stringifyDeviceMessage(deviceMessage: any) {
if (!deviceMessage) {
return '';
}
return typeof deviceMessage === 'object'
? JSON.stringify(deviceMessage, null, 2)
: String(deviceMessage);
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理告警记录 */
async function handleProcess(row: AlertRecordApi.AlertRecord) {
try {
const { value: processRemark } = await ElMessageBox.prompt(
'请输入处理原因:',
'处理告警记录',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请输入处理原因',
inputValidator: (value: string) => {
if (!value) {
return '请输入处理原因';
}
return true;
},
},
);
const loadingInstance = ElLoading.service({ text: '正在处理...' });
try {
await processAlertRecord(row.id!, processRemark);
ElMessage.success('处理成功');
handleRefresh();
} finally {
loadingInstance.close();
}
} catch {
//
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getAlertRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<AlertRecordApi.AlertRecord>,
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="">
<template #deviceMessage="{ row }">
<ElPopover
v-if="row.deviceMessage"
placement="top-start"
trigger="hover"
:popper-style="{ maxWidth: '600px' }"
>
<template #reference>
<ElButton size="small" type="primary" link>
<IconifyIcon icon="ant-design:eye-outlined" class="mr-1" />
查看消息
</ElButton>
</template>
<pre class="text-xs">{{ stringifyDeviceMessage(row.deviceMessage) }}</pre>
</ElPopover>
<span v-else class="text-gray-400">-</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '处理',
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:alert-record:process'],
onClick: handleProcess.bind(null, row),
ifShow: !row.processStatus,
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,336 @@
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';
import { z } from '#/adapter/form';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
/** 基础表单字段 */
export function useBasicFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
},
dependencies: {
triggerFields: ['id'],
disabled: (values: any) => !!values?.id,
},
rules: 'required',
},
{
component: 'Input',
fieldName: 'deviceType',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'deviceName',
label: 'DeviceName',
component: 'Input',
componentProps: {
placeholder: '请输入 DeviceName',
},
dependencies: {
triggerFields: ['id'],
disabled: (values: any) => !!values?.id,
},
rules: z
.string()
.min(4, 'DeviceName 长度不能少于 4 个字符')
.max(32, 'DeviceName 长度不能超过 32 个字符')
.regex(
/^[\w.\-:@]{4,32}$/,
'支持英文字母、数字、下划线_、中划线-)、点号(.)、半角冒号(:)和特殊字符@',
),
},
];
}
/** 高级设置表单字段(更多设置) */
export function useAdvancedFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'nickname',
label: '备注名称',
component: 'Input',
componentProps: {
placeholder: '请输入备注名称',
},
rules: z
.string()
.min(4, '备注名称长度限制为 4~64 个字符')
.max(64, '备注名称长度限制为 4~64 个字符')
.regex(
/^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/,
'备注名称只能包含中文、英文字母、日文、数字和下划线_',
)
.optional()
.or(z.literal('')),
},
{
fieldName: 'picUrl',
label: '设备图片',
component: 'ImageUpload',
},
{
fieldName: 'groupIds',
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
placeholder: '请选择设备分组',
},
},
{
fieldName: 'serialNumber',
label: '设备序列号',
component: 'Input',
componentProps: {
placeholder: '请输入设备序列号',
},
rules: z
.string()
.regex(/^[\w-]+$/, '序列号只能包含字母、数字、中划线和下划线')
.optional()
.or(z.literal('')),
},
{
fieldName: 'longitude',
label: '设备经度',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入设备经度',
min: -180,
max: 180,
precision: 6,
},
rules: z
.number()
.min(-180, '经度范围为 -180 到 180')
.max(180, '经度范围为 -180 到 180')
.optional()
.nullable(),
},
{
fieldName: 'latitude',
label: '设备纬度',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入设备纬度',
min: -90,
max: 90,
precision: 6,
},
rules: z
.number()
.min(-90, '纬度范围为 -90 到 90')
.max(90, '纬度范围为 -90 到 90')
.optional()
.nullable(),
},
];
}
/** 设备分组表单 */
export function useGroupFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'groupIds',
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
placeholder: '请选择设备分组',
},
rules: 'required',
},
];
}
/** 设备导入表单 */
export function useImportFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'file',
label: '设备数据',
component: 'Upload',
rules: 'required',
help: '仅允许导入 xls、xlsx 格式文件',
},
{
fieldName: 'updateSupport',
label: '是否覆盖',
component: 'Switch',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
rules: z.boolean().default(false),
help: '是否更新已经存在的设备数据',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
clearable: true,
},
},
{
fieldName: 'deviceName',
label: 'DeviceName',
component: 'Input',
componentProps: {
placeholder: '请输入 DeviceName',
clearable: true,
},
},
{
fieldName: 'nickname',
label: '备注名称',
component: 'Input',
componentProps: {
placeholder: '请输入备注名称',
clearable: true,
},
},
{
fieldName: 'deviceType',
label: '设备类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
placeholder: '请选择设备类型',
clearable: true,
},
},
{
fieldName: 'status',
label: '设备状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number'),
placeholder: '请选择设备状态',
clearable: true,
},
},
{
fieldName: 'groupId',
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择设备分组',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
},
{
field: 'nickname',
title: '备注名称',
minWidth: 120,
},
{
field: 'picUrl',
title: '设备图片',
width: 100,
cellRender: {
name: 'CellImage',
},
},
{
field: 'productId',
title: '所属产品',
minWidth: 120,
slots: { default: 'product' },
},
{
field: 'deviceType',
title: '设备类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
},
},
{
field: 'groupIds',
title: '所属分组',
minWidth: 150,
slots: { default: 'groups' },
},
{
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
{
field: 'onlineTime',
title: '最后上线时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { DeviceTypeEnum } from '@vben/constants';
import { ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { getDevice } from '#/api/iot/device/device';
import { getProduct, ProtocolTypeEnum } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import DeviceDetailConfig from './modules/config.vue';
import DeviceDetailsHeader from './modules/header.vue';
import DeviceDetailsInfo from './modules/info.vue';
import DeviceDetailsMessage from './modules/message.vue';
import DeviceModbusConfig from './modules/modbus-config.vue';
import DeviceDetailsSimulator from './modules/simulator.vue';
import DeviceDetailsSubDevice from './modules/sub-device.vue';
import DeviceDetailsThingModel from './modules/thing-model.vue';
const route = useRoute();
const router = useRouter();
const id = Number(route.params.id);
const loading = ref(true);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
const activeTab = ref('info');
const thingModelList = ref<ThingModelApi.ThingModel[]>([]);
/** 获取设备详情 */
async function getDeviceData(deviceId: number) {
loading.value = true;
try {
device.value = await getDevice(deviceId);
await getProductData(device.value.productId);
await getThingModelList(device.value.productId);
} catch {
ElMessage.error('获取设备详情失败');
} finally {
loading.value = false;
}
}
/** 获取产品详情 */
async function getProductData(productId: number) {
try {
product.value = await getProduct(productId);
} catch {
ElMessage.error('获取产品详情失败');
}
}
/** 获取物模型列表 */
async function getThingModelList(productId: number) {
try {
const data = await getThingModelListByProductId(productId);
thingModelList.value = data || [];
} catch {
ElMessage.error('获取物模型列表失败');
thingModelList.value = [];
}
}
/** 初始化 */
onMounted(async () => {
if (!id) {
ElMessage.warning('参数错误,设备不能为空!');
router.back();
return;
}
await getDeviceData(id);
// tab
const { tab } = route.query;
if (tab) {
activeTab.value = tab as string;
}
});
</script>
<template>
<Page>
<DeviceDetailsHeader
:device="device"
:loading="loading"
:product="product"
@refresh="() => getDeviceData(id)"
/>
<ElTabs v-model="activeTab" class="mt-4">
<ElTabPane name="info" label="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
:device="device"
:product="product"
/>
</ElTabPane>
<ElTabPane name="model" label="物模型数据">
<DeviceDetailsThingModel
v-if="activeTab === 'model' && device.id"
:device-id="device.id"
:thing-model-list="thingModelList"
/>
</ElTabPane>
<ElTabPane
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
name="subDevice"
label="子设备管理"
>
<DeviceDetailsSubDevice
v-if="activeTab === 'subDevice' && device.id"
:device-id="device.id"
/>
</ElTabPane>
<ElTabPane name="log" label="设备消息">
<DeviceDetailsMessage
v-if="activeTab === 'log' && device.id"
:device-id="device.id"
/>
</ElTabPane>
<ElTabPane name="simulator" label="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
:device="device"
:product="product"
:thing-model-list="thingModelList"
/>
</ElTabPane>
<ElTabPane name="config" label="设备配置">
<DeviceDetailConfig
v-if="activeTab === 'config'"
:device="device"
@success="() => getDeviceData(id)"
/>
</ElTabPane>
<ElTabPane
v-if="
[
ProtocolTypeEnum.MODBUS_TCP_CLIENT,
ProtocolTypeEnum.MODBUS_TCP_SERVER,
].includes(product.protocolType as ProtocolTypeEnum)
"
name="modbus"
label="Modbus 配置"
>
<DeviceModbusConfig
v-if="activeTab === 'modbus'"
:device="device"
:product="product"
:thing-model-list="thingModelList"
/>
</ElTabPane>
</ElTabs>
</Page>
</template>

View File

@ -0,0 +1,202 @@
<!-- 设备配置 -->
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref, watchEffect } from 'vue';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { ElAlert, ElButton, ElInput, ElMessage } from 'element-plus';
import { sendDeviceMessage, updateDevice } from '#/api/iot/device/device';
const props = defineProps<{
device: IotDeviceApi.Device;
}>();
const emit = defineEmits<{
(e: 'success'): void; // success
}>();
const loading = ref(false); //
const pushLoading = ref(false); //
const saveLoading = ref(false); //
const config = ref<any>({}); // config
const configString = ref(''); //
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
// JSON
configString.value = JSON.stringify(config.value, null, 2);
} catch {
config.value = {};
configString.value = '{}';
}
});
const isEditing = ref(false); //
/** 格式化的配置用于只读展示 */
const formattedConfig = computed(() => {
try {
if (typeof config.value === 'string') {
return JSON.stringify(JSON.parse(config.value), null, 2);
}
return JSON.stringify(config.value, null, 2);
} catch {
return JSON.stringify(config.value, null, 2);
}
});
/** 启用编辑模式的函数 */
function handleEdit() {
isEditing.value = true;
//
configString.value = JSON.stringify(config.value, null, 2);
}
/** 取消编辑的函数 */
function handleCancelEdit() {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
configString.value = JSON.stringify(config.value, null, 2);
} catch {
config.value = {};
configString.value = '{}';
}
isEditing.value = false;
}
/** 保存配置的函数 */
async function saveConfig() {
// JSON
try {
config.value = JSON.parse(configString.value);
} catch (error) {
console.error('JSON格式错误:', error);
ElMessage.error('JSON格式错误请修正后再提交');
return;
}
saveLoading.value = true;
try {
await updateDeviceConfig();
isEditing.value = false;
} finally {
saveLoading.value = false;
}
}
/** 配置推送处理函数 */
async function handleConfigPush() {
pushLoading.value = true;
try {
//
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value,
});
//
ElMessage.success('配置推送成功!');
} finally {
pushLoading.value = false;
}
}
/** 更新设备配置 */
async function updateDeviceConfig() {
try {
//
loading.value = true;
await updateDevice({
id: props.device.id,
config: JSON.stringify(config.value),
} as IotDeviceApi.Device);
ElMessage.success('更新成功!');
// success
emit('success');
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- 使用说明提示 -->
<ElAlert
class="my-4"
description="如需编辑文件,请点击下方编辑按钮"
title="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
show-icon
:closable="false"
type="info"
/>
<!-- 代码视图 - 只读展示 -->
<div v-if="!isEditing" class="json-viewer-container">
<pre class="json-code"><code>{{ formattedConfig }}</code></pre>
</div>
<!-- 编辑器视图 - 可编辑 -->
<ElInput
v-else
v-model="configString"
type="textarea"
:rows="20"
class="json-editor"
placeholder="请输入 JSON 格式的配置信息"
/>
<!-- 操作按钮 -->
<div class="mt-5 text-center">
<ElButton v-if="isEditing" @click="handleCancelEdit"></ElButton>
<ElButton
v-if="isEditing"
:loading="saveLoading"
type="primary"
@click="saveConfig"
>
保存
</ElButton>
<ElButton v-else @click="handleEdit"></ElButton>
<ElButton
v-if="!isEditing"
:loading="pushLoading"
type="primary"
@click="handleConfigPush"
>
配置推送
</ElButton>
</div>
</div>
</template>
<style scoped>
/** todo @AI可以改成 unocss 么?如果不行,写下注释,可以的化,就把 antd、ele、vue3 + ep 都改了! **/
.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

@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import {
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElMessage,
} from 'element-plus';
import DeviceForm from '../../modules/form.vue';
interface Props {
product: IotProductApi.Product;
device: IotDeviceApi.Device;
loading?: boolean;
}
withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<{
refresh: [];
}>();
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DeviceForm,
destroyOnClose: true,
});
/** 复制到剪贴板 */
async function copyToClipboard(text: string | undefined) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
ElMessage.success('复制成功');
} catch {
ElMessage.error('复制失败');
}
}
/** 跳转到产品详情页面 */
function goToProductDetail(productId: number | undefined) {
if (productId) {
router.push({ name: 'IoTProductDetail', params: { id: productId } });
}
}
/** 打开编辑表单 */
function openEditForm(row: IotDeviceApi.Device) {
formModalApi.setData(row).open();
}
</script>
<template>
<div class="mb-4">
<FormModal @success="emit('refresh')" />
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
</div>
<div class="flex gap-2">
<ElButton
v-if="product.status === 0"
v-access:code="['iot:device:update']"
@click="openEditForm(device)"
>
编辑
</ElButton>
</div>
</div>
<ElCard class="mt-4">
<ElDescriptions :column="2">
<ElDescriptionsItem label="产品">
<a
class="cursor-pointer text-blue-600"
@click="goToProductDetail(product.id)"
>
{{ product.name }}
</a>
</ElDescriptionsItem>
<ElDescriptionsItem label="ProductKey">
{{ product.productKey }}
<ElButton
class="ml-2"
size="small"
@click="copyToClipboard(product.productKey)"
>
复制
</ElButton>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</template>

View File

@ -0,0 +1,195 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { MapDialog } from '#/components/map';
interface Props {
device: IotDeviceApi.Device;
product: IotProductApi.Product;
}
const props = defineProps<Props>();
const authDialogVisible = ref(false);
const authPasswordVisible = ref(false);
const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
{} as IotDeviceApi.DeviceAuthInfoRespVO,
);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
/** 是否有位置信息 */
const hasLocation = computed(() => {
return !!(props.device.longitude && props.device.latitude);
});
/** 打开地图弹窗 */
function openMapDialog() {
mapDialogRef.value?.open(props.device.longitude, props.device.latitude);
}
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
ElMessage.success('复制成功');
} catch {
ElMessage.error('复制失败');
}
}
/** 打开设备认证信息弹框 */
async function handleAuthInfoDialogOpen() {
if (!props.device.id) return;
try {
authInfo.value = await getDeviceAuthInfo(props.device.id);
authDialogVisible.value = true;
} catch {
ElMessage.error('获取设备认证信息失败,请检查网络连接或联系管理员');
}
}
/** 关闭设备认证信息弹框 */
function handleAuthInfoDialogClose() {
authDialogVisible.value = false;
}
</script>
<template>
<div>
<ElCard>
<template #header>设备信息</template>
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="产品名称">
{{ product.name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="ProductKey">
{{ product.productKey }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="DeviceName">
{{ device.deviceName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="备注名称">
{{ device.nickname || '--' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前状态">
<DictTag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(device.createTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">
{{ formatDateTime(device.activeTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="最后上线时间">
{{ formatDateTime(device.onlineTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="最后离线时间">
{{ formatDateTime(device.offlineTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备位置">
<template v-if="hasLocation">
<span class="mr-2">
{{ device.longitude }}, {{ device.latitude }}
</span>
<ElButton type="primary" link size="small" @click="openMapDialog">
<IconifyIcon icon="lucide:map-pin" class="mr-1" />
查看地图
</ElButton>
</template>
<span v-else class="text-gray-400">暂无位置信息</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="MQTT 连接参数">
<ElButton
size="small"
type="primary"
link
@click="handleAuthInfoDialogOpen"
>
查看
</ElButton>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<!-- 认证信息弹框 -->
<ElDialog
v-model="authDialogVisible"
title="MQTT 连接参数"
width="640px"
:show-close="true"
>
<ElForm label-width="120px">
<ElFormItem label="clientId">
<div class="flex w-full gap-1">
<ElInput v-model="authInfo.clientId" readonly class="flex-1" />
<ElButton type="primary" @click="copyToClipboard(authInfo.clientId)">
<IconifyIcon icon="lucide:copy" />
</ElButton>
</div>
</ElFormItem>
<ElFormItem label="username">
<div class="flex w-full gap-1">
<ElInput v-model="authInfo.username" readonly class="flex-1" />
<ElButton type="primary" @click="copyToClipboard(authInfo.username)">
<IconifyIcon icon="lucide:copy" />
</ElButton>
</div>
</ElFormItem>
<ElFormItem label="password">
<div class="flex w-full gap-1">
<ElInput
v-model="authInfo.password"
:type="authPasswordVisible ? 'text' : 'password'"
readonly
class="flex-1"
/>
<ElButton
type="primary"
@click="authPasswordVisible = !authPasswordVisible"
>
<IconifyIcon
:icon="authPasswordVisible ? 'lucide:eye-off' : 'lucide:eye'"
/>
</ElButton>
<ElButton type="primary" @click="copyToClipboard(authInfo.password)">
<IconifyIcon icon="lucide:copy" />
</ElButton>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleAuthInfoDialogClose"></ElButton>
</template>
</ElDialog>
<!-- 地图弹窗 -->
<MapDialog ref="mapDialogRef" />
</div>
</template>

View File

@ -0,0 +1,248 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { Page } from '@vben/common-ui';
import { DICT_TYPE, IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
ElButton,
ElOption,
ElSelect,
ElSwitch,
ElTag,
} from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePage } from '#/api/iot/device/device';
const props = defineProps<{
deviceId: number;
}>();
/** 查询参数 */
const queryParams = reactive({
method: undefined,
upstream: undefined,
});
// TODO @AI //
/** 自动刷新开关 */
const autoRefresh = ref(false);
/** 自动刷新定时器 */
let autoRefreshTimer: any = null;
/** 消息方法选项 */
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
return [
{
field: 'ts',
title: '时间',
width: 160,
slots: { default: 'ts' },
},
{
field: 'upstream',
title: '上行/下行',
width: 100,
slots: { default: 'upstream' },
},
{
field: 'reply',
title: '是否回复',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'requestId',
title: '请求编号',
width: 280,
showOverflow: 'tooltip',
},
{
field: 'method',
title: '请求方法',
width: 120,
slots: { default: 'method' },
},
{
field: 'params',
title: '请求/响应数据',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'params' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: queryParams.method,
upstream: queryParams.upstream,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索操作 */
function handleQuery() {
gridApi.query();
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
gridApi.query();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 刷新消息列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => {
gridApi.query();
}, delay);
} else {
gridApi.query();
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<ElSelect
v-model="queryParams.method"
clearable
placeholder="所有方法"
style="width: 160px"
>
<ElOption
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
<ElSelect
v-model="queryParams.upstream"
clearable
placeholder="上行/下行"
style="width: 160px"
>
<ElOption label="上行" value="true" />
<ElOption label="下行" value="false" />
</ElSelect>
<div class="flex gap-1">
<ElButton type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</ElButton>
<ElSwitch
v-model="autoRefresh"
active-text="定时刷新"
inactive-text="定时刷新"
/>
</div>
</div>
<!-- 消息列表 -->
<Grid>
<template #ts="{ row }">
{{ formatDateTime(row.ts) }}
</template>
<template #upstream="{ row }">
<ElTag :type="row.upstream ? 'primary' : 'success'">
{{ row.upstream ? '上行' : '下行' }}
</ElTag>
</template>
<template #method="{ row }">
{{ methodOptions.find((item) => item.value === row.method)?.label }}
</template>
<template #params="{ row }">
<span v-if="row.reply">
{{ `{"code":${row.code},"msg":"${row.msg}","data":${row.data}\}` }}
</span>
<span v-else>{{ row.params }}</span>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,222 @@
<!-- Modbus 连接配置弹窗 -->
<script lang="ts" setup>
import type { IotDeviceModbusConfigApi } from '#/api/iot/device/modbus/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElMessage } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import { saveModbusConfig } from '#/api/iot/device/modbus/config';
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { $t } from '#/locales';
import {
ModbusFrameFormatEnum,
ModbusModeEnum,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);
const formData = ref<IotDeviceModbusConfigApi.ModbusConfig>();
const deviceId = ref<number>(0);
const protocolType = ref<string>('');
const isClient = computed(
() => protocolType.value === ProtocolTypeEnum.MODBUS_TCP_CLIENT,
); // Client
const isServer = computed(
() => protocolType.value === ProtocolTypeEnum.MODBUS_TCP_SERVER,
); // Server
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'ip',
label: 'IP 地址',
component: 'Input',
componentProps: {
placeholder: '请输入 Modbus 服务器 IP 地址',
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client IP
},
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
},
rules: z.number().min(1).max(65_535).optional(),
defaultValue: 502,
},
{
fieldName: 'slaveId',
label: '从站地址',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入从站地址,范围 1-247',
min: 1,
max: 247,
},
rules: z.number().min(1, '请输入从站地址').max(247),
defaultValue: 1,
},
{
fieldName: 'timeout',
label: '连接超时(ms)',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入连接超时时间',
min: 1000,
step: 1000,
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
},
rules: z.number().min(1000).optional(),
defaultValue: 3000,
},
{
fieldName: 'retryInterval',
label: '重试间隔(ms)',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入重试间隔',
min: 1000,
step: 1000,
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
},
rules: z.number().min(1000).optional(),
defaultValue: 10_000,
},
{
fieldName: 'mode',
label: '工作模式',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_MODBUS_MODE, 'number'),
},
dependencies: {
triggerFields: [''],
show: () => isServer.value, // Server
},
rules: 'required',
defaultValue: ModbusModeEnum.POLLING,
},
{
fieldName: 'frameFormat',
label: '帧格式',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_MODBUS_FRAME_FORMAT, 'number'),
},
dependencies: {
triggerFields: [''],
show: () => isServer.value, // Server
},
rules: 'required',
defaultValue: ModbusFrameFormatEnum.MODBUS_TCP,
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as IotDeviceModbusConfigApi.ModbusConfig;
try {
data.deviceId = deviceId.value;
await saveModbusConfig(data);
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<{
config?: IotDeviceModbusConfigApi.ModbusConfig;
deviceId: number;
protocolType: string;
}>();
if (!data) {
return;
}
deviceId.value = data.deviceId;
protocolType.value = data.protocolType;
if (!data.config) {
return;
}
// values
formData.value = { ...data.config };
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal title="编辑 Modbus 连接配置" class="w-[600px]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,358 @@
<!-- Modbus 配置 -->
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
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 { ThingModelApi } from '#/api/iot/thingmodel';
import type { DescriptionItemSchema } from '#/components/description';
import { computed, h, onMounted, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getModbusConfig } from '#/api/iot/device/modbus/config';
import {
deleteModbusPoint,
getModbusPointPage,
} from '#/api/iot/device/modbus/point';
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { useDescription } from '#/components/description';
import { DictTag } from '#/components/dict-tag';
import { ModbusFunctionCodeOptions } from '#/views/iot/utils/constants';
import DeviceModbusConfigForm from './modbus-config-form.vue';
import DeviceModbusPointForm from './modbus-point-form.vue';
const props = defineProps<{
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelApi.ThingModel[];
}>();
// ======================= =======================
const isClient = computed(
() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_CLIENT,
); // Client
const isServer = computed(
() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_SERVER,
); // Server
const modbusConfig = ref<IotDeviceModbusConfigApi.ModbusConfig>(
{} as IotDeviceModbusConfigApi.ModbusConfig,
); //
/** 连接配置 Description Schema */
function useConfigDescriptionSchema(): DescriptionItemSchema[] {
return [
// Client
{
field: 'ip',
label: 'IP 地址',
show: () => isClient.value,
},
{
field: 'port',
label: '端口',
show: () => isClient.value,
},
//
{
field: 'slaveId',
label: '从站地址',
},
// Client
{
field: 'timeout',
label: '连接超时',
show: () => isClient.value,
render: (val) => (val ? `${val} ms` : '-'),
},
{
field: 'retryInterval',
label: '重试间隔',
show: () => isClient.value,
render: (val) => (val ? `${val} ms` : '-'),
},
// Server
{
field: 'mode',
label: '工作模式',
show: () => isServer.value,
render: (val) =>
h(DictTag, { type: DICT_TYPE.IOT_MODBUS_MODE, value: val }),
},
{
field: 'frameFormat',
label: '帧格式',
show: () => isServer.value,
render: (val) =>
h(DictTag, { type: DICT_TYPE.IOT_MODBUS_FRAME_FORMAT, value: val }),
},
//
{
field: 'status',
label: '状态',
render: (val) =>
h(DictTag, { type: DICT_TYPE.COMMON_STATUS, value: val }),
},
];
}
const [ConfigDescriptions] = useDescription({
title: '连接配置',
column: 3,
border: true,
schema: useConfigDescriptionSchema(),
});
/** 获取连接配置 */
async function loadModbusConfig() {
modbusConfig.value = await getModbusConfig(props.device.id!);
}
/** 编辑连接配置 - 使用 useVbenModal */
const [ConfigFormModal, configFormModalApi] = useVbenModal({
connectedComponent: DeviceModbusConfigForm,
destroyOnClose: true,
});
/** 打开编辑连接配置弹窗 */
function handleEditConfig() {
configFormModalApi
.setData({
config: modbusConfig.value,
deviceId: props.device.id!,
protocolType: props.product.protocolType!,
})
.open();
}
// ======================= =======================
/** 格式化功能码 */
function formatFunctionCode(code: number) {
const option = ModbusFunctionCodeOptions.find((item) => item.value === code);
return option ? option.label : `${code}`;
}
/** 格式化寄存器地址为十六进制 */
function formatRegisterAddress(address: number) {
return `0x${address.toString(16).toUpperCase().padStart(4, '0')}`;
}
/** 点位搜索表单 Schema */
function usePointFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '属性名称',
component: 'Input',
componentProps: {
placeholder: '请输入属性名称',
clearable: true,
},
},
{
fieldName: 'identifier',
label: '标识符',
component: 'Input',
componentProps: {
placeholder: '请输入标识符',
clearable: true,
},
},
];
}
/** 点位列表列配置 */
function usePointColumns(): VxeTableGridOptions<IotDeviceModbusPointApi.ModbusPoint>['columns'] {
return [
{ field: 'name', title: '属性名称', minWidth: 100 },
{
field: 'identifier',
title: '标识符',
minWidth: 100,
cellRender: { name: 'CellTag', props: { color: 'blue' } },
},
{
field: 'functionCode',
title: '功能码',
minWidth: 140,
formatter: ({ cellValue }) => formatFunctionCode(cellValue),
},
{
field: 'registerAddress',
title: '寄存器地址',
minWidth: 100,
formatter: ({ cellValue }) => formatRegisterAddress(cellValue),
},
{ field: 'registerCount', title: '寄存器数量', minWidth: 90 },
{
field: 'rawDataType',
title: '数据类型',
minWidth: 90,
cellRender: { name: 'CellTag' },
},
{ field: 'byteOrder', title: '字节序', minWidth: 80 },
{ field: 'scale', title: '缩放因子', minWidth: 80 },
{
field: 'pollInterval',
title: '轮询间隔',
minWidth: 90,
formatter: ({ cellValue }) => `${cellValue} ms`,
},
{
field: 'status',
title: '状态',
minWidth: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: usePointFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: usePointColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getModbusPointPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.device.id,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotDeviceModbusPointApi.ModbusPoint>,
});
/** 新增点位 - 使用 useVbenModal */
const [PointFormModal, pointFormModalApi] = useVbenModal({
connectedComponent: DeviceModbusPointForm,
destroyOnClose: true,
});
/** 打开新增点位弹窗 */
function handleAddPoint() {
pointFormModalApi
.setData({
deviceId: props.device.id!,
thingModelList: props.thingModelList,
})
.open();
}
/** 编辑点位 */
function handleEditPoint(row: IotDeviceModbusPointApi.ModbusPoint) {
pointFormModalApi
.setData({
id: row.id,
deviceId: props.device.id!,
thingModelList: props.thingModelList,
})
.open();
}
/** 删除点位 */
async function handleDeletePoint(row: IotDeviceModbusPointApi.ModbusPoint) {
await confirm({ content: `确定要删除点位【${row.name}】吗?` });
await deleteModbusPoint(row.id!);
ElMessage.success('删除成功');
await gridApi.query();
}
/** 刷新点位列表 */
function handlePointSuccess() {
gridApi.query();
}
/** 初始化 */
onMounted(async () => {
await loadModbusConfig();
});
</script>
<template>
<div>
<!-- 连接配置区域 -->
<ConfigDescriptions :data="modbusConfig" class="mb-4">
<template #extra>
<ElButton type="primary" @click="handleEditConfig"></ElButton>
</template>
</ConfigDescriptions>
<!-- 点位配置区域 -->
<Grid table-title="" class="h-[600px] overflow-auto">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '新增点位',
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: handleAddPoint,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'primary',
link: true,
onClick: () => handleEditPoint(row),
},
{
label: '删除',
type: 'danger',
link: true,
popConfirm: {
title: `确定要删除点位【${row.name}】吗?`,
confirm: () => handleDeletePoint(row),
},
},
]"
/>
</template>
</Grid>
<!-- 连接配置弹窗 -->
<ConfigFormModal @success="loadModbusConfig" />
<!-- 点位表单弹窗 -->
<PointFormModal @success="handlePointSuccess" />
</div>
</template>

View File

@ -0,0 +1,314 @@
<!-- Modbus 点位表单弹窗 -->
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { IotDeviceModbusPointApi } from '#/api/iot/device/modbus/point';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElMessage } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import {
createModbusPoint,
getModbusPoint,
updateModbusPoint,
} from '#/api/iot/device/modbus/point';
import { $t } from '#/locales';
import {
getByteOrderOptions,
IoTThingModelTypeEnum,
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);
const formData = ref<IotDeviceModbusPointApi.ModbusPoint>();
const getTitle = computed(() => {
return formData.value?.id ? '编辑点位' : '新增点位';
});
const deviceId = ref<number>(0);
const thingModelList = ref<ThingModelApi.ThingModel[]>([]);
/** 筛选属性类型的物模型 */
const propertyList = computed(() => {
return thingModelList.value.filter(
(item) => Number(item.type) === IoTThingModelTypeEnum.PROPERTY,
);
});
/** 表单 Schema */
function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'identifier',
dependencies: {
triggerFields: [''], // identifier
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
dependencies: {
triggerFields: [''], // name
show: () => false,
},
},
{
fieldName: 'thingModelId',
label: '物模型属性',
component: 'Select',
componentProps: {
placeholder: '请选择物模型属性',
filterable: true,
filterMethod(input: string, option: any) {
return option.label.toLowerCase().includes(input.toLowerCase());
},
},
dependencies: {
triggerFields: [''],
componentProps: () => ({
options: propertyList.value.map((item) => ({
value: item.id,
label: `${item.name} (${item.identifier})`,
})),
}),
},
rules: 'required',
},
{
fieldName: 'functionCode',
label: '功能码',
component: 'Select',
componentProps: {
placeholder: '请选择功能码',
options: ModbusFunctionCodeOptions.map((item) => ({
value: item.value,
label: item.label,
})),
},
rules: 'required',
},
{
fieldName: 'registerAddress',
label: '寄存器地址',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入寄存器地址',
min: 0,
max: 65_535,
},
rules: 'required',
suffix: () => {
const addr = formApi.form.values?.registerAddress;
if (addr === null || addr === undefined) {
return '';
}
return h(
'span',
{ class: 'text-gray-400' },
`0x${Number(addr).toString(16).toUpperCase().padStart(4, '0')}`,
);
},
},
{
fieldName: 'registerCount',
label: '寄存器数量',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入寄存器数量',
min: 1,
max: 125,
},
rules: 'required',
},
{
fieldName: 'rawDataType',
label: '原始数据类型',
component: 'Select',
componentProps: {
placeholder: '请选择数据类型',
options: ModbusRawDataTypeOptions.map((item) => ({
value: item.value,
label: `${item.label} - ${item.description}`,
})),
},
rules: 'required',
},
{
fieldName: 'byteOrder',
label: '字节序',
component: 'Select',
componentProps: {
placeholder: '请选择字节序',
},
dependencies: {
triggerFields: ['rawDataType'],
componentProps: (values) => ({
options: values.rawDataType
? getByteOrderOptions(values.rawDataType).map((item) => ({
value: item.value,
label: `${item.label} - ${item.description}`,
}))
: [],
}),
},
rules: 'required',
},
{
fieldName: 'scale',
label: '缩放因子',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入缩放因子',
precision: 6,
step: 0.1,
},
defaultValue: 1,
},
{
fieldName: 'pollInterval',
label: '轮询间隔(ms)',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入轮询间隔',
min: 100,
step: 1000,
},
rules: z.number().min(100, '请输入轮询间隔'),
defaultValue: 5000,
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
handleValuesChange: async (values, changedFields) => {
// identifier name
if (changedFields.includes('thingModelId')) {
const thingModelId = values.thingModelId;
const thingModel = thingModelList.value.find(
(item) => item.id === thingModelId,
);
if (thingModel) {
await formApi.setFieldValue('identifier', thingModel.identifier);
await formApi.setFieldValue('name', thingModel.name);
}
}
//
if (changedFields.includes('rawDataType')) {
const rawDataType = values.rawDataType;
if (rawDataType) {
//
const option = ModbusRawDataTypeOptions.find(
(item) => item.value === rawDataType,
);
if (option && option.registerCount > 0) {
await formApi.setFieldValue('registerCount', option.registerCount);
}
//
const byteOrderOptions = getByteOrderOptions(rawDataType);
if (byteOrderOptions.length > 0) {
await formApi.setFieldValue('byteOrder', byteOrderOptions[0]!.value);
}
}
}
},
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as IotDeviceModbusPointApi.ModbusPoint;
try {
data.deviceId = deviceId.value;
await (formData.value?.id
? updateModbusPoint(data)
: createModbusPoint(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<{
deviceId: number;
id?: number;
thingModelList: ThingModelApi.ThingModel[];
}>();
if (!data) {
return;
}
deviceId.value = data.deviceId;
thingModelList.value = data.thingModelList || [];
if (!data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getModbusPoint(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[600px]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,566 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElCard,
ElCol,
ElInput,
ElMessage,
ElRow,
ElTable,
ElTableColumn,
ElTabPane,
ElTabs,
} from 'element-plus';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
DeviceStateEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DataDefinition from '../../../../thingmodel/modules/components/data-definition.vue';
import DeviceDetailsMessage from './message.vue';
const props = defineProps<{
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelApi.ThingModel[];
}>();
//
const activeTab = ref('upstream'); // upstreamdownstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); //
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); //
const deviceMessageRef = ref(); //
const deviceMessageRefreshDelay = 2000; // N
//
const debugCollapsed = ref(false); //
const messageCollapsed = ref(false); //
//
const formData = ref<Record<string, string>>({});
//
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter(
(item) => String(item.type) === String(type),
);
};
//
const propertyList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY),
);
//
const eventList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.EVENT),
);
//
const serviceList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE),
);
//
function getFormValue(identifier: string) {
return formData.value[identifier] || '';
}
//
function setFormValue(identifier: string, value: string) {
formData.value[identifier] = value;
}
//
async function handlePropertyPost() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
ElMessage.warning('请至少输入一个属性值');
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
ElMessage.success('属性上报成功');
//
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
ElMessage.error('属性上报失败');
console.error(error);
}
}
//
async function handleEventPost(row: ThingModelApi.ThingModel) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
ElMessage.error('事件参数格式错误请输入有效的JSON格式');
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
ElMessage.success('事件上报成功');
//
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
ElMessage.error('事件上报失败');
console.error(error);
}
}
//
async function handleDeviceState(state: number) {
try {
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
ElMessage.success('状态变更成功');
//
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
ElMessage.error('状态变更失败');
console.error(error);
}
}
//
async function handlePropertySet() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
ElMessage.warning('请至少输入一个属性值');
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
ElMessage.success('属性设置成功');
//
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
ElMessage.error('属性设置失败');
console.error(error);
}
}
//
async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
ElMessage.error('服务参数格式错误请输入有效的JSON格式');
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
ElMessage.success('服务调用成功');
//
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
ElMessage.error('服务调用失败');
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<ElRow :gutter="16">
<!-- 左侧指令调试区域 -->
<ElCol :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<ElCard class="simulator-tabs h-full">
<template #header>
<div class="flex items-center justify-between">
<span>指令调试</span>
<ElButton
size="small"
text
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon v-if="!debugCollapsed" icon="lucide:chevron-up" />
<IconifyIcon v-if="debugCollapsed" icon="lucide:chevron-down" />
</ElButton>
</div>
</template>
<div v-show="!debugCollapsed">
<ElTabs v-model="activeTab" size="small">
<!-- 上行指令调试 -->
<ElTabPane name="upstream" label="上行指令调试">
<ElTabs
v-if="activeTab === 'upstream'"
v-model="upstreamTab"
size="small"
>
<!-- 属性上报 -->
<ElTabPane
:name="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
label="属性上报"
>
<ContentWrap>
<ElTable
:data="propertyList"
:max-height="300"
size="small"
>
<ElTableColumn
label="功能名称"
prop="name"
width="100"
fixed="left"
/>
<ElTableColumn
label="标识符"
prop="identifier"
width="120"
fixed="left"
/>
<ElTableColumn label="数据类型" width="90">
<template #default="{ row }">
{{ row.property?.dataType ?? '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="数据定义" min-width="150">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</ElTableColumn>
<ElTableColumn label="值" width="180" fixed="right">
<template #default="{ row }">
<ElInput
:model-value="getFormValue(row.identifier)"
placeholder="输入值"
size="small"
@update:model-value="
setFormValue(row.identifier, $event)
"
/>
</template>
</ElTableColumn>
</ElTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<ElButton type="primary" @click="handlePropertyPost">
发送属性上报
</ElButton>
</div>
</ContentWrap>
</ElTabPane>
<!-- 事件上报 -->
<ElTabPane
:name="IotDeviceMessageMethodEnum.EVENT_POST.method"
label="事件上报"
>
<ContentWrap>
<ElTable
:data="eventList"
:max-height="300"
size="small"
>
<ElTableColumn
label="功能名称"
prop="name"
width="100"
fixed="left"
/>
<ElTableColumn
label="标识符"
prop="identifier"
width="120"
fixed="left"
/>
<ElTableColumn label="数据类型" width="90">
<template #default="{ row }">
{{ row.event?.dataType ?? '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="数据定义" min-width="150">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</ElTableColumn>
<ElTableColumn label="值" width="180">
<template #default="{ row }">
<ElInput
type="textarea"
:rows="3"
:model-value="getFormValue(row.identifier)"
placeholder="输入事件参数JSON格式"
size="small"
@update:model-value="
setFormValue(row.identifier, $event)
"
/>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="{ row }">
<ElButton
size="small"
type="primary"
@click="handleEventPost(row)"
>
上报事件
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</ContentWrap>
</ElTabPane>
<!-- 状态变更 -->
<ElTabPane
:name="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
label="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<ElButton
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</ElButton>
<ElButton
type="danger"
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</ElButton>
</div>
</ContentWrap>
</ElTabPane>
</ElTabs>
</ElTabPane>
<!-- 下行指令调试 -->
<ElTabPane name="downstream" label="下行指令调试">
<ElTabs
v-if="activeTab === 'downstream'"
v-model="downstreamTab"
size="small"
>
<!-- 属性调试 -->
<ElTabPane
:name="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
label="属性设置"
>
<ContentWrap>
<ElTable
:data="propertyList"
:max-height="300"
size="small"
>
<ElTableColumn
label="功能名称"
prop="name"
width="100"
fixed="left"
/>
<ElTableColumn
label="标识符"
prop="identifier"
width="120"
fixed="left"
/>
<ElTableColumn label="数据类型" width="90">
<template #default="{ row }">
{{ row.property?.dataType ?? '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="数据定义" min-width="150">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</ElTableColumn>
<ElTableColumn label="值" width="180" fixed="right">
<template #default="{ row }">
<ElInput
:model-value="getFormValue(row.identifier)"
placeholder="输入值"
size="small"
@update:model-value="
setFormValue(row.identifier, $event)
"
/>
</template>
</ElTableColumn>
</ElTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<ElButton type="primary" @click="handlePropertySet">
发送属性设置
</ElButton>
</div>
</ContentWrap>
</ElTabPane>
<!-- 服务调用 -->
<ElTabPane
:name="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
label="设备服务调用"
>
<ContentWrap>
<ElTable
:data="serviceList"
:max-height="300"
size="small"
>
<ElTableColumn
label="服务名称"
prop="name"
width="100"
fixed="left"
/>
<ElTableColumn
label="标识符"
prop="identifier"
width="120"
fixed="left"
/>
<ElTableColumn label="输入参数" min-width="150">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</ElTableColumn>
<ElTableColumn label="参数值" width="180">
<template #default="{ row }">
<ElInput
type="textarea"
:rows="3"
:model-value="getFormValue(row.identifier)"
placeholder="输入服务参数JSON格式"
size="small"
@update:model-value="
setFormValue(row.identifier, $event)
"
/>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="{ row }">
<ElButton
size="small"
type="primary"
@click="handleServiceInvoke(row)"
>
服务调用
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</ContentWrap>
</ElTabPane>
</ElTabs>
</ElTabPane>
</ElTabs>
</div>
</ElCard>
</ElCol>
<!-- 右侧设备消息区域 -->
<ElCol :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<ElCard class="h-full">
<template #header>
<div class="flex items-center justify-between">
<span>设备消息</span>
<ElButton
size="small"
text
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon
v-if="!messageCollapsed"
icon="lucide:chevron-up"
/>
<IconifyIcon
v-if="messageCollapsed"
icon="lucide:chevron-down"
/>
</ElButton>
</div>
</template>
<div v-show="!messageCollapsed">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</div>
</ElCard>
</ElCol>
</ElRow>
</ContentWrap>
</template>

View File

@ -0,0 +1,355 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { formatDateTime, isEmpty } from '@vben/utils';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
bindDeviceGateway,
getSubDeviceList,
getUnboundSubDevicePage,
unbindDeviceGateway,
} from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
interface Props {
deviceId: number;
}
const props = defineProps<Props>();
const router = useRouter();
/** 子设备列表表格列配置 */
function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
},
{
field: 'nickname',
title: '备注名称',
minWidth: 120,
},
{
field: 'productName',
title: '产品名称',
minWidth: 120,
},
{
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
{
field: 'onlineTime',
title: '最后上线时间',
minWidth: 160,
formatter: ({ cellValue }) => formatDateTime(cellValue),
},
{
field: 'actions',
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
if (!props.deviceId) {
return [];
}
return await getSubDeviceList(props.deviceId);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<IotDeviceApi.Device>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 获取子设备列表 */
function getList() {
gridApi.query();
}
/** 打开设备详情 */
function openDeviceDetail(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id } });
}
/** 多选框选中数据 */
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 解绑单个设备 */
async function handleUnbind(row: IotDeviceApi.Device) {
await confirm({ content: `确定要解绑子设备【${row.deviceName}】吗?` });
const loadingInstance = ElLoading.service({
text: `正在解绑【${row.deviceName}】...`,
});
try {
await unbindDeviceGateway(props.deviceId, [row.id!]);
ElMessage.success('解绑成功');
getList();
} finally {
loadingInstance.close();
}
}
/** 批量解绑 */
async function handleUnbindBatch() {
await confirm({
content: `确定要解绑选中的 ${checkedIds.value.length} 个子设备吗?`,
});
const loadingInstance = ElLoading.service({
text: '正在批量解绑...',
});
try {
await unbindDeviceGateway(props.deviceId, checkedIds.value);
checkedIds.value = [];
ElMessage.success('批量解绑成功');
getList();
} finally {
loadingInstance.close();
}
}
// ===================== =====================
const addSelectedRowKeys = ref<number[]>([]);
/** 添加弹窗搜索表单 schema */
function useAddGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleProductList(DeviceTypeEnum.GATEWAY_SUB),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
clearable: true,
},
},
{
fieldName: 'deviceName',
label: 'DeviceName',
component: 'Input',
componentProps: {
placeholder: '请输入 DeviceName',
clearable: true,
},
},
];
}
function useAddGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
},
{
field: 'nickname',
title: '备注名称',
minWidth: 120,
},
{
field: 'productName',
title: '产品名称',
minWidth: 120,
},
{
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
];
}
const [AddGrid, addGridApi] = useVbenVxeGrid({
formOptions: {
schema: useAddGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useAddGridColumns(),
height: 400,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getUnboundSubDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotDeviceApi.Device>,
gridEvents: {
checkboxAll: handleAddSelectionChange,
checkboxChange: handleAddSelectionChange,
},
});
/** 处理添加弹窗表格选择变化 */
function handleAddSelectionChange() {
const records = addGridApi.grid?.getCheckboxRecords() || [];
addSelectedRowKeys.value = records.map(
(record: IotDeviceApi.Device) => record.id!,
);
}
const [AddModal, addModalApi] = useVbenModal({
async onConfirm() {
if (addSelectedRowKeys.value.length === 0) {
ElMessage.warning('请先选择要添加的子设备');
return;
}
addModalApi.lock();
try {
await bindDeviceGateway(props.deviceId, addSelectedRowKeys.value);
ElMessage.success('绑定成功');
await addModalApi.close();
addSelectedRowKeys.value = [];
getList();
} finally {
addModalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (isOpen) {
addSelectedRowKeys.value = [];
await addGridApi.formApi?.resetForm();
await addGridApi.query();
}
},
});
/** 打开添加子设备弹窗 */
function openAddModal() {
addModalApi.open();
}
/** 监听 deviceId 变化 */
watch(
() => props.deviceId,
(newVal) => {
if (newVal) {
getList();
}
},
{ immediate: true },
);
</script>
<template>
<Page auto-content-height>
<!-- 子设备列表 -->
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '添加子设备',
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: openAddModal,
},
{
label: '批量解绑',
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
onClick: handleUnbindBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看',
type: 'primary',
link: true,
onClick: () => openDeviceDetail(row.id!),
},
{
label: '解绑',
type: 'danger',
link: true,
onClick: () => handleUnbind(row),
},
]"
/>
</template>
</Grid>
<!-- 添加子设备弹窗 -->
<AddModal title="添加子设备" class="w-3/5">
<AddGrid />
</AddModal>
</Page>
</template>

View File

@ -0,0 +1,260 @@
<!-- 设备事件管理 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
ElButton,
ElDatePicker,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelApi.ThingModel[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelApi.ThingModel) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
return [
{
field: 'reportTime',
title: '上报时间',
width: 180,
slots: { default: 'reportTime' },
},
{
field: 'identifier',
title: '标识符',
width: 160,
slots: { default: 'identifier' },
},
{
field: 'eventName',
title: '事件名称',
width: 160,
slots: { default: 'eventName' },
},
{
field: 'eventType',
title: '事件类型',
width: 100,
slots: { default: 'eventType' },
},
{
field: 'params',
title: '输入参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'params' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePairPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
identifier: queryParams.identifier || undefined,
times: queryParams.times,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索按钮操作 */
function handleQuery() {
gridApi.query();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取事件名称 */
function getEventName(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
}
/** 刷新列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => gridApi.query(), delay);
} else {
gridApi.query();
}
}
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span>标识符</span>
<ElSelect
v-model="queryParams.identifier"
clearable
placeholder="请选择事件标识符"
style="width: 240px"
>
<ElOption
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
:label="`${event.name}(${event.identifier})`"
/>
</ElSelect>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<ElDatePicker
v-model="queryParams.times"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
/>
</div>
<div class="flex gap-1">
<ElButton type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-1" />
搜索
</ElButton>
<ElButton @click="resetQuery">
<IconifyIcon icon="ep:refresh" class="mr-1" />
重置
</ElButton>
</div>
</div>
<!-- 事件列表 -->
<Grid>
<template #reportTime="{ row }">
{{
row.request?.reportTime ? formatDateTime(row.request.reportTime) : '-'
}}
</template>
<template #identifier="{ row }">
<ElTag type="primary" size="small">
{{ row.request?.identifier }}
</ElTag>
</template>
<template #eventName="{ row }">
{{ getEventName(row.request?.identifier) }}
</template>
<template #eventType="{ row }">
{{ getEventType(row.request?.identifier) }}
</template>
<template #params="{ row }">
{{ parseParams(row.request?.params) }}
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,512 @@
<!-- 设备物模型 -> 运行状态 -> 查看数据设备的属性值历史-->
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatDate, formatDateTime } from '@vben/utils';
import dayjs from 'dayjs';
import {
ElButton,
ElButtonGroup,
ElDialog,
ElEmpty,
ElMessage,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
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 total = ref(0); //
const thingModelDataType = ref<string>(''); //
const propertyIdentifier = ref<string>(''); //
const dateRange = ref<[string, string]>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]); //
const queryParams = reactive({
deviceId: -1,
identifier: '',
times: formatDateRangeWithTime(dateRange.value),
});
// Echarts
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 不支持图表展示的数据类型列表 */
const CHART_DISABLED_DATA_TYPES = [
IoTDataSpecsDataTypeEnum.ARRAY, //
IoTDataSpecsDataTypeEnum.STRUCT, //
IoTDataSpecsDataTypeEnum.TEXT, //
IoTDataSpecsDataTypeEnum.BOOL, //
IoTDataSpecsDataTypeEnum.ENUM, //
IoTDataSpecsDataTypeEnum.DATE, //
] as const;
/** 判断是否支持图表展示仅数值类型支持int、float、double */
const canShowChart = computed(() => {
if (!thingModelDataType.value) return false;
return !CHART_DISABLED_DATA_TYPES.includes(
thingModelDataType.value as (typeof CHART_DISABLED_DATA_TYPES)[number],
);
});
/** 判断是否为复杂数据类型(用于格式化显示) */
const isComplexDataType = computed(() => {
if (!thingModelDataType.value) return false;
return (
thingModelDataType.value === IoTDataSpecsDataTypeEnum.ARRAY ||
thingModelDataType.value === IoTDataSpecsDataTypeEnum.STRUCT
);
});
/** 最大值统计 */
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));
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
});
/** 最小值统计 */
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));
return values.length > 0 ? Math.min(...values).toFixed(2) : '-';
});
/** 平均值统计 */
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));
if (values.length === 0) return '-';
const sum = values.reduce((acc, val) => acc + val, 0);
return (sum / values.length).toFixed(2);
});
/** 将日期范围转换为带时分秒的格式 */
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
}
/** 获得设备历史数据 */
async function getList() {
loading.value = true;
try {
//
const data = await getHistoryDevicePropertyList(queryParams);
list.value = (data || []) as IotDeviceApi.DevicePropertyDetail[];
total.value = list.value.length;
//
if (
viewMode.value === 'chart' &&
canShowChart.value &&
list.value.length > 0
) {
await renderChartWhenReady();
}
} catch {
ElMessage.error('获取数据失败');
list.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
/** 确保图表容器已经可见后再渲染 */
async function renderChartWhenReady() {
if (!list.value || list.value.length === 0) {
return;
}
// ModalCard loading v-show DOM
await nextTick();
await nextTick();
renderChart();
}
/** 渲染图表 */
function renderChart() {
if (!list.value || list.value.length === 0) {
return;
}
const times = list.value.map((item) =>
formatDate(new Date(item.updateTime), 'YYYY-MM-DD HH:mm:ss'),
);
const values = list.value.map((item) => Number(item.value));
renderEcharts({
title: {
text: '属性值趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
},
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
xAxis: {
type: 'category',
boundaryGap: false,
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
data: times,
},
yAxis: {
type: 'value',
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0],
},
},
series: [
{
name: '属性值',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF',
},
itemStyle: {
color: '#1890FF',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)',
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)',
},
],
},
},
data: values,
},
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
type: 'slider',
height: 30,
bottom: 20,
},
],
});
}
/** 打开弹窗 */
async function open(deviceId: number, identifier: string, dataType: string) {
dialogVisible.value = true;
queryParams.deviceId = deviceId;
queryParams.identifier = identifier;
propertyIdentifier.value = identifier;
thingModelDataType.value = dataType;
// 7
dateRange.value = [
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
];
//
queryParams.times = formatDateRangeWithTime(dateRange.value);
// 使
viewMode.value = canShowChart.value ? 'chart' : 'list';
//
await nextTick();
await nextTick(); // nextTick Modal
await getList();
}
/** 处理时间范围变化 */
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
if (!times || times.length !== 2) {
return;
}
dateRange.value = [
dayjs(times[0]).format('YYYY-MM-DD'),
dayjs(times[1]).format('YYYY-MM-DD'),
];
// 00:00:00 23:59:59
queryParams.times = formatDateRangeWithTime(dateRange.value);
getList();
}
/** 刷新数据 */
function handleRefresh() {
getList();
}
/** 导出数据 */
async function handleExport() {
if (list.value.length === 0) {
ElMessage.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);
ElMessage.success('导出成功');
} catch {
ElMessage.error('导出失败');
} finally {
exporting.value = false;
}
}
/** 关闭弹窗 */
function handleClose() {
dialogVisible.value = false;
list.value = [];
total.value = 0;
}
/** 格式化复杂数据类型 */
function formatComplexValue(value: any) {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (newMode === 'chart' && canShowChart.value && list.value.length > 0) {
await renderChartWhenReady();
}
});
defineExpose({ open }); // open
</script>
<template>
<ElDialog
v-model="dialogVisible"
title="查看数据"
width="1200px"
@close="handleClose"
>
<div class="property-history-container">
<!-- 工具栏 -->
<div class="toolbar-wrapper mb-4">
<div class="flex w-full flex-wrap items-center gap-3">
<!-- 时间选择 -->
<div class="flex items-center gap-3">
<span class="whitespace-nowrap text-sm text-gray-500">
时间范围
</span>
<ShortcutDateRangePicker @change="handleDateRangeChange" />
</div>
<!-- 刷新按钮 -->
<ElButton :loading="loading" @click="handleRefresh">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
刷新
</ElButton>
<!-- 导出按钮 -->
<ElButton
:disabled="list.length === 0"
:loading="exporting"
@click="handleExport"
>
<IconifyIcon icon="ant-design:export-outlined" class="mr-1" />
导出
</ElButton>
<!-- 视图切换 -->
<ElButtonGroup class="ml-auto">
<ElButton
:disabled="!canShowChart"
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
>
<IconifyIcon icon="ant-design:line-chart-outlined" class="mr-1" />
图表
</ElButton>
<ElButton
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
>
<IconifyIcon icon="ant-design:table-outlined" class="mr-1" />
列表
</ElButton>
</ElButtonGroup>
</div>
<!-- 数据统计信息 -->
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
<div class="flex flex-wrap gap-4">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && canShowChart">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值:
{{ avgValue }}
</span>
</div>
</div>
</div>
<!-- 数据展示区域 -->
<div v-loading="loading">
<!-- 图表模式 - 使用 v-show 确保图表组件始终挂载 -->
<div v-show="viewMode === 'chart'" class="chart-container">
<ElEmpty
v-if="list.length === 0"
class="py-20"
:description="$t('common.noData')"
/>
<div v-show="list.length > 0">
<EchartsUI ref="chartRef" height="500px" />
</div>
</div>
<!-- 表格模式 -->
<div v-show="viewMode === 'list'" class="table-container">
<ElTable
:data="list"
:max-height="500"
row-key="updateTime"
size="small"
>
<ElTableColumn label="序号" width="80" align="center" type="index" />
<ElTableColumn
label="时间"
prop="updateTime"
width="200"
align="center"
>
<template #default="{ row }">
{{ formatDate(new Date(row.updateTime)) }}
</template>
</ElTableColumn>
<ElTableColumn label="属性值" prop="value" align="center">
<template #default="{ row }">
<ElTag v-if="isComplexDataType" type="primary">
{{ formatComplexValue(row.value) }}
</ElTag>
<span v-else class="font-medium">{{ row.value }}</span>
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
</div>
<template #footer>
<ElButton @click="handleClose"></ElButton>
</template>
</ElDialog>
</template>
<style lang="scss" scoped>
/** 同别的地方,将 style 改成 unocss 的诉求。如果不好改,就注释说明; */
.property-history-container {
max-height: 70vh;
overflow: auto;
.toolbar-wrapper {
padding: 16px;
background-color: hsl(var(--card) / 90%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 8px;
}
.chart-container,
.table-container {
padding: 16px;
background-color: hsl(var(--card) / 100%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 8px;
}
}
</style>

View File

@ -0,0 +1,421 @@
<!-- 设备属性管理 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import {
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
ElButton,
ElButtonGroup,
ElCard,
ElCol,
ElDivider,
ElInput,
ElRow,
ElSwitch,
ElTag,
} from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getLatestDeviceProperties } from '#/api/iot/device/device';
import DeviceDetailsThingModelPropertyHistory from './thing-model-property-history.vue';
const props = defineProps<{ deviceId: number }>();
const loading = ref(true); //
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const filterList = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const queryParams = reactive({
keyword: '' as string,
}); //
const autoRefresh = ref(false); //
let autoRefreshTimer: any = null; //
const viewMode = ref<'card' | 'list'>('card'); //
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions<IotDeviceApi.DevicePropertyDetail>['columns'] {
return [
{
field: 'identifier',
title: '属性标识符',
},
{
field: 'name',
title: '属性名称',
},
{
field: 'dataType',
title: '数据类型',
},
{
field: 'value',
title: '属性值',
slots: { default: 'value' },
},
{
field: 'updateTime',
title: '更新时间',
width: 180,
slots: { default: 'updateTime' },
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
rowConfig: {
keyField: 'identifier',
isHover: true,
},
proxyConfig: {
ajax: {
query: async () => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
const data = await getLatestDeviceProperties({
deviceId: props.deviceId,
identifier: undefined,
name: undefined,
});
//
let filteredData = data;
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
filteredData = data.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
}
//
filterList.value = data;
list.value = filteredData;
return {
list: filteredData,
total: filteredData.length,
};
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<IotDeviceApi.DevicePropertyDetail>,
});
// gridApi.query()
gridApi.query = async () => {
if (viewMode.value === 'list') {
// Grid
if (!props.deviceId) {
return;
}
const data = await getLatestDeviceProperties({
deviceId: props.deviceId,
identifier: undefined,
name: undefined,
});
const dataArray = Array.isArray(data) ? data : [];
let filteredData = dataArray;
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
filteredData = dataArray.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
}
filterList.value = dataArray;
list.value = filteredData;
// Grid
if (gridApi.grid) {
gridApi.grid.loadData(filteredData);
}
} else {
// getList
await getList();
}
};
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (viewMode.value === 'list') {
await gridApi.query();
} else {
//
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await getLatestDeviceProperties(params);
handleFilter();
}
} finally {
loading.value = false;
}
}
/** 前端筛选数据 */
function handleFilter() {
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
list.value = filterList.value.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
} else {
list.value = filterList.value;
}
}
/** 搜索按钮操作 */
function handleQuery() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
handleFilter();
}
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return;
}
viewMode.value = mode;
await nextTick();
gridApi.query();
}
/** 历史操作 */
const historyRef = ref();
function openHistory(deviceId: number, identifier: string, dataType: string) {
historyRef.value.open(deviceId, identifier, dataType);
}
/** 格式化属性值和单位 */
function formatValueWithUnit(item: IotDeviceApi.DevicePropertyDetail) {
if (item.value === null || item.value === undefined || item.value === '') {
return '-';
}
const unitName = item.dataSpecs?.unitName;
return unitName ? `${item.value} ${unitName}` : item.value;
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
gridApi.query();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
gridApi.query();
}
},
);
/** 初始化 */
onMounted(async () => {
if (props.deviceId) {
await nextTick();
gridApi.query();
}
});
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索工作栏 -->
<div class="flex items-center justify-between" style="margin-bottom: 16px">
<div class="flex items-center" style="gap: 16px">
<ElInput
v-model="queryParams.keyword"
clearable
placeholder="请输入属性名称、标识符"
style="width: 240px"
@keyup.enter="handleQuery"
/>
<ElSwitch
v-model="autoRefresh"
active-text="定时刷新"
class="ml-20px"
inactive-text="定时刷新"
/>
</div>
<ElButtonGroup>
<ElButton
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ep:grid" />
</ElButton>
<ElButton
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ep:list" />
</ElButton>
</ElButtonGroup>
</div>
<!-- 分隔线 -->
<ElDivider style="margin: 16px 0" />
<!-- 卡片视图 -->
<template v-if="viewMode === 'card'">
<ElRow v-loading="loading" :gutter="16">
<ElCol
v-for="item in list"
:key="item.identifier"
:lg="6"
:md="12"
:sm="12"
:xs="24"
class="mb-4"
>
<ElCard
class="relative h-full overflow-hidden transition-colors"
body-class="!p-0"
>
<!-- 添加渐变背景层 -->
<div
class="pointer-events-none absolute left-0 right-0 top-0 h-12 bg-gradient-to-b from-muted to-transparent"
></div>
<div class="relative p-4">
<!-- 标题区域 -->
<div class="mb-3 flex items-center">
<div class="mr-2.5 flex items-center">
<IconifyIcon class="text-lg text-primary" icon="ep:cpu" />
</div>
<div class="flex-1 text-base font-bold">{{ item.name }}</div>
<!-- 标识符 -->
<div class="mr-2 inline-flex items-center">
<ElTag type="primary" size="small">
{{ item.identifier }}
</ElTag>
</div>
<!-- 数据类型标签 -->
<div class="mr-2 inline-flex items-center">
<ElTag size="small">
{{ item.dataType }}
</ElTag>
</div>
<!-- 数据图标 - 可点击 -->
<div
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-50"
@click="
openHistory(props.deviceId, item.identifier, item.dataType)
"
>
<IconifyIcon
class="text-lg text-primary"
icon="ep:data-line"
/>
</div>
</div>
<!-- 信息区域 -->
<div class="text-sm">
<div class="mb-2.5 last:mb-0">
<span class="mr-2.5 text-muted-foreground">属性值</span>
<span class="font-bold text-foreground">
{{ formatValueWithUnit(item) }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="mr-2.5 text-muted-foreground">更新时间</span>
<span class="text-sm text-foreground">
{{
item.updateTime ? formatDateTime(item.updateTime) : '-'
}}
</span>
</div>
</div>
</div>
</ElCard>
</ElCol>
</ElRow>
</template>
<!-- 列表视图 -->
<Grid v-show="viewMode === 'list'">
<template #value="{ row }">
{{ formatValueWithUnit(row) }}
</template>
<template #updateTime="{ row }">
{{ row.updateTime ? formatDateTime(row.updateTime) : '-' }}
</template>
<template #actions="{ row }">
<ElButton
type="primary"
link
@click="openHistory(props.deviceId, row.identifier, row.dataType)"
>
查看数据
</ElButton>
</template>
</Grid>
<!-- 表单弹窗添加/修改 -->
<DeviceDetailsThingModelPropertyHistory
ref="historyRef"
:device-id="props.deviceId"
/>
</Page>
</template>
<style scoped>
/* 移除 row 的额外边距 */
:deep(.el-row) {
margin-right: -8px !important;
margin-left: -8px !important;
}
</style>

View File

@ -0,0 +1,285 @@
<!-- 设备服务调用 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
ElButton,
ElDatePicker,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelApi.ThingModel[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelApi.ThingModel) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions<Record<string, any>>['columns'] {
return [
{
field: 'requestTime',
title: '调用时间',
width: 180,
slots: { default: 'requestTime' },
},
{
field: 'responseTime',
title: '响应时间',
width: 180,
slots: { default: 'responseTime' },
},
{
field: 'identifier',
title: '标识符',
width: 160,
slots: { default: 'identifier' },
},
{
field: 'serviceName',
title: '服务名称',
width: 160,
slots: { default: 'serviceName' },
},
{
field: 'callType',
title: '调用方式',
width: 100,
slots: { default: 'callType' },
},
{
field: 'inputParams',
title: '输入参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'inputParams' },
},
{
field: 'outputParams',
title: '输出参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'outputParams' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePairPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
identifier: queryParams.identifier || undefined,
times: queryParams.times,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索按钮操作 */
function handleQuery() {
gridApi.query();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取服务名称 */
function getServiceName(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
);
return service?.name || identifier;
}
/** 获取调用方式 */
function getCallType(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelApi.ThingModel) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
if (!params) return '-';
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2);
}
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
}
/** 刷新列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => gridApi.query(), delay);
} else {
gridApi.query();
}
}
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span>标识符</span>
<ElSelect
v-model="queryParams.identifier"
clearable
placeholder="请选择服务标识符"
style="width: 240px"
>
<ElOption
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
:label="`${service.name}(${service.identifier})`"
/>
</ElSelect>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<ElDatePicker
v-model="queryParams.times"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
/>
</div>
<div class="flex gap-1">
<ElButton type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-1" />
搜索
</ElButton>
<ElButton @click="resetQuery">
<IconifyIcon icon="ep:refresh" class="mr-1" />
重置
</ElButton>
</div>
</div>
<!-- 服务调用列表 -->
<Grid>
<template #requestTime="{ row }">
{{
row.request?.reportTime ? formatDateTime(row.request.reportTime) : '-'
}}
</template>
<template #responseTime="{ row }">
{{ row.reply?.reportTime ? formatDateTime(row.reply.reportTime) : '-' }}
</template>
<template #identifier="{ row }">
<ElTag type="primary" size="small">
{{ row.request?.identifier }}
</ElTag>
</template>
<template #serviceName="{ row }">
{{ getServiceName(row.request?.identifier) }}
</template>
<template #callType="{ row }">
{{ getCallType(row.request?.identifier) }}
</template>
<template #inputParams="{ row }">
{{ parseParams(row.request?.params) }}
</template>
<template #outputParams="{ row }">
<span v-if="row.reply">
{{
`{"code":${row.reply.code},"msg":"${row.reply.msg}","data":${row.reply.data}\}`
}}
</span>
<span v-else>-</span>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,47 @@
<!-- 设备物模型设备属性事件管理服务调用 -->
<script lang="ts" setup>
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { ElTabPane, ElTabs } from 'element-plus';
import DeviceDetailsThingModelEvent from './thing-model-event.vue';
import DeviceDetailsThingModelProperty from './thing-model-property.vue';
import DeviceDetailsThingModelService from './thing-model-service.vue';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelApi.ThingModel[];
}>();
const activeTab = ref('property'); //
</script>
<template>
<ContentWrap>
<ElTabs v-model="activeTab" class="!h-auto !p-0">
<ElTabPane name="property" label="设备属性(运行状态)">
<DeviceDetailsThingModelProperty
v-if="activeTab === 'property'"
:device-id="deviceId"
/>
</ElTabPane>
<ElTabPane name="event" label="设备事件上报">
<DeviceDetailsThingModelEvent
v-if="activeTab === 'event'"
:device-id="props.deviceId"
:thing-model-list="props.thingModelList"
/>
</ElTabPane>
<ElTabPane name="service" label="设备服务调用">
<DeviceDetailsThingModelService
v-if="activeTab === 'service'"
:device-id="deviceId"
:thing-model-list="props.thingModelList"
/>
</ElTabPane>
</ElTabs>
</ContentWrap>
</template>

View File

@ -0,0 +1,517 @@
<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';
import { nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { 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 {
ElButton,
ElCard,
ElInput,
ElLoading,
ElMessage,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDevice,
deleteDeviceList,
exportDeviceExcel,
getDevicePage,
} from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import DeviceCardView from './modules/card-view.vue';
import DeviceForm from './modules/form.vue';
import DeviceGroupForm from './modules/group-form.vue';
import DeviceImportForm from './modules/import-form.vue';
const route = useRoute();
const router = useRouter();
const products = ref<IotProductApi.Product[]>([]);
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]);
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
const checkedIds = ref<number[]>([]);
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
connectedComponent: DeviceForm,
destroyOnClose: true,
});
const [DeviceGroupFormModal, deviceGroupFormModalApi] = useVbenModal({
connectedComponent: DeviceGroupForm,
destroyOnClose: true,
});
const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
connectedComponent: DeviceImportForm,
destroyOnClose: true,
});
const queryParams = ref<Partial<PageParam>>({
deviceName: '',
nickname: '',
productId: undefined,
deviceType: undefined,
status: undefined,
groupId: undefined,
}); //
/** 搜索 */
function handleSearch() {
if (viewMode.value === 'list') {
gridApi.formApi.setValues(queryParams.value);
}
gridApi.query();
}
/** 重置搜索 */
function handleReset() {
queryParams.value.deviceName = '';
queryParams.value.nickname = '';
queryParams.value.productId = undefined;
queryParams.value.deviceType = undefined;
queryParams.value.status = undefined;
queryParams.value.groupId = undefined;
handleSearch();
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return; //
}
viewMode.value = mode;
//
await nextTick();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDeviceExcel({
...queryParams.value,
pageNo: 1,
pageSize: 999_999,
} as PageParam);
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
}
/** 打开设备详情 */
function openDetail(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id } });
}
/** 跳转到产品详情页面 */
function openProductDetail(productId: number) {
router.push({ name: 'IoTProductDetail', params: { id: productId } });
}
/** 打开物模型数据 */
function openModel(id: number) {
router.push({
name: 'IoTDeviceDetail',
params: { id },
query: { tab: 'model' },
});
}
/** 新增设备 */
function handleCreate() {
deviceFormModalApi.setData(null).open();
}
/** 编辑设备 */
function handleEdit(row: IotDeviceApi.Device) {
deviceFormModalApi.setData(row).open();
}
/** 删除设备 */
async function handleDelete(row: IotDeviceApi.Device) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.deviceName]),
});
try {
await deleteDevice(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.deviceName]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 批量删除设备 */
async function handleDeleteBatch() {
if (checkedIds.value.length === 0) {
ElMessage.warning('请选择要删除的设备');
return;
}
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deletingBatch'),
});
try {
await deleteDeviceList(checkedIds.value);
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
checkedIds.value = [];
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 添加到分组 */
function handleAddToGroup() {
if (checkedIds.value.length === 0) {
ElMessage.warning('请选择要添加到分组的设备');
return;
}
deviceGroupFormModalApi.setData(checkedIds.value).open();
}
/** 设备导入 */
function handleImport() {
deviceImportFormModalApi.open();
}
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
checkboxConfig: {
highlight: true,
reserve: true,
},
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
return await getDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams.value,
} as PageParam);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotDeviceApi.Device>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') {
return await originalQuery(params);
} else {
// query
cardViewRef.value?.query();
}
};
/** 初始化 */
onMounted(async () => {
//
products.value = await getSimpleProductList();
//
deviceGroups.value = await getSimpleDeviceGroupList();
// productId
const { productId } = route.query;
if (productId) {
queryParams.value.productId = Number(productId);
//
handleSearch();
}
});
</script>
<template>
<Page auto-content-height>
<DeviceFormModal @success="handleRefresh" />
<DeviceGroupFormModal @success="handleRefresh" />
<DeviceImportFormModal @success="handleRefresh" />
<!-- 统一搜索工具栏 -->
<ElCard class="!mb-2">
<!-- 搜索表单 -->
<div class="mb-3 flex flex-wrap items-center gap-3">
<ElSelect
v-model="queryParams.productId"
placeholder="请选择产品"
clearable
style="width: 200px"
>
<ElOption
v-for="product in products"
:key="product.id!"
:value="product.id!"
:label="product.name"
/>
</ElSelect>
<ElInput
v-model="queryParams.deviceName"
placeholder="请输入 DeviceName"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
<ElInput
v-model="queryParams.nickname"
placeholder="请输入备注名称"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
<ElSelect
v-model="queryParams.deviceType"
placeholder="请选择设备类型"
clearable
style="width: 200px"
>
<ElOption
v-for="dict in getDictOptions(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
'number',
)"
:key="String(dict.value)"
:value="dict.value as number"
:label="dict.label"
/>
</ElSelect>
<ElSelect
v-model="queryParams.status"
placeholder="请选择设备状态"
clearable
style="width: 200px"
>
<ElOption
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
:key="String(dict.value)"
:value="dict.value as number"
:label="dict.label"
/>
</ElSelect>
<ElSelect
v-model="queryParams.groupId"
placeholder="请选择设备分组"
clearable
style="width: 200px"
>
<ElOption
v-for="group in deviceGroups"
:key="group.id!"
:value="group.id!"
:label="group.name"
/>
</ElSelect>
<ElButton type="primary" @click="handleSearch">
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
{{ $t('common.search') }}
</ElButton>
<ElButton @click="handleReset">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
{{ $t('common.reset') }}
</ElButton>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['设备']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['iot:device:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.import'),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['iot:device:import'],
onClick: handleImport,
},
{
label: '添加到分组',
type: 'primary',
icon: 'ant-design:folder-add-outlined',
auth: ['iot:device:update'],
disabled: isEmpty(checkedIds),
onClick: handleAddToGroup,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['iot:device:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
<!-- 视图切换 -->
<div class="flex gap-1">
<ElButton
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" />
</ElButton>
<ElButton
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ant-design:unordered-list-outlined" />
</ElButton>
</div>
</div>
</ElCard>
<!-- 列表视图 -->
<Grid table-title="" v-show="viewMode === 'list'">
<template #product="{ row }">
<a
class="cursor-pointer text-primary"
@click="openProductDetail(row.productId)"
>
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
</a>
</template>
<template #groups="{ row }">
<template v-if="row.groupIds?.length">
<ElTag
v-for="groupId in row.groupIds"
:key="groupId"
size="small"
class="mr-1"
>
{{ deviceGroups.find((g) => g.id === groupId)?.name }}
</ElTag>
</template>
<span v-else>-</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
link: true,
auth: ['iot:device:query'],
onClick: openDetail.bind(null, row.id!),
},
{
label: '日志',
type: 'primary',
link: true,
auth: ['iot:device:message-query'],
onClick: openModel.bind(null, row.id!),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:device:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:device:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.deviceName]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<!-- 卡片视图 -->
<DeviceCardView
v-show="viewMode === 'card'"
ref="cardViewRef"
:products="products"
:device-groups="deviceGroups"
:search-params="{
deviceName: queryParams.deviceName || '',
nickname: queryParams.nickname || '',
productId: queryParams.productId,
deviceType: queryParams.deviceType,
status: queryParams.status,
groupId: queryParams.groupId,
}"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@detail="openDetail"
@model="openModel"
@product-detail="openProductDetail"
/>
</Page>
</template>
<style scoped>
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;
}
</style>

View File

@ -0,0 +1,437 @@
<script lang="ts" setup>
import type { PageParam } from '@vben/request';
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';
import {
ElButton,
ElCard,
ElCol,
ElEmpty,
ElImage,
ElPagination,
ElPopconfirm,
ElRow,
ElTooltip,
} from 'element-plus';
import { getDevicePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
interface Props {
products: any[];
deviceGroups: any[];
searchParams?: {
deviceName: string;
deviceType?: number;
groupId?: number;
nickname: string;
productId?: number;
status?: number;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [id: number];
edit: [row: any];
model: [id: number];
productDetail: [productId: number];
}>();
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<IotDeviceApi.Device[]>([]);
const total = ref(0);
const queryParams = ref<Partial<PageParam>>({
pageNo: 1,
pageSize: 12,
});
/** 获取产品名称 */
function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId);
return product?.name || '-';
}
/** 获取设备列表 */
async function getList() {
loading.value = true;
try {
const data = await getDevicePage({
...queryParams.value,
...props.searchParams,
} as PageParam);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 处理页码变化 */
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
}
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
getList();
},
query: () => {
queryParams.value.pageNo = 1;
getList();
},
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<div class="device-card-view">
<!-- 设备卡片列表 -->
<div v-loading="loading" class="min-h-96">
<ElRow v-if="list.length > 0" :gutter="16">
<ElCol
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="12"
:lg="6"
class="mb-4"
>
<ElCard
class="device-card h-full 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">
<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>
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="item.state"
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>
<a
class="info-value cursor-pointer text-primary"
@click="
(e) => {
e.stopPropagation();
emit('productDetail', item.productId);
}
"
>
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="info-label">设备类型</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
/>
</div>
<div class="info-item">
<span class="info-label">Deviceid</span>
<ElTooltip :content="String(item.id)" placement="top">
<span class="info-value device-id cursor-pointer">
{{ item.id }}
</span>
</ElTooltip>
</div>
</div>
<!-- 设备图片 -->
<div class="device-image">
<ElImage
v-if="item.picUrl"
:src="item.picUrl"
:preview-src-list="[item.picUrl]"
class="size-full rounded object-cover"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<ElButton
v-if="hasAccessByCodes(['iot:device:update'])"
size="small"
class="action-btn action-btn-edit"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
编辑
</ElButton>
<ElButton
v-if="hasAccessByCodes(['iot:device:query'])"
size="small"
class="action-btn action-btn-detail"
@click="emit('detail', item.id!)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
详情
</ElButton>
<ElButton
v-if="hasAccessByCodes(['iot:device:message-query'])"
size="small"
class="action-btn action-btn-data"
@click="emit('model', item.id!)"
>
<IconifyIcon icon="lucide:database" class="mr-1" />
数据
</ElButton>
<ElPopconfirm
v-if="hasAccessByCodes(['iot:device:delete'])"
:title="`确认删除设备 ${item.deviceName} 吗?`"
@confirm="emit('delete', item)"
>
<template #reference>
<ElButton
size="small"
type="danger"
class="action-btn action-btn-delete !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
</template>
</ElPopconfirm>
</div>
</ElCard>
</ElCol>
</ElRow>
<!-- 空状态 -->
<ElEmpty v-else description="暂无设备数据" class="my-20" />
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="mt-3 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(page: number) => handlePageChange(page, queryParams.pageSize!)"
@size-change="(size: number) => handlePageChange(queryParams.pageNo!, size)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.device-card-view {
.device-card {
overflow: hidden;
:deep(.el-card__body) {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
//
.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(--el-border-color);
.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

@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElMessage,
} from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { MapDialog } from '#/components/map';
import { $t } from '#/locales';
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<IotDeviceApi.Device>();
const products = ref<IotProductApi.Product[]>([]);
const activeKey = ref<string[]>([]);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['设备'])
: $t('ui.actionTitle.create', ['设备']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useBasicFormSchema(),
showDefaultActions: false,
handleValuesChange: async (values, changedFields) => {
// ProductId
if (changedFields.includes('productId')) {
const productId = values.productId;
if (!productId) {
await formApi.setFieldValue('deviceType', undefined);
return;
}
//
const product = products.value.find((p) => p.id === productId);
if (product?.deviceType !== undefined) {
await formApi.setFieldValue('deviceType', product.deviceType);
}
}
},
});
const [AdvancedForm, advancedFormApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useAdvancedFormSchema(),
showDefaultActions: false,
});
/** 获取高级表单的值(如果表单未挂载,则从 formData 中获取) */
async function getAdvancedFormValues() {
if (advancedFormApi.isMounted) {
return await advancedFormApi.getValues();
}
// formData
return {
nickname: formData.value?.nickname,
picUrl: formData.value?.picUrl,
groupIds: formData.value?.groupIds,
serialNumber: formData.value?.serialNumber,
longitude: formData.value?.longitude,
latitude: formData.value?.latitude,
};
}
/** 打开地图选择弹窗 */
async function openMapDialog() {
// Collapse
if (!advancedFormApi.isMounted) {
activeKey.value = ['advanced'];
await nextTick();
await nextTick();
}
const values = await advancedFormApi.getValues();
mapDialogRef.value?.open(
values.longitude ? Number(values.longitude) : undefined,
values.latitude ? Number(values.latitude) : undefined,
);
}
/** 处理地图选择确认 */
async function handleMapConfirm(data: {
address: string;
latitude: string;
longitude: string;
}) {
if (advancedFormApi.isMounted) {
await advancedFormApi.setFieldValue('longitude', Number(data.longitude));
await advancedFormApi.setFieldValue('latitude', Number(data.latitude));
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const basicValues = await formApi.getValues();
const advancedValues = await getAdvancedFormValues();
const data = {
...basicValues,
...advancedValues,
} as IotDeviceApi.Device;
try {
await (formData.value?.id ? updateDevice(data) : createDevice(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
activeKey.value = [];
return;
}
//
const data = modalApi.getData<IotDeviceApi.Device>();
if (!data || !data.id) {
return;
}
//
modalApi.lock();
try {
formData.value = await getDevice(data.id);
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
/** 监听 Collapse 展开,自动设置高级表单的值 */
watch(
activeKey,
async (newKeys) => {
// Collapse
if (newKeys.includes('advanced') && formData.value) {
//
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
},
{ immediate: false },
);
/** 初始化产品列表 */
onMounted(async () => {
products.value = await getSimpleProductList();
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<div class="mx-4">
<Form />
<ElCollapse v-model="activeKey" class="mt-4">
<ElCollapseItem name="advanced" title="更多设置">
<AdvancedForm />
<div class="mt-2 flex gap-1">
<ElButton type="primary" @click="openMapDialog"></ElButton>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</Modal>
<!-- 地图选择弹窗 -->
<MapDialog ref="mapDialogRef" @confirm="handleMapConfirm" />
</template>

View File

@ -0,0 +1,73 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { updateDeviceGroup } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useGroupFormSchema } from '../data';
const emit = defineEmits(['success']);
const deviceIds = ref<number[]>([]);
const getTitle = computed(() => '添加设备到分组');
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useGroupFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = await formApi.getValues();
try {
await updateDeviceGroup({
ids: deviceIds.value,
groupIds: data.groupIds as number[],
} as IotDeviceApi.DeviceUpdateGroupReqVO);
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
deviceIds.value = [];
return;
}
//
const ids = modalApi.getData<number[]>();
if (ids) {
deviceIds.value = ids;
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ElButton, ElMessage, ElUpload } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { importDevice, importDeviceTemplate } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useImportFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = await formApi.getValues();
try {
const result = await importDevice(data.file, data.updateSupport);
//
const importData = result as IotDeviceApi.DeviceImportRespVO;
if (importData) {
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
if (importData.createDeviceNames?.length) {
for (const deviceName of importData.createDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新成功数量:${importData.updateDeviceNames?.length || 0};`;
if (importData.updateDeviceNames?.length) {
for (const deviceName of importData.updateDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新失败数量:${Object.keys(importData.failureDeviceNames || {}).length};`;
if (importData.failureDeviceNames) {
for (const deviceName in importData.failureDeviceNames) {
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
}
}
ElMessage.info(text);
}
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 上传前 */
function beforeUpload(file: File) {
formApi.setFieldValue('file', file);
return false;
}
/** 下载模版 */
async function handleDownload() {
const data = await importDeviceTemplate();
downloadFileFromBlobPart({ fileName: '设备导入模板.xls', source: data });
}
</script>
<template>
<Modal :title="$t('ui.actionTitle.import', ['设备'])" class="w-1/3">
<Form class="mx-4">
<template #file>
<div class="w-full">
<ElUpload
:before-upload="beforeUpload"
:limit="1"
accept=".xls,.xlsx"
:show-file-list="false"
>
<ElButton type="primary">选择 Excel 文件</ElButton>
</ElUpload>
</div>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<ElButton @click="handleDownload"></ElButton>
</div>
</template>
</Modal>
</template>

View File

@ -0,0 +1,124 @@
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 { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '分组名称',
component: 'Input',
componentProps: {
placeholder: '请输入分组名称',
},
rules: z
.string()
.min(1, '分组名称不能为空')
.max(64, '分组名称长度不能超过 64 个字符'),
},
{
fieldName: 'status',
label: '分组状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'description',
label: '分组描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入分组描述',
rows: 3,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '分组名称',
component: 'Input',
componentProps: {
placeholder: '请输入分组名称',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<IotDeviceGroupApi.DeviceGroup>['columns'] {
return [
{
field: 'id',
title: 'ID',
minWidth: 100,
},
{
field: 'name',
title: '分组名称',
minWidth: 200,
},
{
field: 'status',
title: '状态',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'description',
title: '分组描述',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'deviceCount',
title: '设备数量',
minWidth: 100,
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDeviceGroup, getDeviceGroupPage } from '#/api/iot/device/group';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建设备分组 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑设备分组 */
function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
formModalApi.setData(row).open();
}
/** 删除设备分组 */
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteDeviceGroup(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDeviceGroupPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotDeviceGroupApi.DeviceGroup>,
});
</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:device-group:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:device-group:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:device-group:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createDeviceGroup,
getDeviceGroup,
updateDeviceGroup,
} from '#/api/iot/device/group';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<IotDeviceGroupApi.DeviceGroup>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['设备分组'])
: $t('ui.actionTitle.create', ['设备分组']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as IotDeviceGroupApi.DeviceGroup;
try {
await (formData.value?.id
? updateDeviceGroup(data)
: createDeviceGroup(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<IotDeviceGroupApi.DeviceGroup>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDeviceGroup(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,196 @@
/** 消息趋势图表配置 */
export function getMessageTrendChartOptions(
times: string[],
upstreamData: number[],
downstreamData: number[],
): any {
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['上行消息', '下行消息'],
top: '5%',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true,
},
xAxis: [
{
type: 'category',
boundaryGap: false,
data: times,
},
],
yAxis: [
{
type: 'value',
name: '消息数量',
},
],
series: [
{
name: '上行消息',
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3,
},
emphasis: {
focus: 'series',
},
data: upstreamData,
itemStyle: {
color: '#1890ff',
},
},
{
name: '下行消息',
type: 'line',
smooth: true,
areaStyle: {
opacity: 0.3,
},
emphasis: {
focus: 'series',
},
data: downstreamData,
itemStyle: {
color: '#52c41a',
},
},
],
};
}
/**
*
*/
export function getDeviceStateGaugeChartOptions(
value: number,
max: number,
color: string,
title: string,
): any {
return {
series: [
{
type: 'gauge',
startAngle: 225,
endAngle: -45,
min: 0,
max,
center: ['50%', '50%'],
radius: '80%',
progress: {
show: true,
width: 12,
itemStyle: {
color,
},
},
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']] as [number, string][],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
title: {
show: true,
offsetCenter: [0, '80%'],
fontSize: 14,
color: '#666',
},
detail: {
valueAnimation: true,
fontSize: 32,
fontWeight: 'bold',
color,
offsetCenter: [0, '10%'],
formatter: (val: number) => `${val}`,
},
data: [{ value, name: title }],
},
],
};
}
/**
*
*/
export function getDeviceCountPieChartOptions(
data: Array<{ name: string; value: number }>,
): any {
return {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 个 ({d}%)',
},
legend: {
type: 'scroll',
orient: 'horizontal',
bottom: '10px',
left: 'center',
icon: 'circle',
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
textStyle: {
fontSize: 12,
},
pageButtonPosition: 'end',
pageIconSize: 12,
pageTextStyle: {
fontSize: 12,
},
pageFormatter: '{current}/{total}',
},
series: [
{
name: '设备数量',
type: 'pie',
radius: ['35%', '55%'],
center: ['50%', '40%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
labelLine: {
show: false,
},
data,
},
],
};
}

View File

@ -0,0 +1,20 @@
import type { IotStatisticsApi } from '#/api/iot/statistics';
/** 统计数据 */
export type StatsData = IotStatisticsApi.StatisticsSummaryRespVO;
/** 默认统计数据;用 -1 作为「未加载」哨兵,避免与「真 0 设备」混淆 */
export const defaultStatsData: StatsData = {
productCategoryCount: -1,
productCount: -1,
deviceCount: -1,
deviceMessageCount: -1,
productCategoryTodayCount: 0,
productTodayCount: 0,
deviceTodayCount: 0,
deviceMessageTodayCount: 0,
deviceOnlineCount: 0,
deviceOfflineCount: 0,
deviceInactiveCount: 0,
productCategoryDeviceCounts: {},
};

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { StatsData } from './data';
import { onMounted, ref } from 'vue';
import { ComparisonCard, Page } from '@vben/common-ui';
import { ElCol, ElRow } from 'element-plus';
import { getStatisticsSummary } from '#/api/iot/statistics';
import { defaultStatsData } from './data';
import DeviceCountCard from './modules/device-count-card.vue';
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';
const loading = ref(true);
const statsData = ref<StatsData>(defaultStatsData);
/** 加载数据 */
async function loadData() {
loading.value = true;
try {
statsData.value = await getStatisticsSummary();
} finally {
loading.value = false;
}
}
// TODO @AIantd /** */
/** 初始化 */
onMounted(() => {
loadData();
});
</script>
<template>
<Page>
<!-- 第一行统计卡片 -->
<ElRow :gutter="16" class="mb-4">
<ElCol :span="6">
<ComparisonCard
title="分类数量"
:value="statsData.productCategoryCount"
:today-count="statsData.productCategoryTodayCount"
icon="menu"
icon-color="text-blue-500"
:loading="loading"
/>
</ElCol>
<ElCol :span="6">
<ComparisonCard
title="产品数量"
:value="statsData.productCount"
:today-count="statsData.productTodayCount"
icon="box"
icon-color="text-orange-500"
:loading="loading"
/>
</ElCol>
<ElCol :span="6">
<ComparisonCard
title="设备数量"
:value="statsData.deviceCount"
:today-count="statsData.deviceTodayCount"
icon="cpu"
icon-color="text-purple-500"
:loading="loading"
/>
</ElCol>
<ElCol :span="6">
<ComparisonCard
title="设备消息数"
:value="statsData.deviceMessageCount"
:today-count="statsData.deviceMessageTodayCount"
icon="message"
icon-color="text-teal-500"
:loading="loading"
/>
</ElCol>
</ElRow>
<!-- 第二行图表 -->
<ElRow :gutter="16" class="mb-4">
<ElCol :span="12">
<DeviceCountCard :stats-data="statsData" :loading="loading" />
</ElCol>
<ElCol :span="12">
<DeviceStateCountCard :stats-data="statsData" :loading="loading" />
</ElCol>
</ElRow>
<!-- 第三行消息统计 -->
<ElRow :gutter="16" class="mb-4">
<ElCol :span="24">
<MessageTrendCard />
</ElCol>
</ElRow>
<!-- 第四行设备分布地图 -->
<ElRow :gutter="16">
<ElCol :span="24">
<DeviceMapCard />
</ElCol>
</ElRow>
</Page>
</template>

View File

@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard, ElEmpty } from 'element-plus';
import { getDeviceCountPieChartOptions } from '../chart-options';
defineOptions({ name: 'DeviceCountCard' });
const props = defineProps<{
loading?: boolean;
statsData: IotStatisticsApi.StatisticsSummaryRespVO;
}>();
const deviceCountChartRef = ref();
const { renderEcharts } = useEcharts(deviceCountChartRef);
/** 是否有数据 */
const hasData = computed(() => {
// TODO @AI使 return
if (!props.statsData) return false;
const categories = Object.entries(
props.statsData.productCategoryDeviceCounts || {},
);
return categories.length > 0 && props.statsData.deviceCount !== -1;
});
/** 初始化图表 */
async function initChart() {
if (!hasData.value) {
return;
}
await nextTick();
const data = Object.entries(props.statsData.productCategoryDeviceCounts).map(
([name, value]) => ({ name, value }),
);
await renderEcharts(getDeviceCountPieChartOptions(data));
}
/** 监听数据变化 */
watch(
() => props.statsData,
() => {
initChart();
},
{ deep: true },
);
/** 组件挂载时初始化图表 */
onMounted(() => {
initChart();
});
</script>
<template>
<ElCard header="设备数量统计" v-loading="loading" class="h-full">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="加载中..." />
</div>
<div
v-else-if="!hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="deviceCountChartRef" class="h-[400px] w-full" />
</div>
</ElCard>
</template>

View File

@ -0,0 +1,220 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { ElCard, ElEmpty } from 'element-plus';
import { getDeviceLocationList } from '#/api/iot/device/device';
import { loadBaiduMapSdk } from '#/components/map';
import { DeviceStateEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceMapCard' });
const router = useRouter();
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 stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // -
[DeviceStateEnum.ONLINE]: '#22C55E', // 线 - 绿
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 线 -
};
/** 获取设备状态配置;名称走字典,颜色用本地映射 */
function getStateConfig(state: number): { color: string; name: string } {
return {
name: getDictLabel(DICT_TYPE.IOT_DEVICE_STATE, state) || '未知',
color: stateColorMap[state] || '#909399',
};
}
/** 创建自定义标记点图标 */
function createMarkerIcon(color: string, isOnline: boolean) {
const size = isOnline ? 24 : 20;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="${color}" stroke="white" stroke-width="2"/>
${isOnline ? `<circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : ''}
</svg>
`;
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
return new window.BMapGL.Icon(url, new window.BMapGL.Size(size, size), {
anchor: new window.BMapGL.Size(size / 2, size / 2),
});
}
/** 初始化地图 */
function initMap() {
if (!mapContainerRef.value || !window.BMapGL) {
return;
}
//
if (mapInstance) {
mapInstance.destroy?.();
mapInstance = null;
}
//
mapInstance = new window.BMapGL.Map(mapContainerRef.value);
mapInstance.centerAndZoom(new window.BMapGL.Point(106, 37.5), 5);
mapInstance.enableScrollWheelZoom();
//
mapInstance.addControl(new window.BMapGL.ScaleControl());
mapInstance.addControl(new window.BMapGL.ZoomControl());
//
deviceList.value.forEach((device) => {
const config = getStateConfig(device.state!);
const isOnline = device.state === DeviceStateEnum.ONLINE;
const point = new window.BMapGL.Point(device.longitude, device.latitude);
//
const marker = new window.BMapGL.Marker(point, {
icon: createMarkerIcon(config.color, isOnline),
});
//
const infoContent = `
<div style="padding: 8px; min-width: 180px;">
<div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">${device.nickname || device.deviceName}</div>
<div style="color: #666; font-size: 12px; line-height: 1.8;">
<div>产品: ${device.productName || '-'}</div>
<div>状态: <span style="color: ${config.color}; font-weight: 500;">${config.name}</span></div>
</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #eee;">
<a href="javascript:void(0)" class="device-link" data-id="${device.id}" style="color: #1890ff; font-size: 12px; text-decoration: none;">点击查看详情 </a>
</div>
</div>
`;
//
marker.addEventListener('click', () => {
const infoWindow = new window.BMapGL.InfoWindow(infoContent, {
width: 220,
height: 140,
title: '',
});
//
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 },
});
}
});
}
}, 100);
});
mapInstance.openInfoWindow(infoWindow, point);
});
mapInstance.addOverlay(marker);
});
}
/** 加载设备数据 */
async function loadDeviceData() {
loading.value = true;
try {
deviceList.value = await getDeviceLocationList();
} finally {
loading.value = false;
}
}
/** 初始化 */
async function init() {
await loadDeviceData();
if (!hasData.value) {
return;
}
await loadBaiduMapSdk();
initMap();
}
/** 组件挂载时初始化 */
onMounted(() => {
init();
});
/** 组件卸载时销毁地图实例 */
onUnmounted(() => {
if (mapInstance) {
mapInstance.destroy?.();
mapInstance = null;
}
});
</script>
<template>
<ElCard class="h-full">
<template #header>
<div class="flex items-center justify-between">
<span>设备分布地图</span>
<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
class="inline-block h-3 w-3 rounded-full"
:style="{
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
}"
></span>
<span class="text-gray-500">待激活</span>
</span>
</div>
</div>
</template>
<div
v-if="loading"
v-loading="loading"
class="flex h-[500px] items-center justify-center"
></div>
<ElEmpty
v-else-if="!hasData"
class="h-[500px]"
description="暂无设备位置数据"
/>
<div
v-show="hasData && !loading"
ref="mapContainerRef"
class="h-[500px] w-full"
></div>
</ElCard>
</template>

View File

@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard, ElCol, ElEmpty, ElRow } from 'element-plus';
import { getDeviceStateGaugeChartOptions } from '../chart-options';
defineOptions({ name: 'DeviceStateCountCard' });
const props = defineProps<{
loading?: boolean;
statsData: IotStatisticsApi.StatisticsSummaryRespVO;
}>();
const deviceOnlineChartRef = ref();
const deviceOfflineChartRef = ref();
const deviceInactiveChartRef = ref();
const { renderEcharts: renderOnlineChart } = useEcharts(deviceOnlineChartRef);
const { renderEcharts: renderOfflineChart } = useEcharts(deviceOfflineChartRef);
const { renderEcharts: renderInactiveChart } = useEcharts(
deviceInactiveChartRef,
);
/** 是否有数据 */
const hasData = computed(() => {
if (!props.statsData) return false;
return props.statsData.deviceCount !== -1;
});
/** 初始化图表 */
async function initCharts() {
if (!hasData.value) {
return;
}
await nextTick();
const max = props.statsData.deviceCount || 100;
// 线
await renderOnlineChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceOnlineCount,
max,
'#52c41a',
'在线设备',
),
);
// 线
await renderOfflineChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceOfflineCount,
max,
'#ff4d4f',
'离线设备',
),
);
//
await renderInactiveChart(
getDeviceStateGaugeChartOptions(
props.statsData.deviceInactiveCount,
max,
'#1890ff',
'待激活设备',
),
);
}
/** 监听数据变化 */
watch(
() => props.statsData,
() => {
initCharts();
},
{ deep: true },
);
/** 组件挂载时初始化图表 */
onMounted(() => {
initCharts();
});
</script>
<template>
<ElCard header="设备状态统计" v-loading="loading" class="h-full">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="加载中..." />
</div>
<div
v-else-if="!hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="暂无数据" />
</div>
<ElRow v-else class="h-[280px]">
<ElCol :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOnlineChartRef" class="h-[250px] w-full" />
</ElCol>
<ElCol :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOfflineChartRef" class="h-[250px] w-full" />
</ElCol>
<ElCol :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceInactiveChartRef" class="h-[250px] w-full" />
</ElCol>
</ElRow>
</ElCard>
</template>

View File

@ -0,0 +1,177 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import dayjs from 'dayjs';
import { ElCard, ElEmpty, ElSelect } from 'element-plus';
import { getDeviceMessageSummaryByDate } from '#/api/iot/statistics';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { getMessageTrendChartOptions } from '../chart-options';
defineOptions({ name: 'MessageTrendCard' });
const messageChartRef = ref();
const { renderEcharts } = useEcharts(messageChartRef);
const loading = ref(false);
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>(
[],
);
/** 时间范围(仅日期,不包含时分秒) */
const dateRange = ref<[string, string]>([
//
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]);
/** 将日期范围转换为带时分秒的格式 */
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
}
/** 查询参数 */
const queryParams = reactive<IotStatisticsApi.DeviceMessageReqVO>({
interval: 1, //
times: formatDateRangeWithTime(dateRange.value),
});
/** 是否有数据 */
const hasData = computed(() => {
return messageData.value && messageData.value.length > 0;
});
/** 时间间隔字典选项 */
const intervalOptions = computed(() =>
getDictOptions(DICT_TYPE.DATE_INTERVAL, 'number').map((item) => ({
label: item.label,
value: item.value as number,
})),
);
/** 处理查询操作 */
function handleQuery() {
fetchMessageData();
}
/** 处理时间范围变化 */
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
if (!times || times.length !== 2) {
return;
}
dateRange.value = [
dayjs(times[0]).format('YYYY-MM-DD'),
dayjs(times[1]).format('YYYY-MM-DD'),
];
// 00:00:00 23:59:59
queryParams.times = formatDateRangeWithTime(dateRange.value);
handleQuery();
}
/** 处理时间间隔变化 */
function handleIntervalChange() {
handleQuery();
}
/** 获取消息统计数据 */
async function fetchMessageData() {
if (!queryParams.times || queryParams.times.length !== 2) {
return;
}
loading.value = true;
try {
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
} finally {
loading.value = false;
await renderChartWhenReady();
}
}
/** 初始化图表 */
function initChart() {
//
if (!hasData.value) {
return;
}
const times = messageData.value.map((item) => item.time);
const upstreamData = messageData.value.map((item) => item.upstreamCount);
const downstreamData = messageData.value.map((item) => item.downstreamCount);
renderEcharts(
getMessageTrendChartOptions(times, upstreamData, downstreamData),
);
}
/** 确保图表容器已经可见后再渲染 */
async function renderChartWhenReady() {
if (!hasData.value) {
return;
}
// Card loading v-show DOM
await nextTick();
await nextTick();
initChart();
}
/** 组件挂载时查询数据 */
onMounted(() => {
fetchMessageData();
});
</script>
<template>
<ElCard class="h-full">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-4">
<span class="text-base font-medium text-gray-600">消息量统计</span>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-3">
<span class="whitespace-nowrap text-sm text-gray-500">
时间范围
</span>
<ShortcutDateRangePicker @change="handleDateRangeChange" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">时间间隔</span>
<ElSelect
v-model="queryParams.interval"
:options="intervalOptions"
placeholder="间隔类型"
:style="{ width: '80px' }"
@change="handleIntervalChange"
/>
</div>
</div>
</div>
</template>
<!-- 加载中状态 -->
<div
v-show="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="加载中..." />
</div>
<!-- 无数据状态 -->
<div
v-show="!loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<ElEmpty description="暂无数据" />
</div>
<!-- 图表容器 - 使用 v-show 而非 v-if确保组件始终挂载 -->
<div v-show="hasData">
<EchartsUI ref="messageChartRef" class="h-[300px] w-full" />
</div>
</ElCard>
</template>

View File

@ -0,0 +1,181 @@
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));
/** 固件详情的描述字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{ field: 'name', label: '固件名称' },
{ field: 'productName', label: '所属产品' },
{ field: 'version', label: '固件版本' },
{
field: 'createTime',
label: '创建时间',
render: (val) => (val ? (formatDateTime(val) as string) : '-'),
},
{ field: 'description', label: '固件描述', span: 2 },
];
}
/** 新增/修改固件的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
},
rules: 'required',
},
{
fieldName: 'productId',
label: '所属产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
},
rules: 'required',
},
{
fieldName: 'version',
label: '版本号',
component: 'Input',
componentProps: {
placeholder: '请输入版本号',
},
rules: 'required',
},
{
fieldName: 'description',
label: '固件描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入固件描述',
rows: 3,
},
},
{
fieldName: 'fileUrl',
label: '固件文件',
component: 'FileUpload',
componentProps: {
maxNumber: 1,
accept: ['bin', 'hex', 'zip'],
maxSize: 50,
helpText: '支持上传 .bin、.hex、.zip 格式的固件文件,最大 50MB',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
clearable: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '固件编号',
minWidth: 80,
},
{
field: 'name',
title: '固件名称',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 120,
},
{
field: 'description',
title: '固件描述',
minWidth: 200,
},
{
field: 'productId',
title: '所属产品',
minWidth: 150,
formatter: ({ cellValue }) =>
productList.find((p) => p.id === cellValue)?.name || '-',
},
{
field: 'fileUrl',
title: '固件文件',
minWidth: 120,
slots: { default: 'fileUrl' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

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,27 @@
<script lang="ts" setup>
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { ElCard } from 'element-plus';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../../data';
/** IoT OTA 固件基本信息 */ // TODO @AI
defineProps<{
firmware?: IoTOtaFirmwareApi.Firmware;
loading?: boolean;
}>();
const [Description] = useDescription({
border: true,
column: 3,
schema: useDetailSchema(),
});
</script>
<template>
<ElCard v-loading="loading" header="固件信息">
<Description :data="firmware" />
</ElCard>
</template>

View File

@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { ElLoading, ElMessage } from 'element-plus';
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/form.vue';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: OtaFirmwareForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建固件 */
function handleCreate() {
formModalApi.setData({ type: 'create' }).open();
}
/** 编辑固件 */
function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'update', id: row.id }).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteOtaFirmware(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看固件详情 */
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
push({ name: 'IoTOtaFirmwareDetail', params: { id: row.id } });
}
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="">
<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 #fileUrl="{ row }">
<div
v-if="row.fileUrl"
class="inline-flex items-center gap-1.5 align-middle leading-none"
>
<IconifyIcon
icon="ant-design:download-outlined"
class="shrink-0 align-middle text-base text-primary"
/>
<a
:href="row.fileUrl"
target="_blank"
download
class="cursor-pointer align-middle text-primary hover:underline"
>
下载固件
</a>
</div>
<span v-else class="text-gray-400">无文件</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
auth: ['iot:ota-firmware:query'],
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:ota-firmware:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: 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

@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createOtaFirmware,
getOtaFirmware,
updateOtaFirmware,
} from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<IoTOtaFirmwareApi.Firmware>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['固件'])
: $t('ui.actionTitle.create', ['固件']);
});
const [Form, formApi] = useVbenForm({
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 IoTOtaFirmwareApi.Firmware;
try {
await (formData.value?.id
? updateOtaFirmware(data)
: createOtaFirmware(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<IoTOtaFirmwareApi.Firmware>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getOtaFirmware(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,165 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel, getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
/** 任务详情的描述字段 */
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 { ElMessage } from 'element-plus';
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');
ElMessage.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 { ElCard } from 'element-plus';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
defineProps<{
loading?: boolean;
task?: IoTOtaTaskApi.Task;
}>();
const [Description] = useDescription({
border: true,
column: 3,
schema: useDetailSchema(),
});
</script>
<template>
<ElCard v-loading="loading" header="任务信息">
<Description :data="task" />
</ElCard>
</template>

View File

@ -0,0 +1,160 @@
<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 { ElInput, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { cancelOtaTask, getOtaTaskPage } from '#/api/iot/ota/task';
import { $t } from '#/locales';
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
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');
}
/** 按任务名搜索(嵌入页面里,单字段搜索做成 toolbar 内联输入框,回车 / 清空触发查询) */
async function handleSearch() {
await gridApi.query();
}
/** 新增任务 */
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!);
ElMessage.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">
<ElInput
v-model="searchName"
placeholder="请输入任务名称"
clearable
style="width: 200px"
@keyup.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: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
auth: ['iot:ota-task:query'],
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.cancel'),
type: 'danger',
link: 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,86 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { ElCard, ElCol, ElRow } from 'element-plus';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
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>
<ElCard v-loading="loading" header="升级设备统计">
<ElRow :gutter="20" class="py-5">
<ElCol 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>
</ElCol>
</ElRow>
</ElCard>
</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,132 @@
<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 { ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
cancelOtaTaskRecord,
getOtaTaskRecordPage,
} from '#/api/iot/ota/task/record';
import { $t } from '#/locales';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
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.query();
}
/** 取消单条记录的升级 */
async function handleCancelUpgrade(record: IoTOtaTaskRecordApi.TaskRecord) {
await cancelOtaTaskRecord(record.id!);
ElMessage.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.query();
}
},
);
</script>
<template>
<ElCard header="升级设备记录">
<ElTabs v-model="activeTab" @tab-change="handleTabChange" class="mb-4">
<ElTabPane
v-for="tab in statusTabs"
:key="tab.key"
:name="tab.key"
:label="tab.label"
/>
</ElTabs>
<Grid>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.cancel'),
type: 'danger',
link: 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>
</ElCard>
</template>

View File

@ -0,0 +1,137 @@
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';
import { z } from '#/adapter/form';
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: z
.string()
.min(1, '分类名字不能为空')
.max(64, '分类名字长度不能超过 64 个字符'),
},
{
fieldName: 'sort',
label: '分类排序',
component: 'InputNumber',
componentProps: {
class: '!w-full',
placeholder: '请输入分类排序',
min: 0,
precision: 0,
},
defaultValue: 0,
rules: z.number().min(0, '分类排序不能小于 0'),
},
{
fieldName: 'status',
label: '分类状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'description',
label: '描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入分类描述',
rows: 3,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '分类名字',
component: 'Input',
componentProps: {
placeholder: '请输入分类名字',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<IotProductCategoryApi.ProductCategory>['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'name',
title: '名字',
minWidth: 200,
},
{
field: 'sort',
title: '排序',
width: 100,
},
{
field: 'status',
title: '状态',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'description',
title: '描述',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteProductCategory,
getProductCategoryPage,
} from '#/api/iot/product/category';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建分类 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑分类 */
function handleEdit(row: IotProductCategoryApi.ProductCategory) {
formModalApi.setData(row).open();
}
/** 删除分类 */
async function handleDelete(row: IotProductCategoryApi.ProductCategory) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteProductCategory(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getProductCategoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotProductCategoryApi.ProductCategory>,
});
</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:product-category:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:product-category:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:product-category:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createProductCategory,
getProductCategory,
updateProductCategory,
} from '#/api/iot/product/category';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<IotProductCategoryApi.ProductCategory>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['产品分类'])
: $t('ui.actionTitle.create', ['产品分类']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as IotProductCategoryApi.ProductCategory;
try {
await (formData.value?.id
? updateProductCategory(data)
: createProductCategory(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<IotProductCategoryApi.ProductCategory>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getProductCategory(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1 @@
export { default as ProductSelect } from './select.vue';

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, ref } from 'vue';
import { ElOption, ElSelect } from 'element-plus';
import { getSimpleProductList } from '#/api/iot/product/product';
/** 产品下拉选择器组件 */
defineOptions({ name: 'ProductSelect' });
const props = defineProps<{
deviceType?: number; //
modelValue?: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value?: number): void;
(e: 'change', value?: number): void;
}>();
const loading = ref(false);
const productList = ref<IotProductApi.Product[]>([]);
/** 处理选择变化 */
function handleChange(value: any) {
emit('update:modelValue', value as number | undefined);
emit('change', value as number | undefined);
}
/** 获取产品列表 */
async function getProductList() {
try {
loading.value = true;
productList.value = (await getSimpleProductList(props.deviceType)) || [];
} finally {
loading.value = false;
}
}
onMounted(() => {
getProductList();
});
</script>
<template>
<ElSelect
:model-value="modelValue"
:loading="loading"
placeholder="请选择产品"
clearable
class="w-full"
@change="handleChange"
>
<ElOption
v-for="p in productList"
:key="p.id!"
:label="p.name"
:value="p.id!"
/>
</ElSelect>
</template>

View File

@ -0,0 +1,255 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductApi } from '#/api/iot/product/product';
import { h } from 'vue';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElButton } from 'element-plus';
import { z } from '#/adapter/form';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
/** 基础表单字段(不含图标、图片、描述) */
export function useBasicFormSchema(
formApi?: any,
generateProductKey?: () => string,
): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'productKey',
label: 'ProductKey',
component: 'Input',
componentProps: {
placeholder: '请输入 ProductKey',
},
dependencies: {
triggerFields: ['id'],
if(values) {
return !values.id;
},
},
rules: z
.string()
.min(1, 'ProductKey 不能为空')
.max(32, 'ProductKey 长度不能超过 32 个字符'),
suffix: () => {
return h(
ElButton,
{
onClick: () => {
if (generateProductKey) {
formApi?.setFieldValue('productKey', generateProductKey());
}
},
},
{ default: () => '重新生成' },
);
},
},
{
fieldName: 'productKey',
label: 'ProductKey',
component: 'Input',
componentProps: {
placeholder: '请输入 ProductKey',
disabled: true,
},
dependencies: {
triggerFields: ['id'],
if(values) {
return !!values.id;
},
},
rules: z
.string()
.min(1, 'ProductKey 不能为空')
.max(32, 'ProductKey 长度不能超过 32 个字符'),
},
{
fieldName: 'name',
label: '产品名称',
component: 'Input',
componentProps: {
placeholder: '请输入产品名称',
},
rules: z
.string()
.min(1, '产品名称不能为空')
.max(64, '产品名称长度不能超过 64 个字符'),
},
{
fieldName: 'categoryId',
label: '产品分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductCategoryList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品分类',
},
rules: 'required',
},
{
fieldName: 'deviceType',
label: '设备类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
},
defaultValue: DeviceTypeEnum.DEVICE,
dependencies: {
triggerFields: ['id'],
componentProps: (values) => ({
// 编辑时设备类型不可改
disabled: !!values.id,
}),
},
rules: 'required',
},
{
fieldName: 'netType',
label: '联网方式',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_NET_TYPE, 'number'),
placeholder: '请选择联网方式',
},
// 网关子设备走网关联网,不需要联网方式
dependencies: {
triggerFields: ['deviceType'],
// TODO DONE @AI枚举值。或者这里不要枚举值也看看 vben 里,其它是不是也漏了枚举值。)
show: (values) => values.deviceType !== DeviceTypeEnum.GATEWAY,
},
rules: 'required',
},
{
fieldName: 'protocolType',
label: '协议类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE, 'string'),
placeholder: '请选择协议类型',
},
defaultValue: 'mqtt',
rules: 'required',
},
{
fieldName: 'serializeType',
label: '序列化类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_SERIALIZE_TYPE, 'string'),
placeholder: '请选择序列化类型',
},
defaultValue: 'json',
help: 'iot-gateway-server 默认根据接入的协议类型确定数据格式,仅 MQTT、EMQX 协议支持自定义序列化类型',
rules: 'required',
},
];
}
/** 高级设置表单字段(图标、图片、产品描述、动态注册) */
export function useAdvancedFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'registerEnabled',
label: '动态注册',
component: 'Switch',
componentProps: {
checkedChildren: '开',
unCheckedChildren: '关',
},
defaultValue: false,
help: '设备动态注册无需一一烧录设备证书DeviceSecret每台设备烧录相同的产品证书即 ProductKey 和 ProductSecret ,云端鉴权通过后下发设备证书,您可以根据需要开启或关闭动态注册,保障安全性。',
},
{
fieldName: 'icon',
label: '产品图标',
component: 'ImageUpload',
},
{
fieldName: 'picUrl',
label: '产品图片',
component: 'ImageUpload',
},
{
fieldName: 'description',
label: '产品描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入产品描述',
rows: 3,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<IotProductApi.Product>['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'productKey',
title: 'ProductKey',
minWidth: 150,
},
{
field: 'categoryName',
title: '品类',
minWidth: 120,
formatter: ({ row }) => row.categoryName || '未分类',
},
{
field: 'deviceType',
title: '设备类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
},
},
{
field: 'icon',
title: '产品图标',
width: 100,
cellRender: {
name: 'CellImage',
},
},
{
field: 'picUrl',
title: '产品图片',
width: 100,
cellRender: {
name: 'CellImage',
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,89 @@
<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 { ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { getDeviceCount } from '#/api/iot/device/device';
import { getProduct } from '#/api/iot/product/product';
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import ProductDetailsHeader from './modules/header.vue';
import ProductDetailsInfo from './modules/info.vue';
const route = useRoute();
const router = useRouter();
const id = Number(route.params.id);
const loading = ref(true);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
const activeTab = ref('info');
/** 向子组件提供产品信息 */
provide(IOT_PROVIDE_KEY.PRODUCT, product);
/** 获取产品详情 */
async function getProductData(productId: number) {
loading.value = true;
try {
product.value = await getProduct(productId);
} catch {
ElMessage.error('获取产品详情失败');
} finally {
loading.value = false;
}
}
/** 查询设备数量 */
async function getDeviceCountData(productId: number) {
try {
return await getDeviceCount(productId);
} catch {
ElMessage.error('获取设备数量失败');
return 0;
}
}
/** 初始化 */
onMounted(async () => {
if (!id) {
ElMessage.warning('参数错误,产品不能为空!');
router.back();
return;
}
await getProductData(id);
// tab
const { tab } = route.query;
if (tab) {
activeTab.value = tab as string;
}
//
if (product.value.id) {
product.value.deviceCount = await getDeviceCountData(product.value.id);
}
});
</script>
<template>
<Page>
<ProductDetailsHeader
:loading="loading"
:product="product"
@refresh="() => getProductData(id)"
/>
<ElTabs v-model="activeTab" class="mt-4">
<ElTabPane name="info" label="产品信息">
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
</ElTabPane>
<ElTabPane name="thingModel" label="物模型(功能定义)">
<IoTProductThingModel v-if="activeTab === 'thingModel'" />
</ElTabPane>
</ElTabs>
</Page>
</template>

View File

@ -0,0 +1,168 @@
<script lang="ts" setup>
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 {
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElMessage,
ElMessageBox,
} from 'element-plus';
import {
syncProductPropertyTable,
updateProductStatus,
} from '#/api/iot/product/product';
import Form from '../../modules/form.vue';
interface Props {
product: IotProductApi.Product;
loading?: boolean;
}
withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<{
refresh: [];
}>();
const router = useRouter();
const { hasAccessByCodes } = useAccess();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
ElMessage.success('复制成功');
} catch {
ElMessage.error('复制失败');
}
}
/** 跳转到设备管理 */
function goToDeviceList(productId: number) {
router.push({
name: 'IoTDevice',
query: { productId: String(productId) },
});
}
/** 打开编辑表单 */
function openEditForm(row: IotProductApi.Product) {
formModalApi.setData(row).open();
}
/** 发布产品 */
async function handlePublish(product: IotProductApi.Product) {
await ElMessageBox.confirm(`确认要发布产品「${product.name}」吗?`, '确认发布');
await updateProductStatus(product.id!, ProductStatusEnum.PUBLISHED);
ElMessage.success('发布成功');
emit('refresh');
}
/** 撤销发布 */
async function handleUnpublish(product: IotProductApi.Product) {
await ElMessageBox.confirm(
`确认要撤销发布产品「${product.name}」吗?`,
'确认撤销发布',
);
await updateProductStatus(product.id!, ProductStatusEnum.UNPUBLISHED);
ElMessage.success('撤销发布成功');
emit('refresh');
}
/** 同步物模型超级表结构 */
async function handleSyncPropertyTable(product: IotProductApi.Product) {
await ElMessageBox.confirm(
`确认要同步产品「${product.name}」的物模型超级表结构吗?`,
'确认同步',
);
await syncProductPropertyTable(product.id!);
ElMessage.success('同步成功');
}
</script>
<template>
<div class="mb-4">
<FormModal @success="emit('refresh')" />
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold">{{ product.name }}</h2>
</div>
<div class="flex gap-2">
<ElButton
v-if="hasAccessByCodes(['iot:product:update'])"
:disabled="product.status === ProductStatusEnum.PUBLISHED"
@click="openEditForm(product)"
>
编辑
</ElButton>
<ElButton
v-if="
product.status === ProductStatusEnum.UNPUBLISHED &&
hasAccessByCodes(['iot:product:update'])
"
type="primary"
@click="handlePublish(product)"
>
发布
</ElButton>
<ElButton
v-if="
product.status === ProductStatusEnum.PUBLISHED &&
hasAccessByCodes(['iot:product:update'])
"
type="danger"
@click="handleUnpublish(product)"
>
撤销发布
</ElButton>
<ElButton
v-if="hasAccessByCodes(['iot:product:update'])"
@click="handleSyncPropertyTable(product)"
>
同步物模型表结构
</ElButton>
</div>
</div>
<ElCard class="mt-4">
<ElDescriptions :column="1">
<ElDescriptionsItem label="ProductKey">
{{ product.productKey }}
<ElButton
class="ml-2"
size="small"
@click="copyToClipboard(product.productKey || '')"
>
复制
</ElButton>
</ElDescriptionsItem>
<ElDescriptionsItem label="设备总数">
<span class="ml-5 mr-2">
{{ product.deviceCount ?? '加载中...' }}
</span>
<ElButton size="small" @click="goToDeviceList(product.id!)">
前往管理
</ElButton>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</template>

View File

@ -0,0 +1,115 @@
<script lang="ts" setup>
import type { IotProductApi } from '#/api/iot/product/product';
import { ref } from 'vue';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import {
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElMessage,
} from 'element-plus';
import { DictTag } from '#/components/dict-tag';
interface Props {
product: IotProductApi.Product;
}
defineProps<Props>();
const showProductSecret = ref(false); //
/** 格式化日期 */
function formatDate(date?: Date | string) {
// TODO @AI使 return
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN');
}
/** 切换产品密钥显示状态 */
function toggleProductSecretVisible() {
showProductSecret.value = !showProductSecret.value;
}
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
ElMessage.success('复制成功');
} catch {
ElMessage.error('复制失败');
}
}
</script>
<template>
<ElCard>
<template #header>产品信息</template>
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="产品名称">
{{ product.name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属分类">
{{ product.categoryName || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDate(product.createTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="协议类型">
<DictTag
:type="DICT_TYPE.IOT_PROTOCOL_TYPE"
:value="product.protocolType"
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="序列化类型">
<DictTag
:type="DICT_TYPE.IOT_SERIALIZE_TYPE"
:value="product.serializeType"
/>
</ElDescriptionsItem>
<ElDescriptionsItem label="产品状态">
<DictTag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="
([DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY] as number[]).includes(
product.deviceType!,
)
"
label="联网方式"
>
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</ElDescriptionsItem>
<ElDescriptionsItem v-if="product.productSecret" label="ProductSecret">
<span v-if="showProductSecret">{{ product.productSecret }}</span>
<span v-else>********</span>
<ElButton class="ml-2" size="small" @click="toggleProductSecretVisible">
{{ showProductSecret ? '隐藏' : '显示' }}
</ElButton>
<ElButton
class="ml-2"
size="small"
@click="copyToClipboard(product.productSecret || '')"
>
复制
</ElButton>
</ElDescriptionsItem>
<ElDescriptionsItem label="动态注册">
{{ product.registerEnabled ? '已开启' : '未开启' }}
</ElDescriptionsItem>
<ElDescriptionsItem :span="3" label="产品描述">
{{ product.description || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</template>

View File

@ -0,0 +1,309 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import type { IotProductApi } from '#/api/iot/product/product';
import { nextTick, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { ProductStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElCard,
ElInput,
ElLoading,
ElMessage,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
import {
deleteProduct,
exportProduct,
getProductPage,
} from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import ProductCardView from './modules/card-view.vue';
import Form from './modules/form.vue';
const router = useRouter();
const categoryList = ref<IotProductCategoryApi.ProductCategory[]>([]);
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
const queryParams = ref({
name: '',
productKey: '',
}); //
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 加载产品分类列表 */
async function loadCategories() {
categoryList.value = await getSimpleProductCategoryList();
}
/** 搜索产品 */
function handleSearch() {
if (viewMode.value === 'list') {
gridApi.formApi.setValues(queryParams.value);
}
gridApi.query();
}
/** 重置搜索 */
function handleReset() {
queryParams.value.name = '';
queryParams.value.productKey = '';
handleSearch();
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return; //
}
viewMode.value = mode;
//
await nextTick();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportProduct(queryParams.value);
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
}
/** 打开产品详情 */
function openProductDetail(productId: number) {
router.push({
name: 'IoTProductDetail',
params: { id: productId },
});
}
/** 打开物模型管理 */
function openThingModel(productId: number) {
router.push({
name: 'IoTProductDetail',
params: { id: productId },
query: { tab: 'thingModel' },
});
}
/** 新增产品 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑产品 */
function handleEdit(row: IotProductApi.Product) {
formModalApi.setData(row).open();
}
/** 删除产品 */
async function handleDelete(row: IotProductApi.Product) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteProduct(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getProductPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams.value,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IotProductApi.Product>,
});
/** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') {
return await originalQuery(params);
} else {
// query
cardViewRef.value?.query();
}
};
/** 初始化 */
onMounted(() => {
loadCategories();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<!-- 统一搜索工具栏 -->
<ElCard body-class="!p-4" class="!mb-2">
<!-- 搜索表单 -->
<div class="mb-3 flex items-center gap-3">
<ElInput
v-model="queryParams.name"
clearable
class="w-[220px]"
placeholder="请输入产品名称"
@keyup.enter="handleSearch"
>
<template #prefix>
<span class="text-gray-400">产品名称</span>
</template>
</ElInput>
<ElInput
v-model="queryParams.productKey"
clearable
class="w-[220px]"
placeholder="请输入产品标识"
@keyup.enter="handleSearch"
>
<template #prefix>
<span class="text-gray-400">ProductKey</span>
</template>
</ElInput>
<ElButton type="primary" @click="handleSearch">
<IconifyIcon class="mr-1" icon="ant-design:search-outlined" />
搜索
</ElButton>
<ElButton @click="handleReset">
<IconifyIcon class="mr-1" icon="ant-design:reload-outlined" />
重置
</ElButton>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<TableAction
:actions="[
{
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,
},
]"
/>
<!-- 视图切换 -->
<div class="flex gap-1">
<ElButton
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" />
</ElButton>
<ElButton
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ant-design:unordered-list-outlined" />
</ElButton>
</div>
</div>
</ElCard>
<Grid v-show="viewMode === 'list'" table-title="产品列表">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
link: true,
auth: ['iot:product:query'],
onClick: openProductDetail.bind(null, row.id!),
},
{
label: '物模型',
type: 'primary',
link: true,
auth: ['iot:thing-model:query'],
onClick: openThingModel.bind(null, row.id!),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:product:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:product:delete'],
disabled: row.status === ProductStatusEnum.PUBLISHED,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<!-- 卡片视图 -->
<ProductCardView
v-show="viewMode === 'card'"
ref="cardViewRef"
:category-list="categoryList"
:search-params="queryParams"
@create="handleCreate"
@delete="handleDelete"
@detail="openProductDetail"
@edit="handleEdit"
@thing-model="openThingModel"
/>
</Page>
</template>

View File

@ -0,0 +1,425 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { DICT_TYPE, ProductStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElCard,
ElCol,
ElEmpty,
ElImage,
ElPagination,
ElPopconfirm,
ElRow,
ElTooltip,
} from 'element-plus';
import { getProductPage } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
interface Props {
categoryList: any[];
searchParams?: {
name: string;
productKey: string;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [productId: number];
edit: [row: any];
thingModel: [productId: number];
}>();
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const queryParams = ref({
pageNo: 1,
pageSize: 12,
});
/** 获取分类名称 */
function getCategoryName(categoryId: number) {
const category = props.categoryList.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
}
/** 获取产品列表 */
async function getList() {
loading.value = true;
try {
const data = await getProductPage({
...queryParams.value,
...props.searchParams,
});
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 处理分页变化 */
function handlePageChange() {
getList();
}
defineExpose({
reload: getList,
query: () => {
queryParams.value.pageNo = 1;
getList();
},
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<div class="product-card-view">
<!-- 产品卡片列表 -->
<div v-loading="loading" class="min-h-96">
<ElRow v-if="list.length > 0" :gutter="16">
<ElCol
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="12"
:lg="6"
class="mb-4"
>
<ElCard
body-class="!p-4"
class="product-card h-full 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">
<IconifyIcon
:icon="item.icon || 'lucide:box'"
class="text-xl"
/>
</div>
<div class="ml-3 min-w-0 flex-1">
<div class="product-title">{{ item.name }}</div>
</div>
</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) }}
</span>
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
/>
</div>
<div class="info-item">
<span class="info-label">产品标识</span>
<ElTooltip
:content="item.productKey || item.id"
placement="top"
>
<span class="info-value product-key cursor-pointer">
{{ item.productKey || item.id }}
</span>
</ElTooltip>
</div>
</div>
<!-- 产品图片 -->
<div class="product-image">
<ElImage
v-if="item.picUrl"
:src="item.picUrl"
:preview-src-list="[item.picUrl]"
class="size-full rounded object-cover"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<ElButton
v-if="hasAccessByCodes(['iot:product:update'])"
size="small"
class="action-btn action-btn-edit"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
编辑
</ElButton>
<ElButton
v-if="hasAccessByCodes(['iot:product:query'])"
size="small"
class="action-btn action-btn-detail"
@click="emit('detail', item.id)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
详情
</ElButton>
<ElButton
v-if="hasAccessByCodes(['iot:thing-model:query'])"
size="small"
class="action-btn action-btn-model"
@click="emit('thingModel', item.id)"
>
<IconifyIcon icon="lucide:git-branch" class="mr-1" />
物模型
</ElButton>
<template v-if="hasAccessByCodes(['iot:product:delete'])">
<!-- TODO DONE @AI使用枚举 -->
<ElTooltip
v-if="item.status === ProductStatusEnum.PUBLISHED"
content="已发布的产品不能删除"
>
<ElButton
size="small"
type="danger"
disabled
class="action-btn action-btn-delete !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
</ElTooltip>
<ElPopconfirm
v-else
:title="`确认删除产品 ${item.name} 吗?`"
@confirm="emit('delete', item)"
>
<template #reference>
<ElButton
size="small"
type="danger"
class="action-btn action-btn-delete !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
</template>
</ElPopconfirm>
</template>
</div>
</ElCard>
</ElCol>
</ElRow>
<!-- 空状态 -->
<ElEmpty v-else description="暂无产品数据" class="my-20" />
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="mt-3 flex justify-end">
<ElPagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.product-card-view {
.product-card {
overflow: hidden;
:deep(.el-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;
}
//
.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(--el-border-color);
.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

@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElCollapse, ElCollapseItem, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createProduct,
getProduct,
updateProduct,
} from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
const emit = defineEmits(['success']);
/** 生成 ProductKey包含大小写字母和数字 */
function generateProductKey(): string {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 16; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
const formData = ref<IotProductApi.Product>();
const activeKey = ref<string[]>([]);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['产品'])
: $t('ui.actionTitle.create', ['产品']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
const [AdvancedForm, advancedFormApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useAdvancedFormSchema(),
showDefaultActions: false,
});
/** 基础表单需要 formApi 引用,所以通过 setState 设置 schema */
formApi.setState({ schema: useBasicFormSchema(formApi, generateProductKey) });
/** 获取高级表单的值(如果表单未挂载,则从 formData 中获取) */
async function getAdvancedFormValues() {
if (advancedFormApi.isMounted) {
return await advancedFormApi.getValues();
}
// formData
return {
registerEnabled: formData.value?.registerEnabled,
icon: formData.value?.icon,
picUrl: formData.value?.picUrl,
description: formData.value?.description,
};
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const basicValues = await formApi.getValues();
const advancedValues = await getAdvancedFormValues();
const data = {
...basicValues,
...advancedValues,
} as IotProductApi.Product;
try {
await (formData.value?.id ? updateProduct(data) : createProduct(data));
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
activeKey.value = [];
return;
}
//
const data = modalApi.getData<IotProductApi.Product>();
if (!data || !data.id) {
// Collapse
activeKey.value = [];
await formApi.setValues({
productKey: generateProductKey(),
});
return;
}
//
modalApi.lock();
try {
formData.value = await getProduct(data.id);
await formApi.setValues(formData.value);
// Collapse
if (
formData.value?.registerEnabled ||
formData.value?.icon ||
formData.value?.picUrl ||
formData.value?.description
) {
activeKey.value = ['advanced'];
// Collapse
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<div class="mx-4">
<Form />
<ElCollapse v-model="activeKey" class="mt-4">
<ElCollapseItem name="advanced" title="更多设置">
<AdvancedForm />
</ElCollapseItem>
</ElCollapse>
</div>
</Modal>
</template>

View File

@ -0,0 +1,67 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDataTypeOptionsLabel } from '#/views/iot/utils/constants';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'type',
label: '功能类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE, 'number'),
placeholder: '请选择功能类型',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<ThingModelApi.ThingModel>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'type',
title: '功能类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_THING_MODEL_TYPE },
},
},
{
field: 'name',
title: '功能名称',
minWidth: 150,
},
{
field: 'identifier',
title: '标识符',
minWidth: 120,
},
{
title: '数据类型',
minWidth: 100,
formatter: ({ row }) =>
getDataTypeOptionsLabel(row.property?.dataType ?? '') || '-',
},
{
title: '数据定义',
minWidth: 200,
slots: { default: 'dataDefinition' },
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, inject } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteThingModel, getThingModelPage } from '#/api/iot/thingmodel';
import { $t } from '#/locales';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
import { DataDefinition } from './modules/components';
import Form from './modules/form.vue';
import Tsl from './modules/tsl.vue';
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const productId = computed(() => product?.value?.id);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TslModal, tslModalApi] = useVbenModal({
connectedComponent: Tsl,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 新增物模型 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑物模型 */
function handleEdit(row: ThingModelApi.ThingModel) {
formModalApi.setData({ id: row.id }).open();
}
/** 删除物模型 */
async function handleDelete(row: ThingModelApi.ThingModel) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteThingModel(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 打开 TSL 弹窗 */
function handleOpenTsl() {
tslModalApi.open();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getThingModelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
productId: productId.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<ThingModelApi.ThingModel>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<TslModal />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['物模型']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:thing-model:create'],
onClick: handleCreate,
},
{
label: 'TSL',
type: 'primary',
auth: ['iot:thing-model:query'],
onClick: handleOpenTsl,
},
]"
/>
</template>
<template #dataDefinition="{ row }">
<DataDefinition :data="row" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:thing-model:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:thing-model:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed } from 'vue';
import { ElTooltip } from 'element-plus';
import {
getEventTypeLabel,
getThingModelServiceCallTypeLabel,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
const NUMBER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
IoTDataSpecsDataTypeEnum.INT,
]);
const PLACEHOLDER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.DATE,
IoTDataSpecsDataTypeEnum.STRUCT,
]);
const LIST_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM,
]);
const formattedDataSpecsList = computed(() => {
if (!props.data.property?.dataSpecsList?.length) {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.value}-${item.name}`)
.join('、');
});
const shortText = computed(() => {
const list = props.data.property?.dataSpecsList;
if (!list?.length) {
return '-';
}
const first = list[0];
return list.length > 1
? `${first.value}-${first.name}${list.length}`
: `${first.value}-${first.name}`;
});
</script>
<template>
<!-- 属性 -->
<template v-if="data.type === IoTThingModelTypeEnum.PROPERTY">
<div v-if="NUMBER_TYPES.has(data.property?.dataType as any)">
取值范围{{
`${data.property?.dataSpecs?.min}~${data.property?.dataSpecs?.max}`
}}
</div>
<div v-if="data.property?.dataType === IoTDataSpecsDataTypeEnum.TEXT">
数据长度{{ data.property?.dataSpecs?.length }}
</div>
<div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div>
<div v-if="LIST_TYPES.has(data.property?.dataType as any)">
<ElTooltip :content="formattedDataSpecsList" placement="top-start">
<span
class="cursor-help border-b border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-500"
>
{{
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
? '布尔值'
: '枚举值'
}}{{ shortText }}
</span>
</ElTooltip>
</div>
</template>
<!-- 服务 -->
<div v-if="data.type === IoTThingModelTypeEnum.SERVICE">
调用方式
{{ getThingModelServiceCallTypeLabel(data.service?.callType as any) }}
</div>
<!-- 事件 -->
<div v-if="data.type === IoTThingModelTypeEnum.EVENT">
事件类型{{ getEventTypeLabel(data.event?.type as any) }}
</div>
</template>

View File

@ -0,0 +1 @@
export { default as DataDefinition } from './data-definition.vue';

View File

@ -0,0 +1,71 @@
<!-- dataTypearray 数组类型 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElRadio, ElRadioGroup } from 'element-plus';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelStructDataSpecs from './struct.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
/** 数组元素禁止选择的类型 */
const EXCLUDED_CHILD_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.DATE,
IoTDataSpecsDataTypeEnum.ENUM,
]);
const childDataTypeOptions = getDataTypeOptions().filter(
(item) => !EXCLUDED_CHILD_TYPES.has(item.value),
);
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 元素类型切到 struct 时,初始化 dataSpecsList 占位 */
function handleChange(val: any) {
if (val !== IoTDataSpecsDataTypeEnum.STRUCT) {
return;
}
dataSpecs.value.dataSpecsList = [];
}
</script>
<template>
<ElFormItem
:rules="ThingModelFormRules.childDataType"
label="元素类型"
prop="property.dataSpecs.childDataType"
>
<ElRadioGroup v-model="dataSpecs.childDataType" @change="handleChange">
<ElRadio
v-for="item in childDataTypeOptions"
:key="item.value"
:value="item.value"
class="w-1/3"
>
{{ `${item.value}(${item.label})` }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
:rules="ThingModelFormRules.size"
label="元素个数"
prop="property.dataSpecs.size"
>
<ElInput
v-model="dataSpecs.size"
placeholder="请输入数组中的元素个数"
/>
</ElFormItem>
<!-- Struct 型配置-->
<ThingModelStructDataSpecs
v-if="dataSpecs.childDataType === IoTDataSpecsDataTypeEnum.STRUCT"
v-model="dataSpecs.dataSpecsList"
/>
</template>

View File

@ -0,0 +1,131 @@
<!-- dataTypeenum 数组类型 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { buildIdentifierLikeNameValidator } from '#/api/iot/thingmodel';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const validateEnumName = buildIdentifierLikeNameValidator('枚举描述');
/** 添加枚举项 */
function addEnum() {
dataSpecsList.value.push({ name: '', value: '' } as any);
}
/** 删除枚举项 */
function deleteEnum(index: number) {
if (dataSpecsList.value.length === 1) {
ElMessage.warning('至少需要一个枚举项');
return;
}
dataSpecsList.value.splice(index, 1);
}
/** 校验单项枚举值:必填、数字、不重复 */
function validateEnumValue(_rule: any, value: any, callback: any) {
if (isEmpty(value)) {
callback(new Error('枚举值不能为空'));
return;
}
if (Number.isNaN(Number(value))) {
callback(new Error('枚举值必须是数字'));
return;
}
const sameCount = dataSpecsList.value.filter((it) => it.value === value)
.length;
if (sameCount > 1) {
callback(new Error('枚举值不能重复'));
return;
}
callback();
}
/** 校验整个枚举列表:非空、无空项、无非法数字、无重复 */
function validateEnumList(_rule: any, _value: any, callback: any) {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('请至少添加一个枚举项'));
return;
}
const hasEmpty = dataSpecsList.value.some(
(item) => isEmpty(item.value) || isEmpty(item.name),
);
if (hasEmpty) {
callback(new Error('存在未填写的枚举值或描述'));
return;
}
const hasInvalidNumber = dataSpecsList.value.some((item) =>
Number.isNaN(Number(item.value)),
);
if (hasInvalidNumber) {
callback(new Error('存在非数字的枚举值'));
return;
}
const values = dataSpecsList.value.map((item) => item.value);
if (new Set(values).size !== values.length) {
callback(new Error('存在重复的枚举值'));
return;
}
callback();
}
</script>
<template>
<ElFormItem
:rules="[{ validator: validateEnumList, trigger: 'change' }]"
label="枚举项"
>
<div class="flex flex-col">
<div class="flex items-center">
<span class="flex-1"> 参数值 </span>
<span class="flex-1"> 参数描述 </span>
</div>
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="mb-[5px] flex items-center justify-between"
>
<ElFormItem
:prop="`property.dataSpecsList.${index}.value`"
:rules="[
{ required: true, message: '枚举值不能为空', trigger: 'blur' },
{ validator: validateEnumValue, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<ElInput v-model="item.value" placeholder="请输入枚举值如「0」" />
</ElFormItem>
<span class="mx-2">~</span>
<ElFormItem
:prop="`property.dataSpecsList.${index}.name`"
:rules="[
{ required: true, message: '枚举描述不能为空', trigger: 'blur' },
{ validator: validateEnumName, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<ElInput v-model="item.name" placeholder="对该枚举项的描述" />
</ElFormItem>
<ElButton class="ml-2.5" link type="primary" @click="deleteEnum(index)">
删除
</ElButton>
</div>
<ElButton link type="primary" @click="addEnum">+ </ElButton>
</div>
</ElFormItem>
</template>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,4 @@
export { default as ThingModelArrayDataSpecs } from './array.vue';
export { default as ThingModelEnumDataSpecs } from './enum.vue';
export { default as ThingModelNumberDataSpecs } from './number.vue';
export { default as ThingModelStructDataSpecs } from './struct.vue';

View File

@ -2,16 +2,13 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { DataSpecsNumberData } from '#/api/iot/thingmodel';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useVModel } from '@vueuse/core';
import { Form, Input, Select } from 'ant-design-vue';
/** 数值型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelNumberDataSpecs' });
import { ElFormItem, ElInput, ElOption, ElSelect } from 'element-plus';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
@ -19,59 +16,60 @@ const dataSpecs = useVModel(
props,
'modelValue',
emits,
) as Ref<DataSpecsNumberData>;
) as Ref<ThingModelApi.DataSpecsNumberData>;
/** 单位发生变化时触发 */
const unitChange = (UnitSpecs: any) => {
if (!UnitSpecs) return;
const [unitName, unit] = String(UnitSpecs).split('-');
/** 单位下拉变化时,拆出 unitName 与 unit 回写 */
function unitChange(unitSpecs: any) {
if (!unitSpecs) {
return;
}
const [unitName, unit] = String(unitSpecs).split('-');
dataSpecs.value.unitName = unitName;
dataSpecs.value.unit = unit;
};
}
</script>
<template>
<Form.Item label="取值范围">
<ElFormItem label="取值范围">
<div class="flex items-center justify-between">
<div class="flex-1">
<Input v-model:value="dataSpecs.min" placeholder="请输入最小值" />
<ElInput v-model="dataSpecs.min" placeholder="请输入最小值" />
</div>
<span class="mx-2">~</span>
<div class="flex-1">
<Input v-model:value="dataSpecs.max" placeholder="请输入最大值" />
<ElInput v-model="dataSpecs.max" placeholder="请输入最大值" />
</div>
</div>
</Form.Item>
<Form.Item label="步长">
<Input v-model:value="dataSpecs.step" placeholder="请输入步长" />
</Form.Item>
<Form.Item label="单位">
<Select
</ElFormItem>
<ElFormItem label="步长">
<ElInput v-model="dataSpecs.step" placeholder="请输入步长" />
</ElFormItem>
<ElFormItem label="单位">
<ElSelect
:model-value="
dataSpecs.unit ? `${dataSpecs.unitName}-${dataSpecs.unit}` : ''
"
show-search
class="w-full"
filterable
placeholder="请选择单位"
class="w-1/1"
@change="unitChange"
>
<Select.Option
<ElOption
v-for="(item, index) in getDictOptions(
DICT_TYPE.IOT_THING_MODEL_UNIT,
'string',
)"
:key="index"
:label="`${item.label}-${item.value}`"
:value="`${item.label}-${item.value}`"
>
{{ `${item.label}-${item.value}` }}
</Select.Option>
</Select>
</Form.Item>
/>
</ElSelect>
</ElFormItem>
</template>
<style lang="scss" scoped>
:deep(.ant-form-item) {
.ant-form-item {
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}

View File

@ -7,15 +7,18 @@ import { useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
import {
ElButton,
ElDivider,
ElForm,
ElFormItem,
ElInput,
} from 'element-plus';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from '../thing-model-property.vue';
/** Struct 型的 dataSpecs 配置 */
defineOptions({ name: 'ThingModelStructDataSpecs' });
import ThingModelProperty from '../property.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
@ -112,7 +115,7 @@ onMounted(() => {
</script>
<template>
<Form.Item label="属性对象">
<ElFormItem label="属性对象">
<div
v-for="(item, index) in dataSpecsList"
:key="index"
@ -120,41 +123,40 @@ onMounted(() => {
>
<span>参数{{ item.name }}</span>
<div>
<Button type="link" @click="openStructForm(item)"></Button>
<Divider type="vertical" />
<Button danger type="link" @click="deleteStructItem(index)">
<ElButton link type="primary" @click="openStructForm(item)"></ElButton>
<ElDivider direction="vertical" />
<ElButton link type="danger" @click="deleteStructItem(index)">
删除
</Button>
</ElButton>
</div>
</div>
<Button type="link" @click="openStructForm(null)">+ </Button>
</Form.Item>
<ElButton link type="primary" @click="openStructForm(null)">+ </ElButton>
</ElFormItem>
<!-- 结构体参数表单 -->
<Modal class="w-2/5" title="结构体参数">
<Form
<ElForm
ref="structFormRef"
:label-col="{ span: 6 }"
:model="formData"
:wrapper-col="{ span: 18 }"
class="mx-4"
label-width="140px"
>
<Form.Item
<ElFormItem
:rules="ThingModelFormRules.name"
label="参数名称"
name="name"
prop="name"
>
<Input v-model:value="formData.name" placeholder="请输入参数名称" />
</Form.Item>
<Form.Item
<ElInput v-model="formData.name" placeholder="请输入参数名称" />
</ElFormItem>
<ElFormItem
:rules="ThingModelFormRules.identifier"
label="标识符"
name="identifier"
prop="identifier"
>
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<ElInput v-model="formData.identifier" placeholder="请输入标识符" />
</ElFormItem>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-struct-data-specs />
</Form>
</ElForm>
</Modal>
</template>

View File

@ -0,0 +1,64 @@
<!-- 产品的物模型表单event -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElRadio, ElRadioGroup } from 'element-plus';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelEventTypeEnum,
IoTThingModelParamDirectionEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './input-output-param.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const thingModelEvent = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 默认选中INFO 信息 */
watch(
() => thingModelEvent.value.type,
(val: string | undefined) =>
isEmpty(val) &&
(thingModelEvent.value.type = IoTThingModelEventTypeEnum.INFO.value),
{ immediate: true },
);
</script>
<template>
<ElFormItem
:rules="ThingModelFormRules.eventType"
label="事件类型"
prop="event.type"
>
<ElRadioGroup v-model="thingModelEvent.type">
<ElRadio
v-for="eventType in Object.values(IoTThingModelEventTypeEnum)"
:key="eventType.value"
:value="eventType.value"
>
{{ eventType.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="输出参数">
<ThingModelInputOutputParam
v-model="thingModelEvent.outputParams"
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
/>
</ElFormItem>
</template>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,265 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { cloneDeep, isEmpty } from '@vben/utils';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElRadioButton,
ElRadioGroup,
} from 'element-plus';
import {
createThingModel,
getThingModel,
ThingModelFormRules,
updateThingModel,
} from '#/api/iot/thingmodel';
import {
IOT_PROVIDE_KEY,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelEvent from './event.vue';
import ThingModelProperty from './property.vue';
import ThingModelService from './service.vue';
const emit = defineEmits(['success']);
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const formRef = ref();
const formData = ref<ThingModelApi.ThingModel>(buildEmptyFormData());
const getTitle = computed(() =>
formData.value.id
? $t('ui.actionTitle.edit', ['物模型'])
: $t('ui.actionTitle.create', ['物模型']),
);
// TODO @AI system user form
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalApi.lock();
try {
const data = cloneDeep(formData.value);
data.productId = product!.value.id;
data.productKey = product!.value.productKey;
fillExtraAttributes(data);
await (data.id ? updateThingModel(data) : createThingModel(data));
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
formData.value = buildEmptyFormData();
formRef.value?.clearValidate?.();
const data = modalApi.getData<{ id?: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
const result = await getThingModel(data.id);
formData.value = normalizeFormData(result);
} finally {
modalApi.unlock();
}
},
});
/** 构造空白表单数据 */
function buildEmptyFormData(): ThingModelApi.ThingModel {
return {
type: IoTThingModelTypeEnum.PROPERTY,
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
},
service: {
inputParams: [],
outputParams: [],
},
event: {
outputParams: [],
},
};
}
/** 回显数据时,规整各分支字段确保子表单可绑定 */
function normalizeFormData(result: ThingModelApi.ThingModel): ThingModelApi.ThingModel {
const next: any = { ...result, type: Number(result.type) };
if (isEmpty(next.property)) {
next.dataType = IoTDataSpecsDataTypeEnum.INT;
next.property = {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: { dataType: IoTDataSpecsDataTypeEnum.INT },
};
} else {
next.property.dataSpecs ??= {};
next.property.dataSpecsList ??= [];
next.property.dataType ??= IoTDataSpecsDataTypeEnum.INT;
}
if (isEmpty(next.service)) {
next.service = { inputParams: [], outputParams: [] };
} else {
next.service.inputParams ??= [];
next.service.outputParams ??= [];
}
if (isEmpty(next.event)) {
next.event = { outputParams: [] };
} else {
next.event.outputParams ??= [];
}
return next;
}
/** 按功能类型将子表单数据回写到顶层,并清理无关分支 */
function fillExtraAttributes(data: any) {
switch (data.type) {
case IoTThingModelTypeEnum.EVENT: {
removeDataSpecs(data.event);
data.dataType = data.event.dataType;
data.event.identifier = data.identifier;
data.event.name = data.name;
if (isEmpty(data.event.outputParams)) {
delete data.event.outputParams;
}
delete data.property;
delete data.service;
break;
}
case IoTThingModelTypeEnum.PROPERTY: {
removeDataSpecs(data.property);
data.dataType = data.property.dataType;
data.property.identifier = data.identifier;
data.property.name = data.name;
delete data.service;
delete data.event;
break;
}
case IoTThingModelTypeEnum.SERVICE: {
removeDataSpecs(data.service);
data.dataType = data.service.dataType;
data.service.identifier = data.identifier;
data.service.name = data.name;
if (isEmpty(data.service.inputParams)) {
delete data.service.inputParams;
}
if (isEmpty(data.service.outputParams)) {
delete data.service.outputParams;
}
delete data.property;
delete data.event;
break;
}
// No default
}
}
/** 清理空的 dataSpecs / dataSpecsList */
function removeDataSpecs(val: any) {
if (isEmpty(val.dataSpecs)) {
delete val.dataSpecs;
}
if (isEmpty(val.dataSpecsList)) {
delete val.dataSpecsList;
}
}
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<ElForm
ref="formRef"
:model="formData"
class="mx-4"
label-width="140px"
>
<ElFormItem
:rules="ThingModelFormRules.type"
label="功能类型"
prop="type"
>
<ElRadioGroup v-model="formData.type">
<ElRadioButton
v-for="dict in getDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
:key="String(dict.value)"
:value="Number(dict.value)"
>
{{ dict.label }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
:rules="ThingModelFormRules.name"
label="功能名称"
prop="name"
>
<ElInput v-model="formData.name" placeholder="请输入功能名称" />
</ElFormItem>
<ElFormItem
:rules="ThingModelFormRules.identifier"
label="标识符"
prop="identifier"
>
<ElInput v-model="formData.identifier" placeholder="请输入标识符" />
</ElFormItem>
<!-- 属性配置 -->
<ThingModelProperty
v-if="formData.type === IoTThingModelTypeEnum.PROPERTY"
v-model="formData.property"
/>
<!-- 服务配置 -->
<ThingModelService
v-if="formData.type === IoTThingModelTypeEnum.SERVICE"
v-model="formData.service"
/>
<!-- 事件配置 -->
<ThingModelEvent
v-if="formData.type === IoTThingModelTypeEnum.EVENT"
v-model="formData.event"
/>
<ElFormItem label="描述" prop="description">
<ElInput
v-model="formData.description"
:maxlength="200"
:rows="3"
placeholder="请输入物模型描述"
type="textarea"
/>
</ElFormItem>
</ElForm>
</Modal>
</template>

View File

@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
ElButton,
ElDivider,
ElForm,
ElFormItem,
ElInput,
} from 'element-plus';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from './property.vue';
const props = defineProps<{ direction: string; modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const paramFormRef = ref();
const formData = ref<any>(buildEmptyFormData());
// TODO @AI system user form
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await paramFormRef.value?.validate();
} catch {
return;
}
if (!thingModelParams.value) {
thingModelParams.value = [];
}
const data = formData.value;
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: data.property.dataType,
paraOrder: 0,
direction: props.direction,
dataSpecs:
!isEmpty(data.property.dataSpecs) &&
Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList)
? undefined
: data.property.dataSpecsList,
};
// identifier
const existingIndex = thingModelParams.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
if (existingIndex === -1) {
thingModelParams.value.push(item);
} else {
thingModelParams.value[existingIndex] = item;
}
await modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
formData.value = buildEmptyFormData();
paramFormRef.value?.clearValidate?.();
const data = modalApi.getData<any>();
if (isEmpty(data)) {
return;
}
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.dataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
},
};
},
});
/** 构造空白参数表单 */
function buildEmptyFormData() {
return {
identifier: '',
name: '',
description: '',
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: { dataType: IoTDataSpecsDataTypeEnum.INT },
dataSpecsList: [],
},
};
}
/** 打开参数表单(新增或编辑) */
function openParamForm(val: any) {
modalApi.setData(val).open();
}
/** 删除参数项 */
function deleteParamItem(index: number) {
thingModelParams.value.splice(index, 1);
}
</script>
<template>
<div
v-for="(item, index) in thingModelParams"
:key="index"
class="mb-2.5 flex w-full justify-between bg-gray-100 px-2.5 dark:bg-gray-800"
>
<span>参数名称{{ item.name }}</span>
<div>
<ElButton link type="primary" @click="openParamForm(item)"></ElButton>
<ElDivider direction="vertical" />
<ElButton link type="danger" @click="deleteParamItem(index)"></ElButton>
</div>
</div>
<ElButton link type="primary" @click="openParamForm(null)">+ </ElButton>
<!-- 参数表单 -->
<Modal class="w-2/5" title="参数配置">
<ElForm
ref="paramFormRef"
:model="formData"
class="mx-4"
label-width="140px"
>
<ElFormItem
:rules="ThingModelFormRules.name"
label="参数名称"
prop="name"
>
<ElInput v-model="formData.name" placeholder="请输入参数名称" />
</ElFormItem>
<ElFormItem
:rules="ThingModelFormRules.identifier"
label="标识符"
prop="identifier"
>
<ElInput v-model="formData.identifier" placeholder="请输入标识符" />
</ElFormItem>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-params />
</ElForm>
</Modal>
</template>

View File

@ -0,0 +1,227 @@
<!-- 产品的物模型表单property -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
ElFormItem,
ElInput,
ElOption,
ElRadio,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { ThingModelFormRules, validateBoolName } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
IoTThingModelAccessModeEnum,
} from '#/views/iot/utils/constants';
import {
ThingModelArrayDataSpecs,
ThingModelEnumDataSpecs,
ThingModelNumberDataSpecs,
ThingModelStructDataSpecs,
} from './data-specs';
const props = defineProps<{
isParams?: boolean;
isStructDataSpecs?: boolean;
modelValue: any;
}>();
const emits = defineEmits(['update:modelValue']);
/** 嵌套在结构体里时,禁止再选数组 / 结构体(最多支持两层嵌套) */
const NESTED_EXCLUDED_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
]);
const STRUCT_CHILD_OPTIONS = getDataTypeOptions().filter(
(item) => !NESTED_EXCLUDED_TYPES.has(item.value),
);
/** 数值型数据类型集合 */
const NUMERIC_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
IoTDataSpecsDataTypeEnum.INT,
]);
const property = useVModel(
props,
'modelValue',
emits,
) as Ref<ThingModelApi.Property>;
const dataTypeOptions = computed(() =>
props.isStructDataSpecs ? STRUCT_CHILD_OPTIONS : getDataTypeOptions(),
);
/** 数据类型切换时,重置 dataSpecs / dataSpecsList 并按新类型初始化 */
function handleChange(dataType: any) {
property.value.dataSpecs = {};
property.value.dataSpecsList = [];
// / / / dataType dataSpecs / / dataSpecsList
const listLike = [
IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM,
IoTDataSpecsDataTypeEnum.STRUCT,
];
if (!listLike.includes(dataType)) {
property.value.dataSpecs.dataType = dataType;
}
if (dataType === IoTDataSpecsDataTypeEnum.BOOL) {
for (let i = 0; i < 2; i++) {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.BOOL,
name: '', //
value: i, //
});
}
} else if (dataType === IoTDataSpecsDataTypeEnum.ENUM) {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.ENUM,
name: '', //
value: undefined, //
});
}
}
/** 顶层属性表单首次进入时,默认选中「读写」 */
if (!props.isStructDataSpecs && !props.isParams) {
watch(
() => property.value.accessMode,
(val: string | undefined) => {
if (isEmpty(val)) {
property.value.accessMode =
IoTThingModelAccessModeEnum.READ_WRITE.value;
}
},
{ immediate: true },
);
}
</script>
<template>
<ElFormItem label="数据类型">
<ElSelect
v-model="property.dataType"
placeholder="请选择数据类型"
@change="handleChange"
>
<!-- ARRAY STRUCT 类型数据相互嵌套时最多支持递归嵌套 2 父和子 -->
<ElOption
v-for="option in dataTypeOptions"
:key="option.value"
:label="`${option.value}(${option.label})`"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<!-- 数值型配置 -->
<ThingModelNumberDataSpecs
v-if="NUMERIC_TYPES.has(property.dataType || '')"
v-model="property.dataSpecs"
/>
<!-- 枚举型配置 -->
<ThingModelEnumDataSpecs
v-if="property.dataType === IoTDataSpecsDataTypeEnum.ENUM"
v-model="property.dataSpecsList"
/>
<!-- 布尔型配置 -->
<ElFormItem
v-if="property.dataType === IoTDataSpecsDataTypeEnum.BOOL"
label="布尔值"
>
<template
v-for="(item, index) in property.dataSpecsList"
:key="item.value"
>
<div class="mb-[5px] flex w-full items-center justify-start">
<span>{{ item.value }}</span>
<span class="mx-2">-</span>
<ElFormItem
:prop="`property.dataSpecsList.${index}.name`"
:rules="[
{ required: true, message: '布尔描述不能为空', trigger: 'blur' },
{ validator: validateBoolName, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<ElInput
v-model="item.name"
:placeholder="`如:${item.value === 0 ? '关' : '开'}`"
class="!w-[255px]"
/>
</ElFormItem>
</div>
</template>
</ElFormItem>
<!-- 文本型配置 -->
<ElFormItem
v-if="property.dataType === IoTDataSpecsDataTypeEnum.TEXT"
:rules="ThingModelFormRules.length"
label="数据长度"
prop="property.dataSpecs.length"
>
<ElInput
v-model="property.dataSpecs.length"
class="!w-[255px]"
placeholder="请输入文本字节长度"
>
<template #append>字节</template>
</ElInput>
</ElFormItem>
<!-- 时间型配置 -->
<ElFormItem
v-if="property.dataType === IoTDataSpecsDataTypeEnum.DATE"
label="时间格式"
prop="date"
>
<ElInput
class="!w-[255px]"
disabled
placeholder="String 类型的 UTC 时间戳(毫秒)"
/>
</ElFormItem>
<!-- 数组型配置-->
<ThingModelArrayDataSpecs
v-if="property.dataType === IoTDataSpecsDataTypeEnum.ARRAY"
v-model="property.dataSpecs"
/>
<!-- Struct 型配置-->
<ThingModelStructDataSpecs
v-if="property.dataType === IoTDataSpecsDataTypeEnum.STRUCT"
v-model="property.dataSpecsList"
/>
<ElFormItem
v-if="!isStructDataSpecs && !isParams"
:rules="ThingModelFormRules.accessMode"
label="读写类型"
prop="property.accessMode"
>
<ElRadioGroup v-model="property.accessMode">
<ElRadio
v-for="accessMode in Object.values(IoTThingModelAccessModeEnum)"
:key="accessMode.value"
:value="accessMode.value"
>
{{ accessMode.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
</template>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,70 @@
<!-- 产品的物模型表单service -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElRadio, ElRadioGroup } from 'element-plus';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelParamDirectionEnum,
IoTThingModelServiceCallTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './input-output-param.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const service = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 默认选中ASYNC 异步 */
watch(
() => service.value.callType,
(val: string | undefined) =>
isEmpty(val) &&
(service.value.callType = IoTThingModelServiceCallTypeEnum.ASYNC.value),
{ immediate: true },
);
</script>
<template>
<ElFormItem
:rules="ThingModelFormRules.callType"
label="调用方式"
prop="service.callType"
>
<ElRadioGroup v-model="service.callType">
<ElRadio
v-for="callType in Object.values(IoTThingModelServiceCallTypeEnum)"
:key="callType.value"
:value="callType.value"
>
{{ callType.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="输入参数">
<ThingModelInputOutputParam
v-model="service.inputParams"
:direction="IoTThingModelParamDirectionEnum.INPUT"
/>
</ElFormItem>
<ElFormItem label="输出参数">
<ThingModelInputOutputParam
v-model="service.outputParams"
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
/>
</ElFormItem>
</template>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, inject, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElInput, ElRadioButton, ElRadioGroup } from 'element-plus';
import { getThingModelTSL } from '#/api/iot/thingmodel';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const viewMode = ref<'editor' | 'view'>('view');
const thingModelTSL = ref<any>({});
const tslString = ref('');
// TODO @AI system user form
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0);
tslString.value = JSON.stringify(thingModelTSL.value, null, 2);
} finally {
modalApi.unlock();
}
},
});
/** 只读视图下,格式化后的 TSL 字符串 */
const formattedTSL = computed(() =>
JSON.stringify(thingModelTSL.value, null, 2),
);
/** 编辑器内容变化时,同步到数据对象 */
watch(tslString, (newValue) => {
// TODO @AI try catchantd
try {
thingModelTSL.value = JSON.parse(newValue);
} catch {
// JSON
}
});
</script>
<template>
<Modal :footer="false" class="w-3/5" title="物模型 TSL">
<div class="mx-4">
<div class="mb-4">
<ElRadioGroup v-model="viewMode" size="small">
<ElRadioButton value="view">代码视图</ElRadioButton>
<ElRadioButton value="editor">编辑器视图</ElRadioButton>
</ElRadioGroup>
</div>
<!-- 代码视图只读展示 -->
<div
v-if="viewMode === 'view'"
class="max-h-[600px] overflow-y-auto rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<!-- TODO @AI这种折行是不是要修复下不够规整 -->
<pre
class="m-0 whitespace-pre-wrap break-words font-mono text-[13px] leading-normal"
><code>{{ formattedTSL }}</code></pre>
</div>
<!-- 编辑器视图可编辑 -->
<ElInput
v-else
v-model="tslString"
:rows="20"
class="font-mono text-[13px]"
placeholder="请输入 JSON 格式的物模型 TSL"
type="textarea"
/>
</div>
</Modal>
</template>

View File

@ -0,0 +1,653 @@
// TODO @AI感觉这块放到 biz-iot-enum 里好点。
import { isEmpty } from '@vben/utils';
/** IoT 依赖注入 KEY */
export const IOT_PROVIDE_KEY = {
PRODUCT: 'IOT_PRODUCT',
};
/** IoT 设备状态枚举 */
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
ONLINE = 1, // 在线
OFFLINE = 2, // 离线
}
/** IoT 产品物模型类型枚举类 */
export const IoTThingModelTypeEnum = {
PROPERTY: 1, // 属性
SERVICE: 2, // 服务
EVENT: 3, // 事件
};
// IoT 产品物模型服务调用方式枚举
export const IoTThingModelServiceCallTypeEnum = {
ASYNC: {
label: '异步',
value: 'async',
},
SYNC: {
label: '同步',
value: 'sync',
},
};
export const getThingModelServiceCallTypeLabel = (
value: string,
): string | undefined =>
Object.values(IoTThingModelServiceCallTypeEnum).find(
(type) => type.value === value,
)?.label;
// IoT 产品物模型事件类型枚举
export const IoTThingModelEventTypeEnum = {
INFO: {
label: '信息',
value: 'info',
},
ALERT: {
label: '告警',
value: 'alert',
},
ERROR: {
label: '故障',
value: 'error',
},
};
export const getEventTypeLabel = (value: string): string | undefined =>
Object.values(IoTThingModelEventTypeEnum).find((type) => type.value === value)
?.label;
// IoT 产品物模型参数是输入参数还是输出参数
export const IoTThingModelParamDirectionEnum = {
INPUT: 'input', // 输入参数
OUTPUT: 'output', // 输出参数
};
// IoT 产品物模型访问模式枚举类
export const IoTThingModelAccessModeEnum = {
READ_WRITE: {
label: '读写',
value: 'rw',
},
READ_ONLY: {
label: '只读',
value: 'r',
},
WRITE_ONLY: {
label: '只写',
value: 'w',
},
};
/** 获取访问模式标签 */
export const getAccessModeLabel = (value: string): string => {
const mode = Object.values(IoTThingModelAccessModeEnum).find(
(mode) => mode.value === value,
);
return mode?.label || value;
};
/** 属性值的数据类型 */
export const IoTDataSpecsDataTypeEnum = {
INT: 'int',
FLOAT: 'float',
DOUBLE: 'double',
ENUM: 'enum',
BOOL: 'bool',
TEXT: 'text',
DATE: 'date',
STRUCT: 'struct',
ARRAY: 'array',
};
const DATA_TYPE_OPTIONS = Object.freeze([
{ value: IoTDataSpecsDataTypeEnum.INT, label: '整数型' },
{ value: IoTDataSpecsDataTypeEnum.FLOAT, label: '单精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.DOUBLE, label: '双精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.ENUM, label: '枚举型' },
{ value: IoTDataSpecsDataTypeEnum.BOOL, label: '布尔型' },
{ value: IoTDataSpecsDataTypeEnum.TEXT, label: '文本型' },
{ value: IoTDataSpecsDataTypeEnum.DATE, label: '时间型' },
{ value: IoTDataSpecsDataTypeEnum.STRUCT, label: '结构体' },
{ value: IoTDataSpecsDataTypeEnum.ARRAY, label: '数组' },
]);
export const getDataTypeOptions = () => DATA_TYPE_OPTIONS;
/** 获得物体模型数据类型配置项名称 */
export const getDataTypeOptionsLabel = (value: string) => {
if (isEmpty(value)) {
return value;
}
const dataType = getDataTypeOptions().find(
(option) => option.value === value,
);
return dataType && `${dataType.value}(${dataType.label})`;
};
/** 获取数据类型显示名称(用于属性选择器) */
export const getDataTypeName = (dataType: string): string => {
const typeMap: Record<string, string> = {
[IoTDataSpecsDataTypeEnum.INT]: '整数',
[IoTDataSpecsDataTypeEnum.FLOAT]: '浮点数',
[IoTDataSpecsDataTypeEnum.DOUBLE]: '双精度',
[IoTDataSpecsDataTypeEnum.TEXT]: '字符串',
[IoTDataSpecsDataTypeEnum.BOOL]: '布尔值',
[IoTDataSpecsDataTypeEnum.ENUM]: '枚举',
[IoTDataSpecsDataTypeEnum.DATE]: '日期',
[IoTDataSpecsDataTypeEnum.STRUCT]: '结构体',
[IoTDataSpecsDataTypeEnum.ARRAY]: '数组',
};
return typeMap[dataType] || dataType;
};
/** 获取数据类型标签颜色antd Tag `color` */
export const getDataTypeTagColor = (
dataType: string,
): 'default' | 'error' | 'processing' | 'success' | 'warning' => {
const tagMap: Record<
string,
'default' | 'error' | 'processing' | 'success' | 'warning'
> = {
[IoTDataSpecsDataTypeEnum.INT]: 'processing',
[IoTDataSpecsDataTypeEnum.FLOAT]: 'success',
[IoTDataSpecsDataTypeEnum.DOUBLE]: 'success',
[IoTDataSpecsDataTypeEnum.TEXT]: 'default',
[IoTDataSpecsDataTypeEnum.BOOL]: 'warning',
[IoTDataSpecsDataTypeEnum.ENUM]: 'error',
[IoTDataSpecsDataTypeEnum.DATE]: 'processing',
[IoTDataSpecsDataTypeEnum.STRUCT]: 'default',
[IoTDataSpecsDataTypeEnum.ARRAY]: 'warning',
};
return tagMap[dataType] || 'default';
};
/** 物模型组标签常量 */
export const THING_MODEL_GROUP_LABELS = {
PROPERTY: '设备属性',
EVENT: '设备事件',
SERVICE: '设备服务',
};
// IoT OTA 任务设备范围枚举
export const IoTOtaTaskDeviceScopeEnum = {
ALL: {
label: '全部设备',
value: 1,
},
SELECT: {
label: '指定设备',
value: 2,
},
};
// IoT OTA 任务状态枚举
export const IoTOtaTaskStatusEnum = {
IN_PROGRESS: {
label: '进行中',
value: 10,
},
END: {
label: '已结束',
value: 20,
},
CANCELED: {
label: '已取消',
value: 30,
},
};
// IoT OTA 升级记录状态枚举
export const IoTOtaTaskRecordStatusEnum = {
PENDING: {
label: '待推送',
value: 0,
},
PUSHED: {
label: '已推送',
value: 10,
},
UPGRADING: {
label: '升级中',
value: 20,
},
SUCCESS: {
label: '升级成功',
value: 30,
},
FAILURE: {
label: '升级失败',
value: 40,
},
CANCELED: {
label: '升级取消',
value: 50,
},
};
// ========== 场景联动规则相关常量 ==========
/** IoT 场景联动触发器类型枚举 */
export const IotRuleSceneTriggerTypeEnum = {
DEVICE_STATE_UPDATE: 1, // 设备上下线变更
DEVICE_PROPERTY_POST: 2, // 物模型属性上报
DEVICE_EVENT_POST: 3, // 设备事件上报
DEVICE_SERVICE_INVOKE: 4, // 设备服务调用
TIMER: 100, // 定时触发
};
/** 触发器类型选项配置 */
export const triggerTypeOptions = [
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
label: '设备状态变更',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
label: '设备属性上报',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
label: '设备事件上报',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
},
{
value: IotRuleSceneTriggerTypeEnum.TIMER,
label: '定时触发',
},
];
/** 判断是否为设备触发器类型 */
export function isDeviceTrigger(type: number): boolean {
const deviceTriggerTypes = [
IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
] as number[];
return deviceTriggerTypes.includes(type);
}
// ========== 场景联动规则执行器相关常量 ==========
/** IoT 场景联动执行器类型枚举 */
export const IotRuleSceneActionTypeEnum = {
DEVICE_PROPERTY_SET: 1, // 设备属性设置
DEVICE_SERVICE_INVOKE: 2, // 设备服务调用
ALERT_TRIGGER: 100, // 告警触发
ALERT_RECOVER: 101, // 告警恢复
};
/** 执行器类型选项配置 */
export const getActionTypeOptions = () => [
{
value: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
label: '设备属性设置',
},
{
value: IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
},
{
value: IotRuleSceneActionTypeEnum.ALERT_TRIGGER,
label: '触发告警',
},
{
value: IotRuleSceneActionTypeEnum.ALERT_RECOVER,
label: '恢复告警',
},
];
/** 获取执行器类型标签 */
export const getActionTypeLabel = (type: number): string => {
const option = getActionTypeOptions().find((opt) => opt.value === type);
return option?.label || '未知类型';
};
/** IoT 场景联动触发条件参数操作符枚举 */
export const IotRuleSceneTriggerConditionParameterOperatorEnum = {
EQUALS: { name: '等于', value: '=' }, // 等于
NOT_EQUALS: { name: '不等于', value: '!=' }, // 不等于
GREATER_THAN: { name: '大于', value: '>' }, // 大于
GREATER_THAN_OR_EQUALS: { name: '大于等于', value: '>=' }, // 大于等于
LESS_THAN: { name: '小于', value: '<' }, // 小于
LESS_THAN_OR_EQUALS: { name: '小于等于', value: '<=' }, // 小于等于
IN: { name: '在...之中', value: 'in' }, // 在...之中
NOT_IN: { name: '不在...之中', value: 'not in' }, // 不在...之中
BETWEEN: { name: '在...之间', value: 'between' }, // 在...之间
NOT_BETWEEN: { name: '不在...之间', value: 'not between' }, // 不在...之间
LIKE: { name: '字符串匹配', value: 'like' }, // 字符串匹配
NOT_NULL: { name: '非空', value: 'not null' }, // 非空
};
/** IoT 场景联动触发条件类型枚举 */
export const IotRuleSceneTriggerConditionTypeEnum = {
DEVICE_STATUS: 1, // 设备状态
DEVICE_PROPERTY: 2, // 设备属性
CURRENT_TIME: 3, // 当前时间
};
/** 获取条件类型选项 */
export const getConditionTypeOptions = () => [
{
value: IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS,
label: '设备状态',
},
{
value: IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY,
label: '设备属性',
},
{
value: IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME,
label: '当前时间',
},
];
/** 设备状态枚举 - 统一的设备状态管理 */
export const IoTDeviceStatusEnum = {
// 在线状态
ONLINE: {
label: '在线',
value: 'online',
tagType: 'success',
},
OFFLINE: {
label: '离线',
value: 'offline',
tagType: 'danger',
},
// 启用状态
ENABLED: {
label: '正常',
value: 0,
value2: 'enabled',
tagType: 'success',
},
DISABLED: {
label: '禁用',
value: 1,
value2: 'disabled',
tagType: 'danger',
},
// 激活状态
ACTIVATED: {
label: '已激活',
value2: 'activated',
tagType: 'success',
},
NOT_ACTIVATED: {
label: '未激活',
value2: 'not_activated',
tagType: 'info',
},
};
/** 设备选择器特殊选项 */
export const DEVICE_SELECTOR_OPTIONS = {
ALL_DEVICES: {
id: 0,
deviceName: '全部设备',
},
};
/** IoT 场景联动触发时间操作符枚举 */
export const IotRuleSceneTriggerTimeOperatorEnum = {
BEFORE_TIME: { name: '在时间之前', value: 'before_time' }, // 在时间之前
AFTER_TIME: { name: '在时间之后', value: 'after_time' }, // 在时间之后
BETWEEN_TIME: { name: '在时间之间', value: 'between_time' }, // 在时间之间
AT_TIME: { name: '在指定时间', value: 'at_time' }, // 在指定时间
BEFORE_TODAY: { name: '在今日之前', value: 'before_today' }, // 在今日之前
AFTER_TODAY: { name: '在今日之后', value: 'after_today' }, // 在今日之后
TODAY: { name: '在今日之间', value: 'today' }, // 在今日之间
};
/** 获取触发器类型标签 */
export const getTriggerTypeLabel = (type: number): string => {
const option = triggerTypeOptions.find((item) => item.value === type);
return option?.label || '未知类型';
};
// ========== JSON 参数输入组件相关常量 ==========
/** JSON 参数输入组件类型枚举 */
export const JsonParamsInputTypeEnum = {
SERVICE: 'service',
EVENT: 'event',
PROPERTY: 'property',
CUSTOM: 'custom',
};
/** JSON 参数输入组件类型 */
export type JsonParamsInputType =
(typeof JsonParamsInputTypeEnum)[keyof typeof JsonParamsInputTypeEnum];
/** JSON 参数输入组件文本常量 */
export const JSON_PARAMS_INPUT_CONSTANTS = {
// 基础文本
PLACEHOLDER: '请输入JSON格式的参数',
JSON_FORMAT_CORRECT: 'JSON 格式正确',
QUICK_FILL_LABEL: '快速填充:',
EXAMPLE_DATA_BUTTON: '示例数据',
CLEAR_BUTTON: '清空',
VIEW_EXAMPLE_TITLE: '查看参数示例',
COMPLETE_JSON_FORMAT: '完整 JSON 格式:',
REQUIRED_TAG: '必填',
// 错误信息
PARAMS_MUST_BE_OBJECT: '参数必须是一个有效的 JSON 对象',
PARAM_REQUIRED_ERROR: (paramName: string) => `参数 ${paramName} 为必填项`,
JSON_FORMAT_ERROR: (error: string) => `JSON格式错误: ${error}`,
UNKNOWN_ERROR: '未知错误',
// 类型相关标题
TITLES: {
SERVICE: (name?: string) => `${name || '服务'} - 输入参数示例`,
EVENT: (name?: string) => `${name || '事件'} - 输出参数示例`,
PROPERTY: '属性设置 - 参数示例',
CUSTOM: (name?: string) => `${name || '自定义'} - 参数示例`,
DEFAULT: '参数示例',
},
// 参数标签
PARAMS_LABELS: {
SERVICE: '输入参数',
EVENT: '输出参数',
PROPERTY: '属性参数',
CUSTOM: '参数列表',
DEFAULT: '参数',
},
// 空状态消息
EMPTY_MESSAGES: {
SERVICE: '此服务无需输入参数',
EVENT: '此事件无输出参数',
PROPERTY: '无可设置的属性',
CUSTOM: '无参数配置',
DEFAULT: '无参数',
},
// 无配置消息
NO_CONFIG_MESSAGES: {
SERVICE: '请先选择服务',
EVENT: '请先选择事件',
PROPERTY: '请先选择产品',
CUSTOM: '请先进行配置',
DEFAULT: '请先进行配置',
},
};
/** JSON 参数输入组件图标常量 */
export const JSON_PARAMS_INPUT_ICONS = {
// 标题图标
TITLE_ICONS: {
SERVICE: 'ep:service',
EVENT: 'ep:bell',
PROPERTY: 'ep:edit',
CUSTOM: 'ep:document',
DEFAULT: 'ep:document',
},
// 参数图标
PARAMS_ICONS: {
SERVICE: 'ep:edit',
EVENT: 'ep:upload',
PROPERTY: 'ep:setting',
CUSTOM: 'ep:list',
DEFAULT: 'ep:edit',
},
// 状态图标
STATUS_ICONS: {
ERROR: 'ep:warning',
SUCCESS: 'ep:circle-check',
},
};
/** JSON 参数输入组件示例值常量 */
export const JSON_PARAMS_EXAMPLE_VALUES: Record<string, any> = {
[IoTDataSpecsDataTypeEnum.INT]: { display: '25', value: 25 },
[IoTDataSpecsDataTypeEnum.FLOAT]: { display: '25.5', value: 25.5 },
[IoTDataSpecsDataTypeEnum.DOUBLE]: { display: '25.5', value: 25.5 },
[IoTDataSpecsDataTypeEnum.BOOL]: { display: 'false', value: false },
[IoTDataSpecsDataTypeEnum.TEXT]: { display: '"auto"', value: 'auto' },
[IoTDataSpecsDataTypeEnum.ENUM]: { display: '"option1"', value: 'option1' },
[IoTDataSpecsDataTypeEnum.STRUCT]: { display: '{}', value: {} },
[IoTDataSpecsDataTypeEnum.ARRAY]: { display: '[]', value: [] },
DEFAULT: { display: '""', value: '' },
};
// ========== Modbus 通用常量 ==========
/** Modbus 模式枚举 */
export const ModbusModeEnum = {
POLLING: 1, // 云端轮询
ACTIVE_REPORT: 2, // 主动上报
} as const;
/** Modbus 帧格式枚举 */
export const ModbusFrameFormatEnum = {
MODBUS_TCP: 1, // Modbus TCP
MODBUS_RTU: 2, // Modbus RTU
} as const;
/** Modbus 功能码枚举 */
export const ModbusFunctionCodeEnum = {
READ_COILS: 1, // 读线圈
READ_DISCRETE_INPUTS: 2, // 读离散输入
READ_HOLDING_REGISTERS: 3, // 读保持寄存器
READ_INPUT_REGISTERS: 4, // 读输入寄存器
} as const;
/** Modbus 功能码选项 */
export const ModbusFunctionCodeOptions = [
{ value: 1, label: '01 - 读线圈 (Coils)', description: '可读写布尔值' },
{
value: 2,
label: '02 - 读离散输入 (Discrete Inputs)',
description: '只读布尔值',
},
{
value: 3,
label: '03 - 读保持寄存器 (Holding Registers)',
description: '可读写 16 位数据',
},
{
value: 4,
label: '04 - 读输入寄存器 (Input Registers)',
description: '只读 16 位数据',
},
];
/** Modbus 原始数据类型枚举 */
export const ModbusRawDataTypeEnum = {
INT16: 'INT16',
UINT16: 'UINT16',
INT32: 'INT32',
UINT32: 'UINT32',
FLOAT: 'FLOAT',
DOUBLE: 'DOUBLE',
BOOLEAN: 'BOOLEAN',
STRING: 'STRING',
} as const;
/** Modbus 原始数据类型选项 */
export const ModbusRawDataTypeOptions = [
{
value: 'INT16',
label: 'INT16',
description: '有符号16位整数',
registerCount: 1,
},
{
value: 'UINT16',
label: 'UINT16',
description: '无符号16位整数',
registerCount: 1,
},
{
value: 'INT32',
label: 'INT32',
description: '有符号32位整数',
registerCount: 2,
},
{
value: 'UINT32',
label: 'UINT32',
description: '无符号32位整数',
registerCount: 2,
},
{
value: 'FLOAT',
label: 'FLOAT',
description: '32位浮点数',
registerCount: 2,
},
{
value: 'DOUBLE',
label: 'DOUBLE',
description: '64位浮点数',
registerCount: 4,
},
{
value: 'BOOLEAN',
label: 'BOOLEAN',
description: '布尔值',
registerCount: 1,
},
{
value: 'STRING',
label: 'STRING',
description: '字符串',
registerCount: 0,
},
];
/** Modbus 字节序选项 - 16位 */
export const ModbusByteOrder16Options = [
{ value: 'AB', label: 'AB', description: '大端序' },
{ value: 'BA', label: 'BA', description: '小端序' },
];
/** Modbus 字节序选项 - 32位 */
export const ModbusByteOrder32Options = [
{ value: 'ABCD', label: 'ABCD', description: '大端序' },
{ value: 'CDAB', label: 'CDAB', description: '大端字交换' },
{ value: 'DCBA', label: 'DCBA', description: '小端序' },
{ value: 'BADC', label: 'BADC', description: '小端字交换' },
];
/** 根据数据类型获取字节序选项 */
export const getByteOrderOptions = (rawDataType: string) => {
if (['FLOAT', 'INT32', 'UINT32'].includes(rawDataType)) {
return ModbusByteOrder32Options;
}
if (rawDataType === 'DOUBLE') {
// 64 位暂时复用 32 位字节序
return ModbusByteOrder32Options;
}
return ModbusByteOrder16Options;
};

View File

@ -13,4 +13,43 @@ export const MesItemOrProductEnum = {
/** MES 自动编码规则 Code 枚举 */
export const MesAutoCodeRuleCode = {
MD_ITEM_TYPE_CODE: 'MD_ITEM_TYPE_CODE',
MD_ITEM_CODE: 'MD_ITEM_CODE',
} as const;
/** MES 条码格式枚举 */
export enum BarcodeFormatEnum {
QR_CODE = 1,
EAN13 = 2,
CODE39 = 3,
UPC_A = 4,
}
/** 条码格式映射表(枚举值 -> JsBarcode 格式名) */
export const BARCODE_FORMAT_MAP: Record<BarcodeFormatEnum, string> = {
[BarcodeFormatEnum.QR_CODE]: 'QR_CODE',
[BarcodeFormatEnum.EAN13]: 'EAN13',
[BarcodeFormatEnum.CODE39]: 'CODE39',
[BarcodeFormatEnum.UPC_A]: 'UPC_A',
};
/** MES 条码业务类型枚举 */
export enum BarcodeBizTypeEnum {
WAREHOUSE = 102,
LOCATION = 103,
AREA = 104,
PACKAGE = 105,
STOCK = 106,
BATCH = 107,
PROCARD = 300,
WORKORDER = 301,
TRANSORDER = 302,
TASK = 303,
MACHINERY = 400,
TOOL = 500,
ITEM = 600,
VENDOR = 601,
WORKSTATION = 602,
WORKSHOP = 603,
USER = 604,
CLIENT = 605,
}