feat: 实现设备模拟功能并优化相关页面

- 新增设备模拟相关的 API 接口
- 实现设备属性上报、功能下发和事件上报
- 优化设备详情页面,增加实时数据订阅和设备状态订阅
- 改进物模型编辑页面,支持步长设置和固定密码校验
- 重构部分组件以支持新功能
This commit is contained in:
fhysy 2025-08-28 11:34:59 +08:00
parent b2a8aa545e
commit a465fa498a
19 changed files with 2407 additions and 108 deletions

View File

@ -22,4 +22,7 @@ VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuP
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启SSE
VITE_GLOB_SSE_ENABLE=true
VITE_GLOB_SSE_ENABLE=false
#开始websocket
VITE_APP_WEBSOCKET=true

View File

@ -28,5 +28,7 @@ VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuP
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启SSE
VITE_GLOB_SSE_ENABLE=true
VITE_GLOB_SSE_ENABLE=false
#开始websocket
VITE_APP_WEBSOCKET=true

View File

@ -52,6 +52,7 @@
"lodash-es": "^4.17.21",
"monaco-editor": "^0.52.2",
"pinia": "catalog:",
"rxjs": "^7.8.2",
"tinymce": "^7.3.0",
"unplugin-vue-components": "^0.27.3",
"vite-plugin-monaco-editor": "^1.1.0",

View File

@ -60,3 +60,32 @@ export function deviceUpdate(data: DeviceForm) {
export function deviceRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/device/device/${id}`);
}
// 设备模拟
/**
*
* @param data
* @returns void
*/
export function deviceOperateReport(data: any) {
return requestClient.postWithMsg<void>('/device/operate/mockReport', data);
}
/**
*
* @param data
* @returns void
*/
export function deviceOperateFunc(data: any) {
return requestClient.postWithMsg<void>('/device/operate/func', data);
}
/**
*
* @param data
* @returns void
*/
export function deviceOperateEvent(data: any) {
return requestClient.postWithMsg<void>('/device/operate/mockEvent', data);
}

View File

@ -0,0 +1,195 @@
import { useAppConfig } from '@vben/hooks';
import { SvgMessageUrl } from '@vben/icons';
import { $t } from '@vben/locales';
import { useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import dayjs from 'dayjs';
import { Observable } from 'rxjs';
import { useNotifyStore } from '#/store/notify';
// 全局变量(原生 WebSocket 单例)
let ws: null | WebSocket = null;
let reconnectCount = 0; // 重连计数
let reconnectTimer: any = null;
let heartTimer: any = null;
let lockReconnect = false; // 避免重复连接
const maxReconnect = 100; // 重连总次数
const backoffBaseMs = 5000;
// 订阅管理requestId -> subscriber handlers
const subs: Record<
string,
Array<{ complete: () => void; next: (v: any) => void }>
> = {};
// 未连接时缓存要发送的消息
const tempQueue: string[] = [];
// 初始化 socket原生 WebSocket
export const initWebSocket = () => {
if (import.meta.env.VITE_APP_WEBSOCKET === 'false') {
return;
}
if (ws) {
return ws;
}
const { apiURL, clientId } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
const userStore = useUserStore();
const accessStore = useAccessStore();
const token = encodeURIComponent(accessStore.accessToken || '');
const url = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}${apiURL}/resource/websocket?Authorization=Bearer ${token}&clientid=${clientId}`;
if (reconnectCount >= maxReconnect) return;
reconnectCount += 1;
ws = new WebSocket(url);
ws.addEventListener('open', () => {
reconnectCount = 0;
// 心跳
heartTimer && clearInterval(heartTimer);
heartTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 10_000);
// flush queue
if (ws && ws.readyState === WebSocket.OPEN && tempQueue.length > 0) {
while (tempQueue.length > 0) {
const payload = tempQueue.pop();
if (payload) ws.send(payload);
}
}
});
ws.addEventListener('close', () => {
ws = null;
reconnect();
});
ws.addEventListener('error', () => {
ws = null;
reconnect();
});
ws.addEventListener('message', (e: MessageEvent<string>) => {
const raw = e.data;
if (typeof raw === 'string' && raw.includes('ping')) return;
let data: any;
try {
data = JSON.parse(raw);
} catch {
return;
}
if (data?.type === 'error') {
notification.error({
key: 'ws-error',
message: data.message || 'WebSocket错误',
});
return;
}
// 通知类消息(可选)
if (data?.type === 'notification') {
useNotifyStore().notificationList.unshift({
message: data.message || raw,
avatar: SvgMessageUrl,
date: dayjs().format('YYYY-MM-DD HH:mm:ss'),
isRead: false,
title: $t('component.notice.title'),
userId: userStore.userInfo?.userId || '0',
});
notification.success({
description: data.message || raw,
duration: 3,
message: $t('component.notice.received'),
});
return;
}
const requestId: string =
typeof data?.requestId === 'string' ? data.requestId : '';
if (!requestId) return;
const list = subs[requestId];
if (!Array.isArray(list) || list.length === 0) return;
if (data.type === 'complete') {
list.forEach((s) => s.complete());
return;
}
if (data.type === 'result') {
list.forEach((s) => s.next(data));
}
});
return ws;
};
function reconnect() {
heartTimer && clearInterval(heartTimer);
if (lockReconnect) return;
lockReconnect = true;
reconnectTimer && clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
initWebSocket();
lockReconnect = false;
}, backoffBaseMs * reconnectCount);
}
export const getWebSocket = (
id: string,
topic: string,
parameter: Record<string, any>,
) =>
new Observable<any>(
(subscriber: { complete: () => void; next: (v: any) => void }) => {
if (!subs[id]) subs[id] = [];
const handle = {
next: (val: any) => subscriber.next(val),
complete: () => subscriber.complete(),
};
subs[id].push(handle);
const msg = JSON.stringify({ id, topic, parameter, type: 'sub' });
initWebSocket();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(msg);
} else {
tempQueue.push(msg);
}
return () => {
const unsub = JSON.stringify({ id, type: 'unsub' });
const list = subs[id];
if (Array.isArray(list)) {
const idx = list.indexOf(handle);
if (idx !== -1) list.splice(idx, 1);
if (list.length === 0) subs[id] = [];
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(unsub);
}
};
},
);
export const closeWs = () => {
heartTimer && clearInterval(heartTimer);
reconnectTimer && clearTimeout(reconnectTimer);
if (ws) {
ws.close();
ws = null;
}
};

View File

@ -55,7 +55,7 @@ export const columns: VxeGridProps['columns'] = [
},
{
title: '所属产品',
field: 'productName',
field: 'productObj.productName',
},
{
title: '设备类型',

View File

@ -3,7 +3,9 @@ import { computed, ref } from 'vue';
import { TabPane, Tabs } from 'ant-design-vue';
import EventSimulation from './simulation/EventSimulation.vue';
import FunctionSimulation from './simulation/FunctionSimulation.vue';
import PropertySimulation from './simulation/PropertySimulation.vue';
interface Props {
deviceId: string;
@ -13,20 +15,34 @@ interface Props {
const props = defineProps<Props>();
// tab
const activeTab = ref('function');
const activeTab = ref('property');
// 使使
const getMetadata = () => {
try {
const raw = props.deviceInfo?.productObj?.metadata;
if (!raw) return { functions: [] } as any;
if (!raw)
return {
functions: [],
properties: [],
propertyGroups: [],
events: [],
} as any;
const obj = JSON.parse(raw || '{}');
return {
functions: obj?.functions || [],
properties: obj?.properties || [],
propertyGroups: obj?.propertyGroups || [],
events: obj?.events || [],
};
} catch (error) {
console.warn('parse metadata error', error);
return { functions: [] } as any;
return {
functions: [],
properties: [],
propertyGroups: [],
events: [],
} as any;
}
};
@ -37,13 +53,22 @@ const metadata = computed(() => getMetadata());
const functionList = computed(() => {
return metadata.value.functions || [];
});
//
const eventList = computed(() => {
return metadata.value.events || [];
});
</script>
<template>
<div class="device-simulation">
<Tabs v-model:active-key="activeTab">
<TabPane key="property" tab="属性">
<div>属性功能开发中...</div>
<PropertySimulation
:device-id="deviceId"
:device-info="deviceInfo"
:metadata="metadata"
/>
</TabPane>
<TabPane key="function" tab="功能">
<FunctionSimulation
@ -53,7 +78,11 @@ const functionList = computed(() => {
/>
</TabPane>
<TabPane key="event" tab="事件">
<div>事件功能开发中...</div>
<EventSimulation
:device-id="deviceId"
:device-info="deviceInfo"
:event-list="eventList"
/>
</TabPane>
<TabPane key="online" tab="上下线">
<div>上下线功能开发中...</div>

View File

@ -35,7 +35,11 @@ const metadata = computed(() => {
<div class="running-status">
<Tabs type="card">
<TabPane key="realtime" tab="实时数据">
<RealtimePanel :device-id="props.deviceId" :metadata="metadata" />
<RealtimePanel
:device-id="props.deviceId"
:metadata="metadata"
:device-info="deviceInfo"
/>
</TabPane>
<TabPane key="events" tab="事件">
<EventsPanel :device-id="props.deviceId" :metadata="metadata" />

View File

@ -25,9 +25,12 @@ import {
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getWebSocket } from '#/utils/websocket';
interface Props {
deviceId: string;
metadata: any;
deviceInfo: any;
}
const props = defineProps<Props>();
@ -39,6 +42,48 @@ const selectedTypes = ref(['R', 'RW']);
// metadata
const runtimeProperties = ref<any[]>([]);
// WebSocket
let realtimeDataSubscription: any = null;
//
const subscribeRealtimeData = () => {
const productKey = props.deviceInfo.productObj.productKey;
const deviceKey = props.deviceInfo.deviceKey;
if (!deviceKey || !props.metadata?.properties?.length) return;
const id = `device-realtime-${deviceKey}`;
const topic = `/device/${productKey}/report`;
realtimeDataSubscription = getWebSocket(id, topic, {
deviceKey,
properties: props.metadata.properties.map((p: any) => p.id),
interval: '3s',
}).subscribe((data: any) => {
if (data.payload?.deviceKey === deviceKey && data.payload?.properties) {
//
data.payload.properties.forEach((propData: any) => {
const property = runtimeProperties.value.find(
(p: any) => p.id === propData.id,
);
if (property) {
property.value = propData.value;
property.timestamp = dayjs(propData.timestamp).format(
'YYYY-MM-DD HH:mm:ss',
);
}
});
}
});
};
//
const unsubscribeRealtimeData = () => {
if (realtimeDataSubscription) {
realtimeDataSubscription.unsubscribe();
realtimeDataSubscription = null;
}
};
const initRuntime = () => {
const properties = (props.metadata?.properties || []).map((prop: any) => ({
...prop,
@ -248,15 +293,22 @@ const stopRefreshTimer = () => {
onMounted(() => {
initRuntime();
startRefreshTimer();
subscribeRealtimeData();
});
onUnmounted(() => {
stopRefreshTimer();
unsubscribeRealtimeData();
});
watch(
() => props.metadata,
() => initRuntime(),
() => {
initRuntime();
//
unsubscribeRealtimeData();
subscribeRealtimeData();
},
{ deep: true },
);
</script>

View File

@ -0,0 +1,729 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import {
Button,
DatePicker,
Form,
Input,
InputNumber,
message,
Select,
SelectOption,
Slider,
Switch,
Table,
TabPane,
Tabs,
TimePicker,
} from 'ant-design-vue';
import { deviceOperateEvent } from '#/api/device/device';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
import { dataTypeOptions } from '#/constants/dicts';
interface Props {
deviceId: string;
deviceInfo: any;
eventList: any[];
}
const props = defineProps<Props>();
//
const selectedEventId = ref('');
// simple() / advanced()
const currentMode = ref('simple');
//
const formRef = ref();
// JSON
const jsonContent = ref('{}');
//
const parameterContent = ref('');
//
const submitResult = ref('');
//
const currentEvent = ref();
//
const currentOutputs = ref([]);
//
const selectedRowKeys = ref([]);
//
const formData = ref({});
//
const columns = [
{
title: '参数名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '输入类型',
dataIndex: 'dataType',
key: 'dataType',
width: 100,
},
{
title: '值',
dataIndex: 'value',
key: 'value',
},
];
//
const tableDataSource = computed(() => {
return currentOutputs.value.map((output) => ({
key: output.id,
id: output.id,
name: output.name,
dataType:
dataTypeOptions.find(
(option) => option.value === output.valueParams.dataType,
)?.label || output.valueParams.dataType,
required: output.required,
formType: output.valueParams.formType,
valueParams: output.valueParams,
}));
});
//
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (selectedKeys: string[]) => {
selectedRowKeys.value = selectedKeys;
},
onSelectAll: (selected: boolean) => {
selectedRowKeys.value = selected
? tableDataSource.value.map((row) => row.key)
: tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
},
onSelect: (record: any, selected: boolean) => {
if (record.required && !selected) {
//
return;
}
selectedRowKeys.value = selected
? [...selectedRowKeys.value, record.key]
: selectedRowKeys.value.filter((key) => key !== record.key);
},
getCheckboxProps: (record: any) => ({
disabled: record.required, //
}),
}));
//
const initializeSelectedRows = () => {
selectedRowKeys.value = currentOutputs.value
.filter((output) => output.required)
.map((output) => output.id);
};
//
const initializeFormData = () => {
const defaultData = {};
currentOutputs.value.forEach((output) => {
const { formType } = output.valueParams;
//
switch (formType) {
case 'number': {
// 0
defaultData[output.id] = output.valueParams.min || 0;
break;
}
case 'progress': {
//
defaultData[output.id] = output.valueParams.min || 0;
break;
}
case 'select': {
//
if (
output.valueParams.enumConf &&
output.valueParams.enumConf.length > 0
) {
defaultData[output.id] = output.valueParams.enumConf[0].value;
}
break;
}
case 'switch': {
//
defaultData[output.id] =
output.valueParams.dataType === 'boolean'
? false
: output.valueParams.falseValue || 'false';
break;
}
default: {
// undefined
break;
}
}
});
formData.value = { ...defaultData };
};
//
watch(selectedEventId, (newEventId) => {
if (newEventId) {
//
const selectedEvt = props.eventList.find((evt) => evt.id === newEventId);
if (selectedEvt) {
currentEvent.value = selectedEvt;
currentOutputs.value = selectedEvt.outputs || [];
//
initializeSelectedRows();
//
initializeFormData();
}
}
resetForm();
generateDefaultContent();
});
//
watch(currentMode, () => {
resetForm();
generateDefaultContent();
});
//
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
formData.value = {};
submitResult.value = '';
};
//
const generateDefaultContent = () => {
if (!currentEvent.value) return;
if (currentMode.value === 'advanced') {
// key:null
const params = {};
currentOutputs.value.forEach((output) => {
params[output.id] = null;
});
jsonContent.value = JSON.stringify(params, null, 2);
parameterContent.value = '';
} else {
//
parameterContent.value = '';
}
};
// JSON
const handleJsonChange = (value: string) => {
jsonContent.value = value;
};
//
const executeSubmit = async () => {
try {
let parameters = {};
if (currentMode.value === 'simple') {
//
if (selectedRowKeys.value.length === 0) {
message.error('请至少选择一个参数');
return;
}
//
await formRef.value.validate();
const formValues = formData.value;
console.log('表单值:', formValues);
//
for (const output of currentOutputs.value) {
if (selectedRowKeys.value.includes(output.id)) {
const value = formValues[output.id];
const { formType } = output.valueParams;
//
let isEmpty = false;
// 使 isEmpty
isEmpty =
formType === 'switch'
? value === undefined || value === null
: value === undefined ||
value === null ||
value === '' ||
(typeof value === 'string' && value.trim() === '');
if (isEmpty) {
message.error(`请填写参数:${output.name}`);
return;
}
}
}
// key:value
parameters = {};
selectedRowKeys.value.forEach((key) => {
const value = formValues[key];
// false
const output = currentOutputs.value.find((item) => item.id === key);
const isSwitch = output?.valueParams?.formType === 'switch';
if (
(value !== undefined && value !== null) ||
(isSwitch && value === false)
) {
if (output) {
const { dataType, formType } = output.valueParams;
let processedValue = value;
// dataTypeformType
if (formType === 'switch') {
if (dataType === 'boolean') {
// boolean
processedValue = Boolean(value);
} else if (dataType === 'string') {
// string
processedValue = value
? output.valueParams.trueValue || 'true'
: output.valueParams.falseValue || 'false';
}
} else if (formType === 'time' && dataType === 'date') {
// date
if (value && typeof value === 'object' && value.valueOf) {
processedValue = value.valueOf(); //
} else if (value && typeof value === 'string') {
processedValue = new Date(value).getTime(); //
}
}
parameters[key] = processedValue;
} else {
parameters[key] = value;
}
}
});
} else {
// JSON
try {
parameters = JSON.parse(jsonContent.value);
} catch {
message.error('JSON格式错误请检查格式');
return;
}
}
//
const submitData = {
productKey: props.deviceInfo.productObj.productKey,
deviceKey: props.deviceInfo.deviceKey,
eventId: selectedEventId.value,
params: parameters,
};
//
parameterContent.value = JSON.stringify(submitData, null, 2);
//
console.log('触发事件:', submitData);
const result = await deviceOperateEvent(submitData);
submitResult.value = JSON.stringify(result, null, 2);
// message.success('');
} catch (error) {
message.error('事件触发失败');
console.error('触发错误:', error);
}
};
//
const handleSubmit = async () => {
//
await executeSubmit();
};
onMounted(() => {
//
if (props.eventList.length > 0 && !selectedEventId.value) {
selectedEventId.value = props.eventList[0].id;
currentEvent.value = props.eventList[0];
currentOutputs.value = props.eventList[0].outputs || [];
//
initializeSelectedRows();
//
initializeFormData();
//
generateDefaultContent();
}
});
</script>
<template>
<div class="event-simulation">
<div class="event-content">
<!-- 左侧事件列表 -->
<div class="event-sidebar">
<div class="sidebar-header">事件列表</div>
<div
v-for="evt in eventList"
:key="evt.id"
class="sidebar-item"
:class="{ active: selectedEventId === evt.id }"
@click="selectedEventId = evt.id"
>
<div class="event-name">{{ evt.name }}</div>
<div class="event-id">{{ evt.id }}</div>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="event-main">
<!-- 模式切换 -->
<div class="mode-tabs">
<Tabs v-model:active-key="currentMode">
<TabPane key="simple" tab="精简模式">
<div class="mode-info">
<ExclamationCircleOutlined />
精简模式下参数只支持输入框的方式录入
</div>
</TabPane>
<TabPane key="advanced" tab="高级模式">
<div class="mode-info">
<ExclamationCircleOutlined />
高级模式下支持JSON格式直接编辑
</div>
</TabPane>
</Tabs>
</div>
<div class="content-area">
<!-- 左侧参数输入 -->
<div class="input-section">
<div v-if="currentMode === 'simple'" class="simple-form">
<Form ref="formRef" :model="formData" layout="vertical">
<Table
:columns="columns"
:data-source="tableDataSource"
:row-selection="rowSelection"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span>{{ record.name }}</span>
</template>
<template v-else-if="column.key === 'value'">
<Form.Item
:name="record.id"
:rules="
record.required
? [
{
required: true,
message: `请输入${record.name}`,
},
]
: []
"
style="margin-bottom: 0"
>
<Input
v-if="record?.valueParams?.formType === 'input'"
v-model:value="formData[record.id]"
:maxlength="record?.valueParams?.length || null"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<InputNumber
v-else-if="record?.valueParams?.formType === 'number'"
v-model:value="formData[record.id]"
type="number"
:min="record?.valueParams?.min || null"
:max="record?.valueParams?.max || null"
:step="record?.valueParams?.step || 1"
:precision="record?.valueParams?.scale || 0"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<Slider
v-else-if="
record?.valueParams?.formType === 'progress'
"
v-model:value="formData[record.id]"
:min="record?.valueParams?.min || 0"
:max="record?.valueParams?.max || 100"
style="width: 99%"
/>
<Select
v-else-if="record?.valueParams?.formType === 'select'"
v-model:value="formData[record.id]"
:placeholder="`请选择${record.name}`"
style="width: 100%"
>
<SelectOption
v-for="option in record?.valueParams?.enumConf ||
[]"
:key="option.value"
:value="option.value"
>
{{ option.text }}
</SelectOption>
</Select>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'boolean'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
/>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'string'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
/>
<DatePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('YYYY-MM-DD')
"
v-model:value="formData[record.id]"
:show-time="
record?.valueParams?.format?.includes('HH:mm:ss')
"
:format="record?.valueParams?.format"
style="width: 100%"
/>
<TimePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('HH:mm:ss')
"
v-model:value="formData[record.id]"
:format="record?.valueParams?.format"
style="width: 100%"
/>
</Form.Item>
</template>
</template>
</Table>
</Form>
</div>
<div v-else class="advanced-form">
<MonacoEditor
v-model="jsonContent"
lang="json"
theme="vs-dark"
style="
height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
"
@update:model-value="handleJsonChange"
/>
</div>
<div class="execute-button">
<Button type="primary" @click="handleSubmit">触发事件</Button>
</div>
</div>
<!-- 右侧参数框和结果 -->
<div class="result-section">
<div class="parameter-box">
<div class="parameter-header">参数:</div>
<div class="parameter-content">
<pre>{{ parameterContent || '点击触发后显示参数' }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.event-simulation {
.event-content {
display: flex;
gap: 16px;
min-height: 600px;
.event-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
&:hover {
background: #e6f7ff;
}
&.active {
color: white;
background: #1890ff;
.event-id {
color: rgb(255 255 255 / 80%);
}
}
.event-name {
font-size: 14px;
font-weight: 500;
}
.event-id {
font-size: 12px;
color: #8c8c8c;
}
}
}
.event-main {
flex: 1;
.mode-tabs {
margin-bottom: 16px;
.mode-info {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
}
}
.content-area {
display: flex;
gap: 16px;
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
}
.ant-table-tbody > tr > td {
vertical-align: middle;
}
}
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
.execute-button {
margin: 10px;
margin-top: 15px;
text-align: right;
}
}
.result-section {
display: flex;
flex: 2;
flex-direction: column;
gap: 16px;
min-width: 300px;
min-height: 300px;
.parameter-box {
height: 100%;
}
.parameter-box,
.result-box {
padding: 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.parameter-header,
.result-header {
margin-bottom: 12px;
font-weight: 500;
color: #262626;
}
.parameter-content,
.result-content {
min-height: calc(100% - 32px);
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}
}
}
.result-box {
.result-content {
background: #f0f9ff;
border: 1px solid #91d5ff;
}
}
}
}
}
}
}
</style>

View File

@ -19,7 +19,9 @@ import {
Tabs,
TimePicker,
} from 'ant-design-vue';
import MD5 from 'crypto-js/md5';
import { deviceOperateFunc } from '#/api/device/device';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
import { dataTypeOptions } from '#/constants/dicts';
@ -112,9 +114,11 @@ const rowSelection = computed(() => ({
selectedRowKeys.value = selectedKeys;
},
onSelectAll: (selected: boolean) => {
selectedRowKeys.value = selected ? tableDataSource.value.map((row) => row.key) : tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
selectedRowKeys.value = selected
? tableDataSource.value.map((row) => row.key)
: tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
},
onSelect: (record: any, selected: boolean) => {
if (record.required && !selected) {
@ -168,7 +172,10 @@ const initializeFormData = () => {
// }
case 'switch': {
//
defaultData[input.id] = input.valueParams.dataType === 'boolean' ? false : input.valueParams.falseValue || 'false' ;
defaultData[input.id] =
input.valueParams.dataType === 'boolean'
? false
: input.valueParams.falseValue || 'false';
break;
}
default: {
@ -336,10 +343,13 @@ const executeSubmit = async (checkValue?: string) => {
//
let isEmpty = false;
isEmpty = formType === 'switch'
? value === undefined || value === null // if
: value === undefined || value === null || value === '' || // else
(typeof value === 'string' && value.trim() === '');
isEmpty =
formType === 'switch'
? value === undefined || value === null // if
: value === undefined ||
value === null ||
value === '' || // else
(typeof value === 'string' && value.trim() === '');
if (isEmpty) {
message.error(`请填写参数:${input.name}`);
@ -404,9 +414,10 @@ const executeSubmit = async (checkValue?: string) => {
//
const submitData: any = {
deviceId: props.deviceId,
functionId: selectedFunctionId.value,
parameter: parameters,
productKey: props.deviceInfo.productObj.productKey,
deviceKey: props.deviceInfo.deviceKey,
funcId: selectedFunctionId.value,
params: parameters,
};
//
@ -417,27 +428,33 @@ const executeSubmit = async (checkValue?: string) => {
//
parameterContent.value = JSON.stringify(submitData, null, 2);
await deviceOperateFunc(submitData);
// API
console.log('执行功能:', submitData);
console.log('选中的参数keys:', selectedRowKeys.value);
console.log('提交的参数对象:', parameters);
//
const mockResult = {
success: true,
message: '执行成功',
data: {
deviceId: props.deviceId,
functionId: selectedFunctionId.value,
executeTime: new Date().toISOString(),
result: 'OK',
},
};
// //
// const mockResult = {
// success: true,
// message: '',
// data: {
// deviceKey: props.deviceInfo.deviceKey,
// funcId: selectedFunctionId.value,
// executeTime: new Date().toISOString(),
// result: 'OK',
// },
// };
submitResult.value = JSON.stringify(mockResult, null, 2);
message.success('执行成功');
// submitResult.value = JSON.stringify(mockResult, null, 2);
// message.success('');
} catch (error) {
message.error('执行失败');
if (error.errorFields[0].errors[0]) {
message.error(error.errorFields[0].errors[0]);
} else {
message.error('执行失败');
}
console.error('执行错误:', error);
}
};
@ -463,7 +480,8 @@ const handlePreCheckConfirm = async () => {
// checkValue
switch (preCheckType.value) {
case 'staticPassword': {
checkValue = preCheckForm.value.password;
const hashResult = MD5(preCheckForm.value.password).toString(); // MD5
checkValue = hashResult;
break;
}
case 'userPassword': {
@ -613,6 +631,7 @@ onMounted(() => {
type="number"
:min="record?.valueParams?.min || null"
:max="record?.valueParams?.max || null"
:step="record?.valueParams?.step || 1"
:precision="record?.valueParams?.scale || 0"
:placeholder="`请输入${record.name}`"
style="width: 100%"
@ -624,6 +643,7 @@ onMounted(() => {
v-model:value="formData[record.id]"
:min="record?.valueParams?.min || 0"
:max="record?.valueParams?.max || 100"
:step="record?.valueParams?.step || 1"
style="width: 99%"
/>
<Select

View File

@ -0,0 +1,882 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import {
Button,
DatePicker,
Form,
Input,
InputNumber,
message,
Select,
SelectOption,
Slider,
Switch,
Table,
TabPane,
Tabs,
TimePicker,
} from 'ant-design-vue';
import { deviceOperateFunc, deviceOperateReport } from '#/api/device/device';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
import { dataTypeOptions } from '#/constants/dicts';
interface Props {
deviceId: string;
deviceInfo: any;
metadata: any;
}
const props = defineProps<Props>();
//
const selectedPropertyFunction = ref('report'); // report(), write(), read()
// simple() / advanced()
const currentMode = ref('simple');
//
const formRef = ref();
// JSON
const jsonContent = ref('{}');
//
const parameterContent = ref('');
//
const submitResult = ref('');
//
const selectedGroup = ref('all');
// 访
// const selectedTypes = ref(['R', 'RW']);
// - 使
// const currentProperty = ref();
//
const selectedRowKeys = ref([]);
//
const formData = ref({});
//
const propertyFunctionOptions = [
{ label: '上报', value: 'report' },
{ label: '写入', value: 'write' },
{ label: '读取', value: 'read' },
];
//
const groupOptions = computed(() => {
const groups = props.metadata?.propertyGroups || [];
return [
{ label: '全部', value: 'all' },
...groups.map((group: any) => ({ label: group.name, value: group.id })),
];
});
// -
// const typeOptions = [
// { label: '', value: 'R' },
// { label: '', value: 'W' },
// { label: '', value: 'RW' },
// ];
//
const filteredProperties = computed(() => {
let properties = [...(props.metadata?.properties || [])];
//
if (selectedGroup.value !== 'all') {
const group = (props.metadata?.propertyGroups || []).find(
(g: any) => g.id === selectedGroup.value,
);
if (group?.properties) {
const ids = new Set(group.properties.map((p: any) => p.id));
properties = properties.filter((p: any) => ids.has(p.id));
}
}
//
// properties = properties.filter((p: any) => {
// const type = p.expands?.type || 'R';
// return selectedTypes.value.includes(type);
// });
//
switch (selectedPropertyFunction.value) {
case 'read': {
//
properties = properties.filter((p: any) => {
const type = p.expands?.type || 'R';
return type === 'R' || type === 'RW';
});
break;
}
case 'report': {
//
break;
}
case 'write': {
//
properties = properties.filter((p: any) => {
const type = p.expands?.type || 'R';
return type === 'W' || type === 'RW';
});
break;
}
}
return properties;
});
//
const columns = [
{
title: '属性名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '数据类型',
dataIndex: 'dataType',
key: 'dataType',
width: 100,
},
// {
// title: '访',
// dataIndex: 'accessType',
// key: 'accessType',
// width: 100,
// },
{
title: '值',
dataIndex: 'value',
key: 'value',
},
];
//
const tableDataSource = computed(() => {
return filteredProperties.value.map((property) => ({
key: property.id,
id: property.id,
name: property.name,
dataType:
dataTypeOptions.find(
(option) => option.value === property.valueParams?.dataType,
)?.label || property.valueParams?.dataType,
accessType: (() => {
const type = property.expands?.type || 'R';
switch (type) {
case 'R': {
return '只读';
}
case 'RW': {
return '读写';
}
case 'W': {
return '只写';
}
default: {
return '只读';
}
}
})(),
required: property.required,
formType: property.valueParams?.formType,
valueParams: property.valueParams,
}));
});
//
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (selectedKeys: string[]) => {
selectedRowKeys.value = selectedKeys;
},
onSelectAll: (selected: boolean) => {
selectedRowKeys.value = selected
? tableDataSource.value.map((row) => row.key)
: tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
},
onSelect: (record: any, selected: boolean) => {
if (record.required && !selected) {
//
return;
}
selectedRowKeys.value = selected
? [...selectedRowKeys.value, record.key]
: selectedRowKeys.value.filter((key) => key !== record.key);
},
getCheckboxProps: (record: any) => ({
disabled: record.required, //
}),
}));
//
const initializeSelectedRows = () => {
console.log('初始化选中行', tableDataSource.value);
selectedRowKeys.value = tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
};
//
const initializeFormData = () => {
const defaultData = {};
tableDataSource.value.forEach((row) => {
const property = filteredProperties.value.find((p) => p.id === row.id);
if (!property) return;
const { formType } = property.valueParams;
//
switch (formType) {
case 'progress': {
//
defaultData[row.id] = property.valueParams?.min || 0;
break;
}
case 'switch': {
//
defaultData[row.id] =
property.valueParams?.dataType === 'boolean'
? false
: property.valueParams?.falseValue || 'false';
break;
}
default: {
// undefined
break;
}
}
});
formData.value = { ...defaultData };
};
//
watch(selectedPropertyFunction, () => {
resetForm();
initializeSelectedRows();
generateDefaultContent();
});
//
watch(currentMode, () => {
resetForm();
generateDefaultContent();
});
//
watch([selectedGroup], () => {
// filteredProperties
setTimeout(() => {
initializeSelectedRows();
initializeFormData();
generateDefaultContent();
}, 0);
});
//
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
formData.value = {};
submitResult.value = '';
};
//
const generateDefaultContent = () => {
if (filteredProperties.value.length === 0) return;
if (currentMode.value === 'advanced') {
// key:null
const params = {};
filteredProperties.value.forEach((property) => {
params[property.id] = null;
});
jsonContent.value = JSON.stringify(params, null, 2);
parameterContent.value = '';
} else {
//
parameterContent.value = '';
}
};
//
const executeSubmit = async () => {
try {
let parameters = {};
if (currentMode.value === 'simple') {
//
if (selectedRowKeys.value.length === 0) {
message.error('请至少选择一个属性');
return;
}
//
await formRef.value.validate();
const formValues = formData.value;
console.log('表单值:', formValues);
//
for (const row of tableDataSource.value) {
if (selectedRowKeys.value.includes(row.key)) {
const property = filteredProperties.value.find(
(p) => p.id === row.id,
);
if (!property) continue;
const value = formValues[row.id];
const { formType } = property.valueParams;
//
let isEmpty = false;
isEmpty =
formType === 'switch'
? value === undefined || value === null
: value === undefined ||
value === null ||
value === '' ||
(typeof value === 'string' && value.trim() === '');
if (isEmpty) {
message.error(`请填写属性:${row.name}`);
return;
}
}
}
// key:value
parameters = {};
selectedRowKeys.value.forEach((key) => {
const row = tableDataSource.value.find((r) => r.key === key);
if (!row) return;
const property = filteredProperties.value.find((p) => p.id === row.id);
if (!property) return;
const value = formValues[row.id];
const { dataType, formType } = property.valueParams;
let processedValue = value;
// dataTypeformType
if (formType === 'switch') {
if (dataType === 'boolean') {
processedValue = Boolean(value);
} else if (dataType === 'string') {
processedValue = value
? property.valueParams?.trueValue || 'true'
: property.valueParams?.falseValue || 'false';
}
} else if (formType === 'time' && dataType === 'date') {
if (value && typeof value === 'object' && value.valueOf) {
processedValue = value.valueOf();
} else if (value && typeof value === 'string') {
processedValue = new Date(value).getTime();
}
}
parameters[row.id] = processedValue;
});
} else {
// JSON
try {
parameters = JSON.parse(jsonContent.value);
} catch {
message.error('JSON格式错误请检查格式');
return;
}
}
//
const submitData: any = {
productKey: props.deviceInfo.productObj.productKey,
deviceKey: props.deviceInfo.deviceKey,
params: parameters,
};
if (selectedPropertyFunction.value === 'read') {
submitData.funcId = 'defaultRead';
} else if (selectedPropertyFunction.value === 'write') {
submitData.funcId = 'defaultWrite';
}
//
parameterContent.value = JSON.stringify(submitData, null, 2);
await (selectedPropertyFunction.value === 'report'
? deviceOperateReport(submitData)
: deviceOperateFunc(submitData));
// API
// console.log(':', submitData);
// console.log('keys:', selectedRowKeys.value);
// console.log(':', parameters);
//
// const mockResult = {
// success: true,
// message: '',
// data: {
// deviceKey: props.deviceInfo.deviceKey,
// executeTime: new Date().toISOString(),
// result: 'OK',
// },
// };
// submitResult.value = JSON.stringify(mockResult, null, 2);
// message.success('');
} catch (error) {
if (error.errorFields[0].errors[0]) {
message.error(error.errorFields[0].errors[0]);
} else {
message.error('执行失败');
}
console.error('执行错误:', error);
}
};
//
const handleSubmit = async () => {
//
await executeSubmit();
};
onMounted(() => {
//
if (propertyFunctionOptions.length > 0 && !selectedPropertyFunction.value) {
selectedPropertyFunction.value = propertyFunctionOptions[0].value;
}
// filteredProperties
setTimeout(() => {
initializeSelectedRows();
initializeFormData();
generateDefaultContent();
}, 0);
});
</script>
<template>
<div class="property-simulation">
<div class="property-content">
<!-- 左侧属性功能列表 -->
<div class="property-sidebar">
<div class="sidebar-header">属性功能</div>
<div
v-for="func in propertyFunctionOptions"
:key="func.value"
class="sidebar-item"
:class="{ active: selectedPropertyFunction === func.value }"
@click="selectedPropertyFunction = func.value"
>
<div class="function-name">{{ func.label }}</div>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="property-main">
<!-- 筛选控制面板 -->
<div class="control-panel">
<div class="control-item">
<span class="control-label">属性分组:</span>
<Select
v-model:value="selectedGroup"
:options="groupOptions"
style="width: 200px"
placeholder="请选择属性分组"
/>
</div>
<!-- 访问权限过滤 - 删除 -->
<!--
<div class="control-item">
<span class="control-label">访问权限:</span>
<Checkbox.Group v-model:value="selectedTypes">
<Checkbox value="RW">读写</Checkbox>
<Checkbox value="R">只读</Checkbox>
<Checkbox value="W">只写</Checkbox>
</Checkbox.Group>
</div>
-->
</div>
<!-- 模式切换 -->
<div class="mode-tabs">
<Tabs v-model:active-key="currentMode">
<TabPane key="simple" tab="精简模式">
<div class="mode-info">
<ExclamationCircleOutlined />
精简模式下属性只支持输入框的方式录入
</div>
</TabPane>
<TabPane key="advanced" tab="高级模式">
<div class="mode-info">
<ExclamationCircleOutlined />
高级模式下支持JSON格式直接编辑
</div>
</TabPane>
</Tabs>
</div>
<div class="content-area">
<!-- 左侧属性输入 -->
<div class="input-section">
<div v-if="currentMode === 'simple'" class="simple-form">
<Form ref="formRef" :model="formData" layout="vertical">
<Table
:columns="columns"
:data-source="tableDataSource"
:row-selection="rowSelection"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span>{{ record.name }}</span>
</template>
<template v-else-if="column.key === 'dataType'">
<span>{{ record.dataType }}</span>
</template>
<template v-else-if="column.key === 'accessType'">
<span>{{ record.accessType }}</span>
</template>
<template v-else-if="column.key === 'value'">
<Form.Item
:name="record.id"
:rules="
record.required
? [
{
required: true,
message: `请输入${record.name}`,
},
]
: []
"
style="margin-bottom: 0"
>
<Input
v-if="record?.valueParams?.formType === 'input'"
v-model:value="formData[record.id]"
:maxlength="record?.valueParams?.length || null"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<InputNumber
v-else-if="record?.valueParams?.formType === 'number'"
v-model:value="formData[record.id]"
type="number"
:min="record?.valueParams?.min || null"
:max="record?.valueParams?.max || null"
:step="record?.valueParams?.step || 1"
:precision="record?.valueParams?.scale || 0"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<Slider
v-else-if="
record?.valueParams?.formType === 'progress'
"
v-model:value="formData[record.id]"
:min="record?.valueParams?.min || 0"
:max="record?.valueParams?.max || 100"
:step="record?.valueParams?.step || 1"
style="width: 99%"
/>
<Select
v-else-if="record?.valueParams?.formType === 'select'"
v-model:value="formData[record.id]"
:placeholder="`请选择${record.name}`"
style="width: 100%"
>
<SelectOption
v-for="item in record.valueParams.enumConf"
:key="item.value"
:value="item.value"
>
{{ item.text }}
</SelectOption>
</Select>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'boolean'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
/>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'string'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:checked-value="
record?.valueParams?.trueValue || true
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
:un-checked-value="
record?.valueParams?.falseValue || false
"
/>
<DatePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('YYYY-MM-DD')
"
v-model:value="formData[record.id]"
:show-time="
record?.valueParams?.format?.includes('HH:mm:ss')
"
:format="record?.valueParams?.format"
:value-format="record?.valueParams?.format"
style="width: 100%"
/>
<TimePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('HH:mm:ss')
"
v-model:value="formData[record.id]"
:format="record?.valueParams?.format"
:value-format="record?.valueParams?.format"
style="width: 100%"
/>
</Form.Item>
</template>
</template>
</Table>
</Form>
</div>
<div v-else class="advanced-form">
<MonacoEditor
:model-value="jsonContent"
lang="json"
theme="vs-dark"
style="
height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
"
@update:model-value="(value) => (jsonContent = value)"
/>
</div>
<div class="execute-button">
<Button type="primary" @click="handleSubmit">执行</Button>
</div>
</div>
<!-- 右侧参数框 -->
<div class="result-section">
<div class="parameter-box">
<div class="parameter-header">参数:</div>
<div class="parameter-content">
<pre>{{ parameterContent || '点击执行后显示执行参数' }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.property-simulation {
.property-content {
display: flex;
gap: 16px;
min-height: 600px;
.property-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
&:hover {
background: #e6f7ff;
}
&.active {
color: white;
background: #1890ff;
}
.function-name {
font-size: 14px;
font-weight: 500;
}
}
}
.property-main {
flex: 1;
.control-panel {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
.control-item {
display: flex;
gap: 12px;
align-items: center;
.control-label {
font-weight: 500;
color: #262626;
white-space: nowrap;
}
}
}
.mode-tabs {
margin-bottom: 16px;
.mode-info {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
.info-icon {
font-weight: bold;
color: #1890ff;
}
}
}
.content-area {
display: flex;
gap: 16px;
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
}
.ant-table-tbody > tr > td {
vertical-align: middle;
}
}
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
.advanced-form {
.json-editor-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #8c8c8c;
text-align: center;
p {
margin: 8px 0 0;
font-size: 14px;
}
}
}
.execute-button {
margin: 10px;
margin-top: 15px;
text-align: right;
}
}
.result-section {
display: flex;
flex: 2;
flex-direction: column;
gap: 16px;
min-width: 300px;
min-height: 300px;
.parameter-box {
height: 100%;
padding: 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.parameter-header {
margin-bottom: 12px;
font-weight: 500;
color: #262626;
}
.parameter-content {
min-height: calc(100% - 32px);
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}
}
}
}
}
}
}
}
</style>

View File

@ -10,6 +10,7 @@ import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import { deviceStateOptions } from '#/constants/dicts';
// import { deviceUpdateStatus } from '#/api/device/device';
import { useDeviceStore } from '#/store/device';
import { getWebSocket } from '#/utils/websocket';
import BasicInfo from './components/BasicInfo.vue';
import DeviceSimulation from './components/DeviceSimulation.vue';
@ -24,10 +25,39 @@ const deviceId = computed(() => route.params.id as string);
const activeTab = computed(() => deviceStore.getTabActiveKey);
const currentDevice = computed(() => deviceStore.getCurrentDevice);
// WebSocket
let deviceStatusSubscription: any = null;
// 线
const subscribeDeviceStatus = () => {
if (!currentDevice.value) return;
const id = `device-status-${currentDevice.value.deviceKey}`;
const topic = `/device/${currentDevice.value.deviceKey}/status`;
deviceStatusSubscription = getWebSocket(id, topic, {
deviceId: currentDevice.value.deviceKey,
}).subscribe((data: any) => {
if (data.payload?.deviceKey === currentDevice.value.deviceKey) {
//
deviceStore.updateDeviceStatus(data.payload);
}
});
};
//
const unsubscribeDeviceStatus = () => {
if (deviceStatusSubscription) {
deviceStatusSubscription.unsubscribe();
deviceStatusSubscription = null;
}
};
//
const loadDeviceInfo = async () => {
try {
await deviceStore.getDetail(deviceId.value);
subscribeDeviceStatus();
} catch {
message.error('加载设备信息失败');
}
@ -41,7 +71,7 @@ const handleStatusChange = async (checked: boolean) => {
// enabled: checked,
// });
// await loadDeviceInfo();
console.log("checked",checked)
console.log('checked', checked);
} catch {
message.error('状态更新失败');
}
@ -64,12 +94,17 @@ const handleProductClick = () => {
router.push(`/device/product/detail/${currentDevice.value.productId}`);
};
const handleDeviceClick = () => {
router.push(`/device/device/detail/${currentDevice.value.parentId}`);
};
onMounted(() => {
loadDeviceInfo();
});
// store
// store
onUnmounted(() => {
unsubscribeDeviceStatus();
deviceStore.reset();
});
</script>
@ -139,6 +174,18 @@ onUnmounted(() => {
currentDevice?.productObj?.productName || '未知产品'
}}</a>
</div>
<div
class="basic-item"
v-if="
currentDevice.parentId &&
currentDevice.deviceType === 'childrenDevice'
"
>
<span class="basic-label">父设备:</span>
<a class="basic-value product-link" @click="handleDeviceClick">{{
currentDevice?.parentName || '未知设备'
}}</a>
</div>
</div>
<!-- 标签页内容 -->

View File

@ -137,6 +137,14 @@ const handleAddOutputParam = () => {
outputParamModalVisible.value = true;
};
//
const handleEditOutputParam = (record: any, index: number) => {
parameterDataIndex.value = index;
parameterType.value = 'edit';
outputParamForm.value = JSON.parse(JSON.stringify(record));
outputParamModalVisible.value = true;
};
//
const handleDeleteOutputParam = (index: number) => {
formData.value.outputs.splice(index, 1);
@ -197,7 +205,12 @@ watch(
</script>
<template>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -216,10 +229,18 @@ watch(
<Col :span="24">
<FormItem label="输出参数" name="outputs">
<div class="parameter-preview">
<Table :columns="outputColumns" :data-source="formData.outputs" :pagination="false">
<Table
:columns="outputColumns"
:data-source="formData.outputs"
:pagination="false"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">
{{ dataTypeOptions.find((item) => item.value === record.valueParams.dataType)?.label }}
{{
dataTypeOptions.find(
(item) => item.value === record.valueParams.dataType,
)?.label
}}
</template>
<template v-if="column.key === 'required'">
<Tag color="processing">
@ -227,12 +248,26 @@ watch(
</Tag>
</template>
<template v-if="column.key === 'formType'">
{{ formTypeOptions.find((item) => item.value === record.valueParams.formType)?.label }}
{{
formTypeOptions.find(
(item) => item.value === record.valueParams.formType,
)?.label
}}
</template>
<template v-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEditOutputParam(record, index)"> 编辑 </Button>
<Popconfirm placement="left" title="确认删除?" @confirm="handleDeleteOutputParam(index)">
<Button
type="link"
size="small"
@click="handleEditOutputParam(record, index)"
>
编辑
</Button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDeleteOutputParam(index)"
>
<Button type="link" size="small" danger> 删除 </Button>
</Popconfirm>
</Space>
@ -240,7 +275,11 @@ watch(
</template>
</Table>
<div class="add-button-container">
<a-button @click="handleAddOutputParam" type="primary" class="add-button">
<a-button
@click="handleAddOutputParam"
type="primary"
class="add-button"
>
<template #icon>
<PlusOutlined />
</template>
@ -253,13 +292,21 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
</FormItem>
</Col>
</Row>
@ -268,7 +315,9 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
确认
</Button>
</Space>
</template>

View File

@ -21,6 +21,8 @@ import {
Tag,
Textarea,
} from 'ant-design-vue';
// MD5
import MD5 from 'crypto-js/md5';
import { dataTypeOptions, formTypeOptions } from '#/constants/dicts';
@ -153,6 +155,17 @@ const formRules = {
message: '请输入以字母开头,只包含字母、数字和下划线的标识符',
trigger: 'blur',
},
{
validator: (_rule: any, value: string) => {
if (value === 'defaultWrite' || value === 'defaultRead') {
return Promise.reject(
new Error('标识符不能为 defaultWrite 或 defaultRead'),
);
}
return Promise.resolve();
},
trigger: 'blur',
},
],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
@ -200,7 +213,27 @@ const handleInputParamsConfirm = (params: any) => {
const handleSave = async () => {
try {
await formRef.value.validate();
//
if (
formData.value.expands.preCheck &&
formData.value.expands.checkType === 'staticPassword' &&
!formData.value.expands.staticPasswordValue
) {
message.error('请填写固定密码');
return;
}
saveLoading.value = true;
if (
formData.value.expands.preCheck &&
formData.value.expands.checkType === 'staticPassword' &&
formData.value.expands.staticPasswordValue
) {
// MD5
const hashResult = MD5(
formData.value.expands.staticPasswordValue,
).toString(); // MD5
formData.value.expands.staticPasswordValue = hashResult;
}
emit('save', { ...formData.value });
visible.value = false;
@ -220,7 +253,10 @@ const handleDrawerClose = () => {
//
const resetForm = () => {
Object.assign(formData.value, JSON.parse(JSON.stringify(defaultFunctionData)));
Object.assign(
formData.value,
JSON.parse(JSON.stringify(defaultFunctionData)),
);
};
//
@ -238,7 +274,12 @@ watch(
</script>
<template>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -255,10 +296,18 @@ watch(
<Col :span="24">
<FormItem label="输入参数" name="inputs">
<div class="parameter-preview">
<Table :columns="inputColumns" :data-source="formData.inputs" :pagination="false">
<Table
:columns="inputColumns"
:data-source="formData.inputs"
:pagination="false"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">
{{ dataTypeOptions.find((item) => item.value === record.valueParams.dataType)?.label }}
{{
dataTypeOptions.find(
(item) => item.value === record.valueParams.dataType,
)?.label
}}
</template>
<template v-if="column.key === 'required'">
<Tag color="processing">
@ -266,12 +315,26 @@ watch(
</Tag>
</template>
<template v-if="column.key === 'formType'">
{{ formTypeOptions.find((item) => item.value === record.valueParams.formType)?.label }}
{{
formTypeOptions.find(
(item) => item.value === record.valueParams.formType,
)?.label
}}
</template>
<template v-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEditInputParam(record, index)"> 编辑 </Button>
<Popconfirm placement="left" title="确认删除?" @confirm="handleDeleteInputParam(index)">
<Button
type="link"
size="small"
@click="handleEditInputParam(record, index)"
>
编辑
</Button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDeleteInputParam(index)"
>
<Button type="link" size="small" danger> 删除 </Button>
</Popconfirm>
</Space>
@ -279,7 +342,11 @@ watch(
</template>
</Table>
<div class="add-button-container">
<a-button @click="handleAddInputParam" type="primary" class="add-button">
<a-button
@click="handleAddInputParam"
type="primary"
class="add-button"
>
<template #icon>
<PlusOutlined />
</template>
@ -294,7 +361,11 @@ watch(
<Col :span="12">
<FormItem label="是否异步" name="async">
<RadioGroup v-model:value="formData.async" button-style="solid">
<RadioButton :value="item.value" v-for="item in asyncOptions" :key="item.value">
<RadioButton
:value="item.value"
v-for="item in asyncOptions"
:key="item.value"
>
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -303,8 +374,15 @@ watch(
<Col :span="12">
<FormItem label="是否开启前置校验" name="expands.preCheck">
<RadioGroup v-model:value="formData.expands.preCheck" button-style="solid">
<RadioButton :value="item.value" v-for="item in preCheckOptions" :key="item.value">
<RadioGroup
v-model:value="formData.expands.preCheck"
button-style="solid"
>
<RadioButton
:value="item.value"
v-for="item in preCheckOptions"
:key="item.value"
>
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -331,13 +409,21 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
</FormItem>
</Col>
</Row>
@ -346,7 +432,9 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
确认
</Button>
</Space>
</template>

View File

@ -1,7 +1,16 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
import { Button, Drawer, message, Modal, Space, TabPane, Tabs } from 'ant-design-vue';
import {
Button,
Drawer,
message,
Modal,
Space,
TabPane,
Tabs,
} from 'ant-design-vue';
import { productUpdateById } from '#/api/device/product';
@ -241,7 +250,9 @@ const handleSave = async () => {
metadata.propertyGroups = (metadata.propertyGroups || [])
.map((g: any) => {
const next = stripPkDeep(g);
next.properties = (next.properties || []).map((item) => stripPkDeep(item));
next.properties = (next.properties || []).map((item) =>
stripPkDeep(item),
);
return next;
})
.sort((a: any, b: any) => (a.sort ?? 0) - (b.sort ?? 0));
@ -288,25 +299,26 @@ const handleMetadataChange = (data: any) => {
};
//
// const handleBeforeRouteLeave = (to: any, from: any, next: any) => {
// if (metadataChanged.value) {
// Modal.confirm({
// title: '',
// content: '',
// onOk: () => next(),
// onCancel: () => next(false),
// });
// } else {
// next();
// }
// };
const handleBeforeRouteLeave = (next: any) => {
if (metadataChanged.value) {
Modal.confirm({
title: '提示',
content: '物模型有未保存的修改,确认离开吗?',
onOk: () => next(),
});
} else {
next();
}
};
//
watch(
currentMetadata,
() => {
if (originalMetadata.value) {
metadataChanged.value = JSON.stringify(currentMetadata.value) !== JSON.stringify(originalMetadata.value);
metadataChanged.value =
JSON.stringify(currentMetadata.value) !==
JSON.stringify(originalMetadata.value);
}
},
{ deep: true },
@ -314,6 +326,16 @@ watch(
//
loadMetadata();
onBeforeRouteUpdate((to, from, next) => {
//
handleBeforeRouteLeave(next as Function);
});
onBeforeRouteLeave((to, from, next) => {
//
handleBeforeRouteLeave(next as Function);
});
</script>
<template>
@ -321,16 +343,36 @@ loadMetadata();
<div class="metadata-header">
<div class="header-left">
<h3>物模型</h3>
<span class="desc"> 设备会默认继承产品的物模型继承的物模型不支持删改 </span>
<span class="desc">
设备会默认继承产品的物模型继承的物模型不支持删改
</span>
</div>
<div class="header-right">
<Space>
<Button v-if="metadataChanged" type="primary" @click="handleSave" v-access:code="['device:product:edit']">
<Button
v-if="metadataChanged"
type="primary"
@click="handleSave"
v-access:code="['device:product:edit']"
>
保存
</Button>
<Button v-if="showReset" @click="handleReset" v-access:code="['device:product:edit']"> 重置操作 </Button>
<Button @click="handleImport" v-access:code="['device:product:edit']"> 快速导入 </Button>
<Button @click="handleViewTSL" v-access:code="['device:product:edit']"> 物模型 </Button>
<Button
v-if="showReset"
@click="handleReset"
v-access:code="['device:product:edit']"
>
重置操作
</Button>
<Button @click="handleImport" v-access:code="['device:product:edit']">
快速导入
</Button>
<Button
@click="handleViewTSL"
v-access:code="['device:product:edit']"
>
物模型
</Button>
</Space>
</div>
</div>
@ -376,12 +418,22 @@ loadMetadata();
</Tabs>
<!-- 导入抽屉 -->
<Drawer v-model:open="importVisible" title="快速导入" width="600px" @close="handleImportClose">
<Drawer
v-model:open="importVisible"
title="快速导入"
width="600px"
@close="handleImportClose"
>
<ImportForm @success="handleImportSuccess" />
</Drawer>
<!-- TSL查看抽屉 -->
<Drawer v-model:open="tslVisible" title="物模型" width="800px" @close="handleTSLClose">
<Drawer
v-model:open="tslVisible"
title="物模型"
width="800px"
@close="handleTSLClose"
>
<TSLViewer
:product-id="productInfo.id"
:product-info="productInfo"

View File

@ -45,6 +45,7 @@ interface ParameterItem {
max?: number;
min?: number;
scale?: number;
step?: number;
trueText?: string;
trueValue?: string;
unit: string;
@ -447,6 +448,20 @@ watch(
</FormItem>
</Col>
</Row>
<FormItem
label="步长"
name="valueParams.step"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
>
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.step"
placeholder="请输入步长"
/>
</FormItem>
<FormItem
label="小数位"
name="valueParams.scale"

View File

@ -52,6 +52,7 @@ interface PropertyData {
max?: number;
min?: number;
scale?: number;
step?: number;
trueText?: string;
trueValue?: string;
unit: string;
@ -105,7 +106,12 @@ const filterFormTypeOptions = computed(() => {
}
case 'string': {
return formTypeOptions.filter((item) => {
return item.value === 'input' || item.value === 'switch' || item.value === 'select' || item.value === 'time';
return (
item.value === 'input' ||
item.value === 'switch' ||
item.value === 'select' ||
item.value === 'time'
);
});
}
default: {
@ -118,7 +124,9 @@ const filterTimeOptions = computed(() => {
switch (formData?.value?.valueParams?.dataType) {
case 'date': {
return timeOptions.filter((item) => {
return item.value === 'YYYY-MM-DD' || item.value === 'YYYY-MM-DD HH:mm:ss';
return (
item.value === 'YYYY-MM-DD' || item.value === 'YYYY-MM-DD HH:mm:ss'
);
});
}
default: {
@ -212,7 +220,10 @@ const handleDataTypeChange = (value: string) => {
//
const resetForm = () => {
Object.assign(formData.value, JSON.parse(JSON.stringify(defaultPropertyData)));
Object.assign(
formData.value,
JSON.parse(JSON.stringify(defaultPropertyData)),
);
};
//
@ -275,7 +286,12 @@ watch(
</script>
<template>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -323,7 +339,13 @@ watch(
<Col :span="24" v-if="formData.valueParams.formType === 'switch'">
<Row
:gutter="24"
style="padding-top: 16px; margin: 0; margin-bottom: 16px; border: 1px solid #f0f0f0; border-radius: 4px"
style="
padding-top: 16px;
margin: 0;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 4px;
"
>
<Col :span="12">
<FormItem label="开关打开名称" name="valueParams.trueText">
@ -368,9 +390,14 @@ watch(
<Col :span="24" v-if="formData.valueParams.formType === 'select'">
<FormItem label="枚举列表" name="valueParams.enumConf">
<div class="enum-preview">
<a-button type="primary" @click="handleEditEnum"> 编辑枚举列表 </a-button>
<a-button type="primary" @click="handleEditEnum">
编辑枚举列表
</a-button>
<Descriptions
v-if="formData.valueParams.enumConf && formData.valueParams.enumConf.length > 0"
v-if="
formData.valueParams.enumConf &&
formData.valueParams.enumConf.length > 0
"
:column="4"
bordered
title=" "
@ -390,14 +417,20 @@ watch(
<!-- 数字类型配置 -->
<Col
:span="12"
v-if="formData.valueParams.formType === 'number' || formData.valueParams.formType === 'progress'"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
>
<FormItem label="最小值" name="valueParams.min">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.min"
:precision="
formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float' ? undefined : 0
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
? undefined
: 0
"
placeholder="请输入最小值"
/>
@ -405,19 +438,41 @@ watch(
</Col>
<Col
:span="12"
v-if="formData.valueParams.formType === 'number' || formData.valueParams.formType === 'progress'"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
>
<FormItem label="最大值" name="valueParams.max">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.max"
:precision="
formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float' ? undefined : 0
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
? undefined
: 0
"
placeholder="请输入最大值"
/>
</FormItem>
</Col>
<Col
:span="12"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
>
<FormItem label="步长" name="valueParams.step">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.step"
placeholder="请输入步长"
/>
</FormItem>
</Col>
<Col :span="12" v-if="formData.valueParams.formType === 'input'">
<FormItem label="长度" name="valueParams.length">
<InputNumber
@ -428,14 +483,27 @@ watch(
/>
</FormItem>
</Col>
<Col :span="12" v-if="formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float'">
<Col
:span="12"
v-if="
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
"
>
<FormItem label="小数位" name="valueParams.scale">
<InputNumber style="width: 100%" v-model:value="formData.valueParams.scale" placeholder="请输入小数位" />
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.scale"
placeholder="请输入小数位"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="单位" name="valueParams.unit">
<Input v-model:value="formData.valueParams.unit" placeholder="请输入单位" />
<Input
v-model:value="formData.valueParams.unit"
placeholder="请输入单位"
/>
</FormItem>
</Col>
<Col :span="12" v-if="formData.valueParams.formType === 'time'">
@ -456,8 +524,15 @@ watch(
</Col>
<Col :span="12">
<FormItem label="读写类型" name="type">
<RadioGroup v-model:value="formData.expands.type" button-style="solid">
<RadioButton :value="item.value" v-for="item in readWriteTypeOptions" :key="item.value">
<RadioGroup
v-model:value="formData.expands.type"
button-style="solid"
>
<RadioButton
:value="item.value"
v-for="item in readWriteTypeOptions"
:key="item.value"
>
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -466,13 +541,21 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
</FormItem>
</Col>
</Row>
@ -481,7 +564,9 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
确认
</Button>
</Space>
</template>

View File

@ -7,7 +7,10 @@ import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Modal, Switch, TabPane, Tabs } from 'ant-design-vue';
import { productPushMetadataById, productUpdateStatus } from '#/api/device/product';
import {
productPushMetadataById,
productUpdateStatus,
} from '#/api/device/product';
import { useProductStore } from '#/store/product';
import BasicInfo from './components/BasicInfo.vue';
@ -145,15 +148,29 @@ onUnmounted(() => {
</div>
<!-- 标签页内容 -->
<Tabs v-model:active-key="activeTab" class="detail-tabs" @change="handleTabChange">
<Tabs
v-model:active-key="activeTab"
class="detail-tabs"
@change="handleTabChange"
>
<TabPane key="BasicInfo" tab="配置信息">
<BasicInfo :product-info="currentProduct" @refresh="loadProductInfo" />
<BasicInfo
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
</TabPane>
<TabPane key="Metadata" tab="物模型">
<Metadata :product-id="productId" :product-info="currentProduct" @refresh="loadProductInfo" />
<Metadata
:product-id="productId"
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
</TabPane>
<TabPane key="DeviceAccess" tab="设备接入">
<DeviceAccess :product-info="currentProduct" @refresh="loadProductInfo" />
<DeviceAccess
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
</TabPane>
</Tabs>
</div>