feat: 实现设备模拟功能并优化相关页面
- 新增设备模拟相关的 API 接口 - 实现设备属性上报、功能下发和事件上报 - 优化设备详情页面,增加实时数据订阅和设备状态订阅 - 改进物模型编辑页面,支持步长设置和固定密码校验 - 重构部分组件以支持新功能
This commit is contained in:
parent
b2a8aa545e
commit
a465fa498a
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -55,7 +55,7 @@ export const columns: VxeGridProps['columns'] = [
|
|||
},
|
||||
{
|
||||
title: '所属产品',
|
||||
field: 'productName',
|
||||
field: 'productObj.productName',
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
// 根据dataType和formType处理数据类型转换
|
||||
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>
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
// 根据dataType和formType处理数据类型转换
|
||||
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>
|
|
@ -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>
|
||||
|
||||
<!-- 标签页内容 -->
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue