refactor:【antd】【iot】重构设备详情页面,优化组件结构与路径,优化设备配置、属性、事件管理等功能

This commit is contained in:
haohao
2025-12-16 16:45:35 +08:00
parent 439a35c165
commit 191e15975c
21 changed files with 1788 additions and 1553 deletions

View File

@@ -18,8 +18,7 @@ const routes: RouteRecordRaw[] = [
title: '产品详情',
activePath: '/iot/device/product',
},
component: () =>
import('#/views/iot/product/product/detail/index.vue'),
component: () => import('#/views/iot/product/product/detail/index.vue'),
},
{
path: 'device/detail/:id',
@@ -28,8 +27,7 @@ const routes: RouteRecordRaw[] = [
title: '设备详情',
activePath: '/iot/device/device',
},
component: () =>
import('#/views/iot/device/device/modules/detail/index.vue'),
component: () => import('#/views/iot/device/device/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',

View File

@@ -35,8 +35,6 @@ const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
const activeTab = ref('info');
const thingModelList = ref<ThingModelData[]>([]);
// TODO @haohao device/detail/index.vue
/** 获取设备详情 */
async function getDeviceData(deviceId: number) {
loading.value = true;

View File

@@ -21,6 +21,7 @@ const emit = defineEmits<{
const loading = ref(false); //
const pushLoading = ref(false); //
const saveLoading = ref(false); //
const config = ref<any>({}); // config
const configString = ref(''); //
@@ -50,11 +51,6 @@ const formattedConfig = computed(() => {
}
});
/** 判断配置是否有数据 */
const hasConfigData = computed(() => {
return config.value && Object.keys(config.value).length > 0;
});
/** 启用编辑模式的函数 */
function handleEdit() {
isEditing.value = true;
@@ -84,9 +80,13 @@ async function saveConfig() {
message.error({ content: 'JSON格式错误请修正后再提交' });
return;
}
// TODO @haohao pushLoading
await updateDeviceConfig();
isEditing.value = false;
saveLoading.value = true;
try {
await updateDeviceConfig();
isEditing.value = false;
} finally {
saveLoading.value = false;
}
}
/** 配置推送处理函数 */
@@ -126,36 +126,14 @@ async function updateDeviceConfig() {
<template>
<div>
<!-- 只在没有配置数据时显示提示 -->
<!-- 使用说明提示 -->
<Alert
v-if="!hasConfigData"
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
type="info"
show-icon
class="my-4"
description="如需编辑文件,请点击下方编辑按钮"
/>
<!-- TODO @haohao应该按钮是在下方可以参考 element-plus 的版本 -->
<div class="mt-5 text-center">
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
<Button
v-if="isEditing"
type="primary"
@click="saveConfig"
:loading="loading"
>
保存
</Button>
<Button v-else @click="handleEdit">编辑</Button>
<Button
v-if="!isEditing"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</Button>
</div>
<!-- 代码视图 - 只读展示 -->
<div v-if="!isEditing" class="json-viewer-container">
@@ -170,6 +148,28 @@ async function updateDeviceConfig() {
placeholder="请输入 JSON 格式的配置信息"
class="json-editor"
/>
<!-- 操作按钮 -->
<div class="mt-5 text-center">
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
<Button
v-if="isEditing"
type="primary"
@click="saveConfig"
:loading="saveLoading"
>
保存
</Button>
<Button v-else @click="handleEdit">编辑</Button>
<Button
v-if="!isEditing"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</Button>
</div>
</div>
</template>

View File

@@ -8,7 +8,7 @@ import { useVbenModal } from '@vben/common-ui';
import { Button, Card, Descriptions, message } from 'ant-design-vue';
import DeviceForm from '../../form.vue';
import DeviceForm from '../../modules/form.vue';
interface Props {
product: IotProductApi.Product;
@@ -74,7 +74,7 @@ function openEditForm(row: IotDeviceApi.Device) {
</div>
<Card class="mt-4">
<Descriptions :column="1">
<Descriptions :column="2">
<Descriptions.Item label="产品">
<a
class="cursor-pointer text-blue-600"

View File

@@ -6,7 +6,7 @@ import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { formatDateTime } from '@vben/utils';
import {
Button,
@@ -92,6 +92,12 @@ function handleAuthInfoDialogClose() {
:value="props.product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
<DictTag
:type="DICT_TYPE.IOT_LOCATION_TYPE"
:value="props.product.locationType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ props.device.deviceName }}
</Descriptions.Item>
@@ -105,16 +111,16 @@ function handleAuthInfoDialogClose() {
/>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(props.device.createTime) }}
{{ formatDateTime(props.device.createTime) }}
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDate(props.device.activeTime) }}
{{ formatDateTime(props.device.activeTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDate(props.device.onlineTime) }}
{{ formatDateTime(props.device.onlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDate(props.device.offlineTime) }}
{{ formatDateTime(props.device.offlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
@@ -138,6 +144,9 @@ function handleAuthInfoDialogClose() {
<IconifyIcon icon="ep:location" class="mr-2 text-primary" />
<span>设备位置</span>
</div>
<div class="text-sm text-gray-500">
最后上线{{ formatDateTime(props.device.onlineTime) || '--' }}
</div>
</div>
</template>
<div class="h-[500px] w-full">

View File

@@ -0,0 +1,252 @@
<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 } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
Button,
Select,
Space,
Switch,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
}>();
/** 查询参数 */
const queryParams = reactive({
method: undefined,
upstream: undefined,
});
/** 自动刷新开关 */
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['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">
<Select
v-model:value="queryParams.method"
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<Select.Option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
<Select
v-model:value="queryParams.upstream"
placeholder="上行/下行"
style="width: 160px"
allow-clear
>
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>
</Select>
<Space>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</Button>
<Switch
v-model:checked="autoRefresh"
checked-children="定时刷新"
un-checked-children="定时刷新"
/>
</Space>
</div>
<!-- 消息列表 -->
<Grid>
<template #ts="{ row }">
{{ formatDateTime(row.ts) }}
</template>
<template #upstream="{ row }">
<Tag :color="row.upstream ? 'blue' : 'green'">
{{ row.upstream ? '上行' : '下行' }}
</Tag>
</template>
<template #method="{ row }">
{{ methodOptions.find((item) => item.value === row.method)?.label }}
</template>
<template #params="{ row }">
<span v-if="row.reply">
{{
`{"code":${row.code},"msg":"${row.msg}","data":${row.data}\}`
}}
</span>
<span v-else>{{ row.params }}</span>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,612 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { TableColumnType } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DeviceStateEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Col,
Input,
message,
Row,
Table,
Tabs,
Textarea,
} from 'ant-design-vue';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
IotDeviceMessageMethodEnum,
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: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 折叠状态
const 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),
);
// 属性表格列定义
const propertyColumns: TableColumnType[] = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 90,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '值',
key: 'value',
width: 180,
fixed: 'right' as any,
},
];
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 90,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '值',
key: 'value',
width: 180,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '参数值',
key: 'value',
width: 180,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 获取表单值
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) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' });
console.error(error);
}
}
// 事件上报
async function handleEventPost(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' });
console.error(error);
}
}
// 状态变更
async function handleDeviceState(state: number) {
try {
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' });
console.error(error);
}
}
// 属性设置
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) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' });
console.error(error);
}
}
// 服务调用
async function handleServiceInvoke(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' });
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<Row :gutter="16">
<!-- 左侧指令调试区域 -->
<Col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<Card class="simulator-tabs h-full">
<template #title>
<div class="flex items-center justify-between">
<span>指令调试</span>
<Button
type="text"
size="small"
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon icon="lucide:chevron-up" v-if="!debugCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="debugCollapsed" />
</Button>
</div>
</template>
<div v-show="!debugCollapsed">
<Tabs v-model:active-key="activeTab" size="small">
<!-- 上行指令调试 -->
<Tabs.TabPane key="upstream" tab="上行指令调试">
<Tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
size="small"
>
<!-- 属性上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<Button type="primary" @click="handlePropertyPost">
发送属性上报
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 事件上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<Table
:data-source="eventList"
align="center"
:columns="eventColumns"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleEventPost(record)"
>
上报事件
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
<!-- 状态变更 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<Button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</Button>
<Button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
<!-- 下行指令调试 -->
<Tabs.TabPane key="downstream" tab="下行指令调试">
<Tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
size="small"
>
<!-- 属性调试 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<Button type="primary" @click="handlePropertySet">
发送属性设置
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 服务调用 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<Table
:data-source="serviceList"
align="center"
:columns="serviceColumns"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleServiceInvoke(record)"
>
服务调用
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
</Tabs>
</div>
</Card>
</Col>
<!-- 右侧设备消息区域 -->
<Col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>设备消息</span>
<Button
type="text"
size="small"
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon icon="lucide:chevron-down" v-if="!messageCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="messageCollapsed" />
</Button>
</div>
</template>
<div v-show="!messageCollapsed">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</div>
</Card>
</Col>
</Row>
</ContentWrap>
</template>

View File

@@ -0,0 +1,264 @@
<!-- 设备事件管理 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
Button,
RangePicker,
Select,
Space,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['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: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
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>
<Select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
>
{{ event.name }}({{ event.identifier }})
</Select.Option>
</Select>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</div>
<Space>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Space>
</div>
<!-- 事件列表 -->
<Grid>
<template #reportTime="{ row }">
{{
row.request?.reportTime
? formatDateTime(row.request.reportTime)
: '-'
}}
</template>
<template #identifier="{ row }">
<Tag color="blue" size="small">
{{ row.request?.identifier }}
</Tag>
</template>
<template #eventName="{ row }">
{{ getEventName(row.request?.identifier) }}
</template>
<template #eventType="{ row }">
{{ getEventType(row.request?.identifier) }}
</template>
<template #params="{ row }">
{{ parseParams(row.request?.params) }}
</template>
</Grid>
</Page>
</template>

View File

@@ -57,32 +57,54 @@ const queryParams = reactive({
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 [
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
].includes(thingModelDataType.value as any);
}); // struct array
return (
thingModelDataType.value === IoTDataSpecsDataTypeEnum.ARRAY ||
thingModelDataType.value === IoTDataSpecsDataTypeEnum.STRUCT
);
});
/** 最大值统计 */
const maxValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
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 (isComplexDataType.value || list.value.length === 0) return '-';
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 (isComplexDataType.value || list.value.length === 0) return '-';
if (!canShowChart.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
@@ -142,10 +164,10 @@ async function getList() {
) as IotDeviceApi.DevicePropertyDetail[];
total.value = list.value.length;
//
//
if (
viewMode.value === 'chart' &&
!isComplexDataType.value &&
canShowChart.value &&
list.value.length > 0
) {
await renderChartWhenReady();
@@ -287,8 +309,8 @@ async function open(deviceId: number, identifier: string, dataType: string) {
//
queryParams.times = formatDateRangeWithTime(dateRange.value);
// structarray使 list
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
// 使
viewMode.value = canShowChart.value ? 'chart' : 'list';
//
await nextTick();
@@ -380,11 +402,7 @@ function formatComplexValue(value: any) {
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (
newMode === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
if (newMode === 'chart' && canShowChart.value && list.value.length > 0) {
await renderChartWhenReady();
}
});
@@ -396,7 +414,6 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
v-model:open="dialogVisible"
title="查看数据"
width="1200px"
:destroy-on-close="true"
@cancel="handleClose"
>
<div class="property-history-container">
@@ -436,7 +453,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<Button
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
:disabled="isComplexDataType"
:disabled="!canShowChart"
>
<template #icon>
<IconifyIcon icon="ant-design:line-chart-outlined" />
@@ -459,7 +476,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
<Space :size="16">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && !isComplexDataType">
<span v-if="viewMode === 'chart' && canShowChart">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值:
{{ avgValue }}
</span>
@@ -529,10 +546,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
.chart-container,
.table-container {
padding: 16px;
background-color: hsl(
var(--card)
); // TODO @haohao fix ~ idea
background-color: hsl(var(--card) / 100%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 8px;
}

View File

@@ -1,13 +1,13 @@
<!-- 设备属性管理 -->
<script lang="ts" setup>
// TODO @haohao Grid 便 element-plus
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { formatDateTime } from '@vben/utils';
import {
Button,
@@ -17,37 +17,168 @@ import {
Input,
Row,
Switch,
Table,
Tag,
} from 'ant-design-vue';
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 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'); //
/** 自动刷新开关 */
const autoRefresh = ref(false);
/** 定时器 */
let autoRefreshTimer: any = null;
/** 视图模式状态 */
const viewMode = ref<'card' | 'list'>('card');
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['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 {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await getLatestDeviceProperties(params);
handleFilter();
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;
}
@@ -69,7 +200,21 @@ function handleFilter() {
/** 搜索按钮操作 */
function handleQuery() {
handleFilter();
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();
}
/** 历史操作 */
@@ -91,29 +236,43 @@ function formatValueWithUnit(item: IotDeviceApi.DevicePropertyDetail) {
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList();
}, 5000); // 5
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;
}
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<Page auto-content-height>
<!-- 搜索工作栏 -->
<div class="flex items-center justify-between" style="margin-bottom: 16px">
<div class="flex items-center" style="gap: 16px">
@@ -134,13 +293,13 @@ onMounted(() => {
<Button.Group>
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ep:grid" />
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ep:list" />
</Button>
@@ -214,7 +373,7 @@ onMounted(() => {
<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 ? formatDate(item.updateTime) : '-' }}
{{ item.updateTime ? formatDateTime(item.updateTime) : '-' }}
</span>
</div>
</div>
@@ -225,45 +384,31 @@ onMounted(() => {
</template>
<!-- 列表视图 -->
<Table v-else v-loading="loading" :data-source="list" :pagination="false">
<Table.Column title="属性标识符" align="center" data-index="identifier" />
<Table.Column title="属性名称" align="center" data-index="name" />
<Table.Column title="数据类型" align="center" data-index="dataType" />
<Table.Column title="属性值" align="center" data-index="value">
<template #default="{ record }">
{{ formatValueWithUnit(record) }}
</template>
</Table.Column>
<Table.Column
title="更新时间"
align="center"
data-index="updateTime"
:width="180"
>
<template #default="{ record }">
{{ record.updateTime ? formatDate(record.updateTime) : '-' }}
</template>
</Table.Column>
<Table.Column title="操作" align="center">
<template #default="{ record }">
<Button
type="link"
@click="
openHistory(props.deviceId, record.identifier, record.dataType)
"
>
查看数据
</Button>
</template>
</Table.Column>
</Table>
<Grid v-show="viewMode === 'list'">
<template #value="{ row }">
{{ formatValueWithUnit(row) }}
</template>
<template #updateTime="{ row }">
{{ row.updateTime ? formatDateTime(row.updateTime) : '-' }}
</template>
<template #actions="{ row }">
<Button
type="link"
@click="
openHistory(props.deviceId, row.identifier, row.dataType)
"
>
查看数据
</Button>
</template>
</Grid>
<!-- 表单弹窗添加/修改 -->
<DeviceDetailsThingModelPropertyHistory
ref="historyRef"
:device-id="props.deviceId"
/>
</ContentWrap>
</Page>
</template>
<style scoped>
/* 移除 a-row 的额外边距 */

View File

@@ -0,0 +1,291 @@
<!-- 设备服务调用 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
Button,
RangePicker,
Select,
Space,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['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: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
}
/** 获取调用方式 */
function getCallType(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
}
/** 解析参数 */
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>
<Select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
>
{{ service.name }}({{ service.identifier }})
</Select.Option>
</Select>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</div>
<Space>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Space>
</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 }">
<Tag color="blue" size="small">
{{ row.request?.identifier }}
</Tag>
</template>
<template #serviceName="{ row }">
{{ getServiceName(row.request?.identifier) }}
</template>
<template #callType="{ row }">
{{ getCallType(row.request?.identifier) }}
</template>
<template #inputParams="{ row }">
{{ parseParams(row.request?.params) }}
</template>
<template #outputParams="{ row }">
<span v-if="row.reply">
{{
`{"code":${row.reply.code},"msg":"${row.reply.msg}","data":${row.reply.data}\}`
}}
</span>
<span v-else>-</span>
</template>
</Grid>
</Page>
</template>

View File

@@ -2,14 +2,14 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onMounted, ref } from 'vue';
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 } from '@vben/utils';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import {
Button,
@@ -72,24 +72,16 @@ const queryParams = ref({
groupId: undefined as number | undefined,
}); // 搜索参数
// 获取字典选项
// TODO @haohao直接使用 getDictOptions 哈,不用包装方法;
const getIntDictOptions = (dictType: string) => {
return getDictOptions(dictType, 'number');
};
/** 搜索 */
function handleSearch() {
if (viewMode.value === 'list') {
gridApi.formApi.setValues(queryParams.value);
gridApi.query();
} else {
// todo @haohao改成 query 方法,更统一;
cardViewRef.value?.search(queryParams.value);
}
gridApi.query();
}
/** 重置 */
/** 重置搜索 */
function handleReset() {
queryParams.value.deviceName = '';
queryParams.value.nickname = '';
@@ -100,13 +92,20 @@ function handleReset() {
handleSearch();
}
/** 刷新 */
/** 刷新表格 */
function handleRefresh() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
cardViewRef.value?.reload();
gridApi.query();
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return; // 如果已经是目标视图,不需要切换
}
viewMode.value = mode;
// 等待视图更新后再触发查询
await nextTick();
gridApi.query();
}
/** 导出表格 */
@@ -161,9 +160,7 @@ async function handleDelete(row: IotDeviceApi.Device) {
/** 批量删除设备 */
async function handleDeleteBatch() {
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
[]) as IotDeviceApi.Device[];
if (checkedRows.length === 0) {
if (checkedIds.value.length === 0) {
message.warning('请选择要删除的设备');
return;
}
@@ -172,9 +169,9 @@ async function handleDeleteBatch() {
duration: 0,
});
try {
const ids = checkedRows.map((row) => row.id!);
await deleteDeviceList(ids);
await deleteDeviceList(checkedIds.value);
message.success($t('ui.actionMessage.deleteSuccess'));
checkedIds.value = [];
handleRefresh();
} finally {
hideLoading();
@@ -183,14 +180,11 @@ async function handleDeleteBatch() {
/** 添加到分组 */
function handleAddToGroup() {
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
[]) as IotDeviceApi.Device[];
if (checkedRows.length === 0) {
if (checkedIds.value.length === 0) {
message.warning('请选择要添加到分组的设备');
return;
}
const ids = checkedRows.map((row) => row.id!);
deviceGroupFormModalApi.setData(ids).open();
deviceGroupFormModalApi.setData(checkedIds.value).open();
}
/** 设备导入 */
@@ -198,6 +192,16 @@ function handleImport() {
deviceImportFormModalApi.open();
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
checkboxConfig: {
@@ -227,8 +231,23 @@ const [Grid, gridApi] = useVbenVxeGrid({
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 () => {
// 获取产品列表
@@ -291,7 +310,7 @@ onMounted(async () => {
style="width: 200px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
v-for="dict in getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number')"
:key="dict.value"
:value="dict.value"
>
@@ -305,7 +324,7 @@ onMounted(async () => {
style="width: 200px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
:key="dict.value"
:value="dict.value"
>
@@ -361,23 +380,23 @@ onMounted(async () => {
auth: ['iot:device:import'],
onClick: handleImport,
},
// TODO @haohao应该是选中后才可用
{
label: '添加到分组',
type: 'primary',
icon: 'ant-design:folder-add-outlined',
auth: ['iot:device:update'],
ifShow: () => viewMode === 'list',
disabled: isEmpty(checkedIds),
onClick: handleAddToGroup,
},
// TODO @haohao应该是选中后才可用然后然后 danger 颜色;
{
label: '批量删除',
type: 'primary',
color: 'error',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:device:delete'],
ifShow: () => viewMode === 'list',
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
@@ -387,13 +406,13 @@ onMounted(async () => {
<Space :size="4">
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" />
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ant-design:unordered-list-outlined" />
</Button>
@@ -402,10 +421,6 @@ onMounted(async () => {
</Card>
<Grid table-title="设备列表" v-show="viewMode === 'list'">
<template #toolbar-tools>
<div></div>
</template>
<!-- 所属产品列 -->
<template #product="{ row }">
<a
@@ -484,10 +499,6 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.vxe-toolbar div) {
z-index: 1;
}
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;

View File

@@ -223,6 +223,10 @@ defineExpose({
queryParams.value.pageNo = 1;
getList();
},
query: () => {
queryParams.value.pageNo = 1;
getList();
},
});
/** 初始化 */

View File

@@ -1,273 +0,0 @@
<script lang="ts" setup>
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Form,
Pagination,
Select,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
// TODO @haohao看看能不能调整成 Grid 风格~方便 element-plus 的迁移
const props = defineProps<{
deviceId: number;
}>();
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10,
});
// 列表数据
const loading = ref(false);
const total = ref(0);
const list = ref<any[]>([]);
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
align: 'center' as const,
width: 180,
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
align: 'center' as const,
width: 140,
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
align: 'center' as const,
width: 140,
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
align: 'center' as const,
width: 300,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
align: 'center' as const,
width: 140,
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
align: 'center' as const,
ellipsis: true,
},
];
/** 查询消息列表 */
async function getMessageList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePage(queryParams);
total.value = data.total;
list.value = data.list;
} finally {
loading.value = false;
}
}
/** 搜索操作 */
function handleQuery() {
queryParams.pageNo = 1;
getMessageList();
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList();
}
});
/** 刷新消息列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => {
handleQuery();
}, delay);
} else {
handleQuery();
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<ContentWrap>
<!-- 搜索区域 -->
<Form :model="queryParams" layout="inline">
<Form.Item>
<Select
v-model:value="queryParams.method"
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<Select.Option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Select
v-model:value="queryParams.upstream"
placeholder="上行/下行"
style="width: 160px"
allow-clear
>
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</Button>
<Switch
v-model:checked="autoRefresh"
class="ml-20px"
checked-children="定时刷新"
un-checked-children="定时刷新"
/>
</Form.Item>
</Form>
<!-- 消息列表 -->
<Table
:loading="loading"
:data-source="list"
:columns="columns"
:pagination="false"
class="whitespace-nowrap"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'ts'">
{{ formatDate(record.ts) }}
</template>
<template v-else-if="column.key === 'upstream'">
<Tag :color="record.upstream ? 'blue' : 'green'">
{{ record.upstream ? '上行' : '下行' }}
</Tag>
</template>
<template v-else-if="column.key === 'reply'">
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="record.reply"
/>
</template>
<template v-else-if="column.key === 'method'">
{{
methodOptions.find((item) => item.value === record.method)?.label
}}
</template>
<template v-else-if="column.key === 'params'">
<span v-if="record.reply">
{{
`{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}`
}}
</span>
<span v-else>{{ record.params }}</span>
</template>
</template>
</Table>
<!-- 分页 -->
<div class="mt-10px flex justify-end">
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getMessageList"
/>
</div>
</ContentWrap>
</template>

View File

@@ -1,605 +0,0 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { TableColumnType } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DeviceStateEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Input,
message,
Table,
Tabs,
Textarea,
} from 'ant-design-vue';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
IotDeviceMessageMethodEnum,
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: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 折叠状态
const 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),
);
// 属性表格列定义
const propertyColumns: TableColumnType[] = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 100,
},
{
title: '值',
key: 'value',
width: 300,
fixed: 'right' as any,
},
];
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
},
{
title: '值',
key: 'value',
width: 200,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 200,
},
{
title: '参数值',
key: 'value',
width: 200,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 获取表单值
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) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' });
console.error(error);
}
}
// 事件上报
async function handleEventPost(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' });
console.error(error);
}
}
// 状态变更
async function handleDeviceState(state: number) {
try {
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' });
console.error(error);
}
}
// 属性设置
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) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' });
console.error(error);
}
}
// 服务调用
async function handleServiceInvoke(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' });
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<!-- 上方指令调试区域 -->
<!-- TODO @haohao要不要改成左右 -->
<Card class="simulator-tabs mb-4">
<template #title>
<div class="flex items-center justify-between">
<span>指令调试</span>
<Button
type="text"
size="small"
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon icon="lucide:chevron-up" v-if="!debugCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="debugCollapsed" />
</Button>
</div>
</template>
<div v-show="!debugCollapsed">
<Tabs v-model:active-key="activeTab" size="small">
<!-- 上行指令调试 -->
<Tabs.TabPane key="upstream" tab="上行指令调试">
<Tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
size="small"
>
<!-- 属性上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<Button type="primary" @click="handlePropertyPost">
发送属性上报
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 事件上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<Table
:data-source="eventList"
align="center"
:columns="eventColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleEventPost(record)"
>
上报事件
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
<!-- 状态变更 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<Button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</Button>
<Button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
<!-- 下行指令调试 -->
<Tabs.TabPane key="downstream" tab="下行指令调试">
<Tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
size="small"
>
<!-- 属性调试 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<Button type="primary" @click="handlePropertySet">
发送属性设置
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 服务调用 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<Table
:data-source="serviceList"
align="center"
:columns="serviceColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleServiceInvoke(record)"
>
服务调用
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
</Tabs>
</div>
</Card>
<!-- 下方设备消息区域 -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>设备消息</span>
<Button
type="text"
size="small"
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon icon="lucide:chevron-down" v-if="!messageCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="messageCollapsed" />
</Button>
</div>
</template>
<div v-show="!messageCollapsed">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</div>
</Card>
</ContentWrap>
</template>

View File

@@ -1,237 +0,0 @@
<!-- 设备事件管理 -->
<script lang="ts" setup>
// TODO @haohao看看能不能用 Grid 实现下,方便 element-plus 迁移
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
RangePicker,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: undefined,
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** 查询列表 */
async function getList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取事件名称 */
function getEventName(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
}
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px"
>
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
>
{{ event.name }}({{ event.identifier }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<!-- 事件列表 -->
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="上报时间"
align="center"
data-index="reportTime"
:width="180"
>
<template #default="{ record }">
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</Tag>
</template>
</Table.Column>
<Table.Column
title="事件名称"
align="center"
data-index="eventName"
:width="160"
>
<template #default="{ record }">
{{ getEventName(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column
title="事件类型"
align="center"
data-index="eventType"
:width="100"
>
<template #default="{ record }">
{{ getEventType(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="params">
<template #default="{ record }">
{{ parseParams(record.request.params) }}
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>

View File

@@ -1,259 +0,0 @@
<!-- 设备服务调用 -->
<script lang="ts" setup>
// TODO @haohao看看能不能调整成 Grid 风格~方便 element-plus 的迁移
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** 查询列表 */
async function getList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = [];
handleQuery();
}
/** 获取服务名称 */
function getServiceName(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
}
/** 获取调用方式 */
function getCallType(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
}
/** 解析参数 */
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;
}
}
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px"
>
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
>
{{ service.name }}({{ service.identifier }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<!-- 服务调用列表 -->
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="调用时间"
align="center"
data-index="requestTime"
:width="180"
>
<template #default="{ record }">
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</Table.Column>
<Table.Column
title="响应时间"
align="center"
data-index="responseTime"
:width="180"
>
<template #default="{ record }">
{{
record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-'
}}
</template>
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</Tag>
</template>
</Table.Column>
<Table.Column
title="服务名称"
align="center"
data-index="serviceName"
:width="160"
>
<template #default="{ record }">
{{ getServiceName(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column
title="调用方式"
align="center"
data-index="callType"
:width="100"
>
<template #default="{ record }">
{{ getCallType(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="inputParams">
<template #default="{ record }">
{{ parseParams(record.request?.params) }}
</template>
</Table.Column>
<Table.Column title="输出参数" align="center" data-index="outputParams">
<template #default="{ record }">
<span v-if="record.reply">
{{
`{"code":${record.reply.code},"msg":"${record.reply.msg}","data":${record.reply.data}\}`
}}
</span>
<span v-else>-</span>
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>

View File

@@ -29,7 +29,7 @@ const [Form, formApi] = useVbenForm({
class: 'w-full',
},
},
wrapperClass: 'grid-cols-2',
wrapperClass: 'grid-cols-1',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,

View File

@@ -3,7 +3,7 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, ref } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
@@ -67,6 +67,17 @@ 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);
@@ -220,13 +231,13 @@ onMounted(() => {
<Space :size="4">
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" />
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ant-design:unordered-list-outlined" />
</Button>