feat: 添加设备上下线模拟、产品json导入功能

- 新增设备上下线模拟接口和相关组件
- 实现设备状态模拟和高级模式下的 JSON 编辑
- 优化设备详情页面布局,添加设备图片显示
- 修复 websocket 取消订阅时的消息格式问题
This commit is contained in:
fhysy 2025-08-29 11:59:43 +08:00
parent a465fa498a
commit f0a666fb1d
13 changed files with 820 additions and 19 deletions

View File

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

View File

@ -89,3 +89,12 @@ export function deviceOperateFunc(data: any) {
export function deviceOperateEvent(data: any) {
return requestClient.postWithMsg<void>('/device/operate/mockEvent', data);
}
/**
* 线
* @param data
* @returns void
*/
export function deviceOperateStatus(data: any) {
return requestClient.postWithMsg<void>('/device/operate/mockStatus', data);
}

View File

@ -171,7 +171,7 @@ export const getWebSocket = (
}
return () => {
const unsub = JSON.stringify({ id, type: 'unsub' });
const unsub = JSON.stringify({ id, topic, type: 'unsub' });
const list = subs[id];
if (Array.isArray(list)) {
const idx = list.indexOf(handle);

View File

@ -5,6 +5,7 @@ import { TabPane, Tabs } from 'ant-design-vue';
import EventSimulation from './simulation/EventSimulation.vue';
import FunctionSimulation from './simulation/FunctionSimulation.vue';
import OnlineOfflineSimulation from './simulation/OnlineOfflineSimulation.vue';
import PropertySimulation from './simulation/PropertySimulation.vue';
interface Props {
@ -85,7 +86,10 @@ const eventList = computed(() => {
/>
</TabPane>
<TabPane key="online" tab="上下线">
<div>上下线功能开发中...</div>
<OnlineOfflineSimulation
:device-id="deviceId"
:device-info="deviceInfo"
/>
</TabPane>
</Tabs>
</div>

View File

@ -395,7 +395,7 @@ onMounted(() => {
<TabPane key="simple" tab="精简模式">
<div class="mode-info">
<ExclamationCircleOutlined />
精简模式下参数只支持输入框的方式录入
精简模式下参数支持表单的方式录入
</div>
</TabPane>
<TabPane key="advanced" tab="高级模式">
@ -550,7 +550,7 @@ onMounted(() => {
</div>
<div class="execute-button">
<Button type="primary" @click="handleSubmit">触发事件</Button>
<Button type="primary" @click="handleSubmit">执行</Button>
</div>
</div>

View File

@ -574,7 +574,7 @@ onMounted(() => {
<TabPane key="simple" tab="精简模式">
<div class="mode-info">
<ExclamationCircleOutlined />
精简模式下参数只支持输入框的方式录入
精简模式下参数支持表单的方式录入
</div>
</TabPane>
<TabPane key="advanced" tab="高级模式">

View File

@ -0,0 +1,414 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import {
Button,
Form,
message,
Select,
SelectOption,
Table,
TabPane,
Tabs,
} from 'ant-design-vue';
import { deviceOperateStatus } from '#/api/device/device';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
interface Props {
deviceId: string;
deviceInfo: any;
}
const props = defineProps<Props>();
// simple() / advanced()
const currentMode = ref('simple');
//
const formRef = ref();
// JSON
const jsonContent = ref('{}');
//
const parameterContent = ref('');
//
const submitResult = ref('');
//
const selectedRowKeys = ref(['status']);
//
const formData = ref({
status: '1', // 线
});
//
const statusParameter = {
id: 'status',
name: '状态',
required: true,
valueParams: {
dataType: 'string',
formType: 'select',
enumConf: [
{ value: '1', text: '上线' },
{ value: '0', text: '下线' },
],
},
};
//
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 [
{
key: statusParameter.id,
id: statusParameter.id,
name: statusParameter.name,
dataType: 'string',
required: statusParameter.required,
formType: statusParameter.valueParams.formType,
valueParams: statusParameter.valueParams,
},
];
});
//
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (selectedKeys: string[]) => {
//
if (!selectedKeys.includes('status')) {
selectedKeys.push('status');
}
selectedRowKeys.value = selectedKeys;
},
getCheckboxProps: (record: any) => ({
disabled: record.id === 'status', //
}),
}));
//
watch(currentMode, () => {
resetForm();
generateDefaultContent();
});
//
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
formData.value = {
status: '1',
};
submitResult.value = '';
};
//
const generateDefaultContent = () => {
if (currentMode.value === 'advanced') {
// key:null
const params = {
status: 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') {
//
await formRef.value.validate();
const formValues = formData.value;
console.log('表单值:', formValues);
//
if (!formValues.status) {
message.error('请选择状态');
return;
}
parameters = {
status: formValues.status,
};
} else {
// JSON
try {
parameters = JSON.parse(jsonContent.value);
} catch {
message.error('JSON格式错误请检查格式');
return;
}
}
//
const submitData = {
productKey: props.deviceInfo.productObj.productKey,
deviceKey: props.deviceInfo.deviceKey,
...parameters,
};
//
parameterContent.value = JSON.stringify(submitData, null, 2);
//
console.log('设备上下线:', submitData);
const result = await deviceOperateStatus(submitData);
submitResult.value = JSON.stringify(result, null, 2);
} catch (error) {
message.error('操作失败');
console.error('操作错误:', error);
}
};
//
const handleSubmit = async () => {
//
await executeSubmit();
};
onMounted(() => {
//
formData.value = {
status: '1',
};
selectedRowKeys.value = ['status'];
generateDefaultContent();
});
</script>
<template>
<div class="online-offline-simulation">
<div class="simulation-content">
<!-- 模式切换 -->
<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"
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="[
{
required: true,
message: `请选择${record.name}`,
},
]"
style="margin-bottom: 0"
>
<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>
</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>
</template>
<style lang="scss" scoped>
.online-offline-simulation {
.simulation-content {
min-height: 600px;
.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;
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-direction: column;
gap: 16px;
width: 300px;
.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: 120px;
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

@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import { Image, message, Switch, TabPane, Tabs } from 'ant-design-vue';
import QrcodeVue from 'qrcode.vue';
import { deviceStateOptions } from '#/constants/dicts';
// import { deviceUpdateStatus } from '#/api/device/device';
@ -32,11 +33,13 @@ let deviceStatusSubscription: any = null;
const subscribeDeviceStatus = () => {
if (!currentDevice.value) return;
const productKey = currentDevice.value.productObj.productKey;
const id = `device-status-${currentDevice.value.deviceKey}`;
const topic = `/device/${currentDevice.value.deviceKey}/status`;
const topic = `/device/${productKey}/status`;
deviceStatusSubscription = getWebSocket(id, topic, {
deviceId: currentDevice.value.deviceKey,
deviceKey: currentDevice.value.deviceKey,
}).subscribe((data: any) => {
if (data.payload?.deviceKey === currentDevice.value.deviceKey) {
//
@ -152,6 +155,16 @@ onUnmounted(() => {
</div>
</div>
<div class="header-right">
<QrcodeVue
:value="currentDevice.deviceKey"
:image-settings="{
src: currentDevice.imgUrl,
height: 20,
width: 20,
}"
:size="80"
level="H"
/>
<!-- <a-button
type="primary"
@click="handleStatusChange(!(currentDevice.enabled === '1'))"
@ -164,6 +177,9 @@ onUnmounted(() => {
<!-- 设备基本信息 -->
<div class="device-basic">
<div class="basic-item" v-if="currentDevice.imgUrl">
<Image :width="55" :src="currentDevice.imgUrl" />
</div>
<div class="basic-item">
<span class="basic-label">ID:</span>
<span class="basic-value">{{ deviceId }}</span>
@ -221,6 +237,7 @@ onUnmounted(() => {
background: #fff;
.detail-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@ -290,12 +307,23 @@ onUnmounted(() => {
}
}
}
.header-right {
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
}
}
.device-basic {
display: flex;
gap: 24px;
padding: 16px 0;
padding: 0;
.basic-item {
display: flex;

View File

@ -91,7 +91,7 @@ const outputColumns = ref([
{
title: '操作',
key: 'action',
width: 100,
width: 120,
fixed: 'right',
},
]);
@ -233,6 +233,7 @@ watch(
:columns="outputColumns"
:data-source="formData.outputs"
:pagination="false"
:scroll="{ y: 320 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">

View File

@ -110,6 +110,7 @@ const inputColumns = ref([
dataIndex: 'required',
key: 'required',
scopedSlots: { customRender: 'required' },
width: 90,
},
{
title: '表单类型',
@ -120,8 +121,7 @@ const inputColumns = ref([
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
width: 120,
},
]);
@ -300,6 +300,7 @@ watch(
:columns="inputColumns"
:data-source="formData.inputs"
:pagination="false"
:scroll="{ y: 280 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">

View File

@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import {
Button,
Form,
FormItem,
message,
Modal,
Space,
Upload,
} from 'ant-design-vue';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
const emit = defineEmits<{
change: [];
success: [];
}>();
//
defineExpose({});
const formRef = ref();
const importLoading = ref(false);
const formData = reactive({
script: `{
"properties": [],
"functions": [],
"events": [],
"propertyGroups": []
}`,
options: ['validate'],
});
const formRules = {
file: [{ required: true, message: '请选择文件' }],
script: [{ required: true, message: '请输入脚本内容' }],
};
//
const canImport = computed(() => {
return formData.script.trim() !== '';
});
//
const handleFileImport = async (file: File) => {
const isValidType = /\.json$/.test(file.name);
if (!isValidType) {
message.error('只支持 JSON 格式的文件');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('文件大小不能超过 2MB');
return false;
}
try {
const content = await readFileContent(file);
if (handleScriptValidate(content)) {
Modal.confirm({
title: '确认导入',
content: '导入文件将覆盖当前脚本内容,是否继续?',
onOk: () => {
//
formData.script = content;
},
});
} else {
message.error('当前脚本内容验证未通过,请修正后再导入');
return false;
}
//
} catch {
message.error('文件读取失败');
}
return false; //
};
const checkJsonScript = () => {
if (handleScriptValidate(formData.script)) {
message.success('脚本验证成功');
} else {
message.error('当前脚本内容验证未通过,请修正后再导入');
}
};
//
const handleScriptValidate = (content: any) => {
try {
parseFileContent(content, 'script.json');
return true;
} catch {
return false;
}
};
//
const readFileContent = (file: File): Promise<string> => {
return file.text();
};
//
const parseFileContent = (content: string, fileName: string) => {
try {
if (fileName.endsWith('.json')) {
const data = JSON.parse(content);
// JSON
if (!data || typeof data !== 'object') {
throw new Error('JSON格式不正确');
}
//
const requiredFields = [
'properties',
'functions',
'events',
'propertyGroups',
];
const hasValidStructure = requiredFields.every(
(field) => Array.isArray(data[field]) && data[field].length >= 0,
);
if (!hasValidStructure) {
throw new Error('物模型JSON格式不正确缺少必要的属性、功能或事件字段');
}
return {
properties: data.properties || [],
functions: data.functions || [],
events: data.events || [],
propertyGroups: data.propertyGroups || [],
};
}
throw new Error('不支持的文件格式');
} catch (error) {
if (error instanceof SyntaxError) {
throw new TypeError('JSON格式错误请检查文件内容');
}
throw error;
}
};
// const handleJsonChange = (value: string) => {
// formData.script = value;
// };
//
const handleImport = async () => {
try {
const metaDataObj = JSON.parse(formData.script);
await formRef.value.validate();
if (handleScriptValidate(formData.script)) {
//
Modal.confirm({
title: '确认覆盖物模型',
content: '将覆盖当前产品的物模型,是否继续?',
onOk: () => {
importLoading.value = true;
emit('change', metaDataObj);
emit('success');
importLoading.value = false;
},
});
} else {
message.error('当前脚本内容验证未通过,请修正后再覆盖');
}
// API
// const res = await importMetadata({
// type: formData.importType,
// data: formData,
// options: formData.options,
// });
// //
// await new Promise((resolve) => setTimeout(resolve, 2000));
// message.success('');
// emit('success');
} catch {
message.error('导入失败');
} finally {
importLoading.value = false;
}
};
//
const handleCancel = () => {
emit('success');
};
</script>
<template>
<div class="import-form">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<!-- 物模型脚本编辑器 -->
<FormItem label="物模型脚本" name="script">
<div class="script-header">
<Space>
<Upload
:show-upload-list="false"
:before-upload="handleFileImport"
:custom-request="() => {}"
accept=".json"
>
<Button>
<template #icon>
<UploadOutlined />
</template>
导入JSON文件
</Button>
</Upload>
<Button @click="checkJsonScript"> 验证脚本 </Button>
</Space>
</div>
<div class="script-editor">
<MonacoEditor
v-model="formData.script"
lang="json"
theme="vs"
style="height: 400px; border: 1px solid #d9d9d9; border-radius: 6px"
/>
</div>
</FormItem>
</Form>
<div class="form-actions">
<Space>
<Button @click="handleCancel">取消</Button>
<Button
type="primary"
@click="handleImport"
:loading="importLoading"
:disabled="!canImport"
>
覆盖物模型
</Button>
</Space>
</div>
</div>
</template>
<style lang="scss" scoped>
.import-form {
.upload-tip {
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
}
.preview-area {
overflow: hidden;
border: 1px solid #d9d9d9;
border-radius: 6px;
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #d9d9d9;
}
.preview-tips {
padding: 12px 16px;
font-size: 14px;
line-height: 1.5;
color: #666;
background: #f0f9ff;
border-bottom: 1px solid #d9d9d9;
}
.preview-content {
border-bottom: 1px solid #d9d9d9;
}
.category-preview {
padding: 16px;
background: #fff;
:deep(.ant-tabs) {
.ant-tabs-tab {
padding: 8px 16px;
}
.ant-tabs-content-holder {
padding-top: 16px;
}
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
}
.ant-table-tbody > tr > td {
padding: 8px 12px;
}
}
}
}
.script-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.script-editor {
overflow: hidden;
border: 1px solid #d9d9d9;
border-radius: 6px;
}
.form-actions {
margin-top: 24px;
text-align: right;
}
}
</style>

View File

@ -87,8 +87,6 @@ const handleViewTSL = () => {
//
const handleImportSuccess = () => {
importVisible.value = false;
loadMetadata();
message.success('导入成功');
};
//
@ -298,6 +296,12 @@ const handleMetadataChange = (data: any) => {
}
};
//
const handleMetadataImport = (data: any) => {
console.log('data', data);
currentMetadata.value = data;
};
//
const handleBeforeRouteLeave = (next: any) => {
if (metadataChanged.value) {
@ -424,7 +428,10 @@ onBeforeRouteLeave((to, from, next) => {
width="600px"
@close="handleImportClose"
>
<ImportForm @success="handleImportSuccess" />
<ImportForm
@change="handleMetadataImport"
@success="handleImportSuccess"
/>
</Drawer>
<!-- TSL查看抽屉 -->

View File

@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Modal, Switch, TabPane, Tabs } from 'ant-design-vue';
import { Image, message, Modal, Switch, TabPane, Tabs } from 'ant-design-vue';
import {
productPushMetadataById,
@ -144,7 +144,12 @@ onUnmounted(() => {
<!-- 产品统计信息 -->
<div class="product-stats">
<span>设备数量</span><a @click="jumpToDevices">{{ deviceCount }}</a>
<div class="basic-item" v-if="currentProduct.imgUrl">
<Image :width="55" :src="currentProduct.imgUrl" />
</div>
<div class="basic-item">
<span>设备数量</span><a @click="jumpToDevices">{{ deviceCount }}</a>
</div>
</div>
<!-- 标签页内容 -->
@ -218,7 +223,9 @@ onUnmounted(() => {
}
.product-stats {
padding: 10px 0;
display: flex;
gap: 24px;
align-items: center;
}
.detail-tabs {