feat: 新增物模型查看组件并优化产品详情页面

- 新增 MonacoEditor 组件用于代码编辑
- 新增 TSLViewer 组件用于查看物模型
- 更新 BasicInfo、DeviceAccess 和 Metadata 组件的显示逻辑
- 调整 MetadataTable 组件的列宽
- 在 package.json 中添加 monaco-editor 依赖
- 在 vite.config.mts 中添加 monaco-editor 插件配置
This commit is contained in:
fhysy 2025-08-18 16:11:58 +08:00
parent 0c0a1994b2
commit 60e3f4fc73
11 changed files with 345 additions and 23 deletions

View File

@ -50,9 +50,11 @@
"echarts": "^5.5.1",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21",
"monaco-editor": "^0.52.2",
"pinia": "catalog:",
"tinymce": "^7.3.0",
"unplugin-vue-components": "^0.27.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vue": "catalog:",
"vue-router": "catalog:",
"vue3-colorpicker": "^2.3.0"

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted, ref, watch, watchEffect } from 'vue';
import * as monaco from 'monaco-editor';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
theme: { type: String, default: 'vs-dark' },
language: { type: String, default: 'json' },
});
const emit = defineEmits(['update:modelValue']);
const dom = ref();
let instance;
onMounted(() => {
const _model = monaco.editor.createModel(props.modelValue, props.language);
instance = monaco.editor.create(dom.value, {
model: _model,
tabSize: 2,
automaticLayout: true,
scrollBeyondLastLine: false,
theme: props.theme, // : vs(), vs-dark(), hc-black()
formatOnPaste: true,
});
instance.onDidChangeModelContent(() => {
const value = instance.getValue();
emit('update:modelValue', value);
});
});
/**
* 代码格式化
*/
const editorFormat = () => {
if (!instance) return;
instance.getAction('editor.action.formatDocument')?.run();
};
watchEffect(() => {
setTimeout(() => {
editorFormat();
}, 300);
});
/**
* 光标位置插入内容
* @param {string} val
*/
const insert = (val) => {
if (!instance) return;
const position = instance.getPosition();
instance.executeEdits(instance.getValue(), [
{
range: new monaco.Range(
position?.lineNumber,
position?.column,
position?.lineNumber,
position?.column,
),
text: val,
},
]);
};
watch(
() => props.modelValue,
(val) => {
if (!instance) return;
// setValue
const position = instance.getPosition();
// setValue
instance.setValue(val);
// setValue
instance.setPosition(position);
},
);
defineExpose({
editorFormat,
insert,
});
</script>
<template>
<div class="editor" ref="dom"></div>
</template>
<style scoped lang="scss">
.editor {
height: 100%;
}
</style>

View File

@ -114,13 +114,13 @@ const productParams = computed(() =>
)?.label
}}
</DescriptionsItem>
<DescriptionsItem label="接入方式">
<DescriptionsItem label="接入网关">
<Button
type="link"
@click="handleAccessConfig"
v-access:code="['device:product:edit']"
>
{{ productInfo.provider || '配置接入方式' }}
{{ productInfo.gatewayName || '配置接入网关' }}
</Button>
</DescriptionsItem>
<DescriptionsItem label="创建时间">

View File

@ -86,10 +86,10 @@ const tableColumns = computed(() => {
});
// MarkdownHTML
const markdownToHtml = computed(() => {
// 使markdown-itmarkdownHTML
return accessInfo.value.document || '';
});
// const markdownToHtml = computed(() => {
// // 使markdown-itmarkdownHTML
// return accessInfo.value.document || '';
// });
//
const handleSelectAccess = () => {
@ -141,7 +141,8 @@ const handleSave = async () => {
saveLoading.value = true;
await productUpdateById(props.productInfo.id, {
id: props.productInfo.id,
provider: accessInfo.value.id,
provider: accessInfo.value.provider,
gatewayId: accessInfo.value.id,
storePolicy: selectedStorePolicy.value,
protocolConf: JSON.stringify(formData),
});
@ -165,9 +166,9 @@ const loadStorePolicy = async () => {
//
const loadAccessInfo = async () => {
if (props.productInfo.provider) {
if (props.productInfo.gatewayId) {
// API
const res = await gatewayInfo(props.productInfo.provider);
const res = await gatewayInfo(props.productInfo.gatewayId);
accessInfo.value = res;
//
@ -214,7 +215,7 @@ onMounted(() => {
<a-button type="link" @click="handleSelectAccess">选择</a-button>
设备接入网关用以提供设备接入能力
</span>
<span v-else>请联系管理员配置产品接入方式</span>
<span v-else>请联系管理员配置产品接入网关</span>
</template>
</Empty>
</div>
@ -226,7 +227,7 @@ onMounted(() => {
<!-- 接入方式 -->
<div class="config-section">
<div class="section-header">
<h4>接入方式</h4>
<h4>接入网关</h4>
<a-button
type="primary"
size="small"
@ -247,13 +248,14 @@ onMounted(() => {
<div class="config-section">
<div class="section-header">
<h4>消息协议</h4>
<Tooltip title="此配置来自于产品接入方式所选择的协议">
<Tooltip title="此配置来自于产品接入网关所选择的协议">
<QuestionCircleOutlined />
</Tooltip>
</div>
<div class="section-content">
<p>{{ accessInfo.protocolName }}</p>
<div v-if="accessInfo.document" v-html="markdownToHtml"></div>
<!-- <div v-if="accessInfo.document" v-html="markdownToHtml"></div>-->
<div>{{markdownToHtml}}</div>
</div>
</div>
@ -311,7 +313,7 @@ onMounted(() => {
>
<div class="section-header">
<h4>{{ config.name }}</h4>
<Tooltip title="此配置来自于产品接入方式所选择的协议">
<Tooltip title="此配置来自于产品接入网关所选择的协议">
<QuestionCircleOutlined />
</Tooltip>
</div>
@ -453,7 +455,7 @@ onMounted(() => {
<!-- 选择接入方式抽屉 -->
<Modal
:open="accessModalVisible"
title="选择接入方式"
title="选择设备接入网关"
width="1000px"
centered
@cancel="handleAccessModalClose"

View File

@ -307,7 +307,7 @@ watch(
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
保存
确认
</Button>
</Space>
</template>

View File

@ -26,9 +26,17 @@ const emit = defineEmits<{
refresh: [];
}>();
const defaultMetadata = {
properties: [],
functions: [],
events: [],
propertyGroups: [], //
};
const activeTab = ref('properties');
const importVisible = ref(false);
const tslVisible = ref(false);
const tslMetadata = ref<any>();
const showReset = ref(false);
//
@ -71,6 +79,7 @@ const handleImport = () => {
// TSL
const handleViewTSL = () => {
tslMetadata.value = JSON.parse(JSON.stringify(currentMetadata.value));
tslVisible.value = true;
};
@ -88,6 +97,7 @@ const handleImportClose = () => {
// TSL
const handleTSLClose = () => {
tslMetadata.value = JSON.parse(JSON.stringify(defaultMetadata));
tslVisible.value = false;
};
@ -220,7 +230,7 @@ const handleSave = async () => {
metadata: JSON.stringify(metadata),
});
message.success('保存成功');
// message.success('');
//
originalMetadata.value = JSON.parse(JSON.stringify(metadata));
metadataChanged.value = false;
@ -320,7 +330,7 @@ loadMetadata();
@click="handleViewTSL"
v-access:code="['device:product:edit']"
>
物模型TSL
物模型
</Button>
</Space>
</div>
@ -379,11 +389,15 @@ loadMetadata();
<!-- TSL查看抽屉 -->
<Drawer
v-model:open="tslVisible"
title="物模型TSL"
title="物模型"
width="800px"
@close="handleTSLClose"
>
<TSLViewer :product-id="productInfo.id" />
<TSLViewer
:product-id="productInfo.id"
:product-info="productInfo"
:metadata="tslMetadata"
/>
</Drawer>
</div>
</template>

View File

@ -91,7 +91,7 @@ const columns = computed(() => {
dataIndex: 'dataType',
key: 'dataType',
align: 'center',
width: 100,
width: 150,
},
{
title: '读写类型',

View File

@ -431,7 +431,7 @@ watch(
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
保存
确认
</Button>
</Space>
</template>

View File

@ -257,7 +257,7 @@ const handleClose = () => {
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="saveLoading" @click="handleSave">
</Button>
</Space>
</template>

View File

@ -0,0 +1,205 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
CopyOutlined,
DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { message, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
const props = defineProps<{
metadata: object;
productId: string;
productInfo: object;
}>();
const mockData = {
properties: [],
functions: [],
events: [],
propertyGroups: [],
};
//
defineExpose({});
const loading = ref(false);
const exportLoading = ref(false);
const exportFormat = ref('json');
const exportFileName = ref('');
const tslJson = ref({
properties: [],
functions: [],
events: [],
propertyGroups: [],
});
// TSL
const loadTSLData = async () => {
loading.value = true;
try {
// tslJson.value = JSON.stringify(mockData, null, 2);
tslJson.value = JSON.stringify(props.metadata || mockData, null, 2);
} catch {
message.error('加载物模型数据失败');
} finally {
loading.value = false;
}
};
//
const handleRefresh = () => {
loadTSLData();
};
//
const handleExport = () => {
const time = dayjs().format('YYYY-MM-DD_HH:mm:ss');
exportFileName.value = `${props.productInfo.productName}_物模型_${time}`;
handleExportConfirm();
};
//
const handleCopy = async () => {
try {
const text = tslJson.value;
// await navigator.clipboard.writeText(text);
if (navigator.clipboard) {
await navigator.clipboard.writeText(text); // API
} else {
//
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.append(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
message.success('复制成功');
} catch {
message.error('复制失败');
}
};
//
const handleExportConfirm = async () => {
try {
exportLoading.value = true;
const blob = new Blob([tslJson.value], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${exportFileName.value}.${exportFormat.value}`;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
} finally {
exportLoading.value = false;
}
};
// metadata
watch(
() => props.metadata,
() => {
loadTSLData();
},
{ immediate: true },
);
onMounted(() => {
loadTSLData();
});
</script>
<template>
<div class="tsl-viewer">
<div class="viewer-header">
<Space>
<a-button @click="handleRefresh" :loading="loading">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
<a-button @click="handleCopy">
<template #icon>
<CopyOutlined />
</template>
复制
</a-button>
<a-button @click="handleExport" type="primary">
<template #icon>
<DownloadOutlined />
</template>
导出
</a-button>
</Space>
</div>
<div class="viewer-content-tips">
物模型是对设备在云端的功能描述包括设备的属性服务事件和属性分组,物联网平台通过定义一种物的描述语言来描述物模型称之为
TSL Thing Specification Language,采用 JSON 格式您可以根据 TSL
组装上报设备的数据您可以导出完整物模型用于云端应用开发
</div>
<div class="viewer-content">
<div class="viewer-toolbar">
<MonacoEditor
v-model="tslJson"
lang="javascript"
style="height: 100%"
theme="vs"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.tsl-viewer {
display: flex;
flex-direction: column;
height: 100%;
.viewer-header {
padding-bottom: 12px;
text-align: right;
}
.viewer-content-tips {
padding: 12px;
margin-bottom: 12px;
color: #8c8c8c;
background: #f5f5f5;
}
.viewer-content {
flex: 1;
.viewer-toolbar {
height: 100%;
}
.code-viewer {
.code-textarea {
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.5;
background-color: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
}
}
}
}
</style>

View File

@ -3,12 +3,14 @@ import { defineConfig } from '@vben/vite-config';
// 自行取消注释来启用按需导入功能
// import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
// import Components from 'unplugin-vue-components/vite';
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
export default defineConfig(async () => {
return {
application: {},
vite: {
plugins: [
(monacoEditorPlugin as any).default({}),
// Components({
// dirs: [], // 默认会导入src/components目录下所有组件 不需要
// dts: './types/components.d.ts', // 输出类型文件