feat(iot): 迁移 ele 的 alert、device、product、ota、home、thingmodel 的实现
parent
f1f8f4e64a
commit
e816288b82
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'); // 上行upstream、下行downstream
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
// 等待 Modal、Card 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {},
|
||||
};
|
||||
|
|
@ -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 @AI:antd 这里,也要加下 /** */
|
||||
/** 初始化 */
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { getOtaFirmware } from '#/api/iot/ota/firmware';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskList from '../../task/modules/list.vue';
|
||||
import UpgradeStatistics from '../../task/modules/statistics.vue';
|
||||
import FirmwareInfo from './modules/info.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const firmwareId = ref(Number(route.params.id));
|
||||
const firmwareLoading = ref(false);
|
||||
const firmware = ref<IoTOtaFirmwareApi.Firmware>();
|
||||
|
||||
const firmwareStatisticsLoading = ref(false);
|
||||
const firmwareStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取固件信息 */
|
||||
async function getFirmwareInfo() {
|
||||
firmwareLoading.value = true;
|
||||
try {
|
||||
firmware.value = await getOtaFirmware(firmwareId.value);
|
||||
} finally {
|
||||
firmwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取升级统计 */
|
||||
async function getStatistics() {
|
||||
firmwareStatisticsLoading.value = true;
|
||||
try {
|
||||
firmwareStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
firmwareId.value,
|
||||
);
|
||||
} finally {
|
||||
firmwareStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getFirmwareInfo();
|
||||
getStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<!-- 固件信息 -->
|
||||
<FirmwareInfo :firmware="firmware" :loading="firmwareLoading" />
|
||||
<!-- 升级设备统计 -->
|
||||
<div class="mt-4">
|
||||
<UpgradeStatistics
|
||||
:loading="firmwareStatisticsLoading"
|
||||
:statistics="firmwareStatistics"
|
||||
/>
|
||||
</div>
|
||||
<!-- 任务管理 -->
|
||||
<div v-if="firmware?.productId" class="mt-4">
|
||||
<OtaTaskList
|
||||
:firmware-id="firmwareId"
|
||||
:product-id="firmware.productId"
|
||||
@success="getStatistics"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { getOtaTask } from '#/api/iot/ota/task';
|
||||
import { getOtaTaskRecordStatusStatistics } from '#/api/iot/ota/task/record';
|
||||
|
||||
import OtaTaskRecordList from '../record/modules/list.vue';
|
||||
import TaskInfo from './info.vue';
|
||||
import UpgradeStatistics from './statistics.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const taskId = ref<number>();
|
||||
const taskLoading = ref(false);
|
||||
const task = ref<IoTOtaTaskApi.Task>();
|
||||
|
||||
const taskStatisticsLoading = ref(false);
|
||||
const taskStatistics = ref<Record<string, number>>({});
|
||||
|
||||
/** 获取任务详情 */
|
||||
async function getTaskInfo() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskLoading.value = true;
|
||||
try {
|
||||
task.value = await getOtaTask(taskId.value);
|
||||
} finally {
|
||||
taskLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取统计数据 */
|
||||
async function getStatistics() {
|
||||
if (!taskId.value) {
|
||||
return;
|
||||
}
|
||||
taskStatisticsLoading.value = true;
|
||||
try {
|
||||
taskStatistics.value = await getOtaTaskRecordStatusStatistics(
|
||||
undefined,
|
||||
taskId.value,
|
||||
);
|
||||
} finally {
|
||||
taskStatisticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条记录取消后,刷新任务信息和统计 */
|
||||
async function handleRecordSuccess() {
|
||||
await getStatistics();
|
||||
await getTaskInfo();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ id: number }>();
|
||||
if (!data?.id) {
|
||||
return;
|
||||
}
|
||||
taskId.value = data.id;
|
||||
await Promise.all([getTaskInfo(), getStatistics()]);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
title="升级任务详情"
|
||||
class="w-5/6"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<!-- 任务信息 -->
|
||||
<TaskInfo :task="task" :loading="taskLoading" />
|
||||
<!-- 升级设备统计 -->
|
||||
<div class="mt-4">
|
||||
<UpgradeStatistics
|
||||
:loading="taskStatisticsLoading"
|
||||
:statistics="taskStatistics"
|
||||
/>
|
||||
</div>
|
||||
<!-- 升级设备记录 -->
|
||||
<div class="mt-4">
|
||||
<OtaTaskRecordList :task-id="taskId" @success="handleRecordSuccess" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
/** 升级记录的列表字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: '设备名称',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'fromFirmwareVersion',
|
||||
title: '当前版本',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '升级状态',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_OTA_TASK_RECORD_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: '升级进度',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
formatter: ({ row }) => `${row.progress || 0}%`,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '状态描述',
|
||||
minWidth: 150,
|
||||
align: 'center',
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'updateTime',
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as ProductSelect } from './select.vue';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as DataDefinition } from './data-definition.vue';
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<!-- dataType:array 数组类型 -->
|
||||
<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>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<!-- dataType:enum 数组类型 -->
|
||||
<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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 catch?也关注下,antd 那
|
||||
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>
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue