feat: 新增物模型查看组件并优化产品详情页面
- 新增 MonacoEditor 组件用于代码编辑 - 新增 TSLViewer 组件用于查看物模型 - 更新 BasicInfo、DeviceAccess 和 Metadata 组件的显示逻辑 - 调整 MetadataTable 组件的列宽 - 在 package.json 中添加 monaco-editor 依赖 - 在 vite.config.mts 中添加 monaco-editor 插件配置
This commit is contained in:
parent
0c0a1994b2
commit
60e3f4fc73
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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="创建时间">
|
||||
|
|
|
@ -86,10 +86,10 @@ const tableColumns = computed(() => {
|
|||
});
|
||||
|
||||
// Markdown转HTML
|
||||
const markdownToHtml = computed(() => {
|
||||
// 这里可以使用markdown-it等库转换markdown为HTML
|
||||
return accessInfo.value.document || '';
|
||||
});
|
||||
// const markdownToHtml = computed(() => {
|
||||
// // 这里可以使用markdown-it等库转换markdown为HTML
|
||||
// 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"
|
||||
|
|
|
@ -307,7 +307,7 @@ watch(
|
|||
<Space>
|
||||
<Button @click="handleDrawerClose">取消</Button>
|
||||
<Button type="primary" @click="handleSave" :loading="saveLoading">
|
||||
保存
|
||||
确认
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -91,7 +91,7 @@ const columns = computed(() => {
|
|||
dataIndex: 'dataType',
|
||||
key: 'dataType',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '读写类型',
|
||||
|
|
|
@ -431,7 +431,7 @@ watch(
|
|||
<Space>
|
||||
<Button @click="handleDrawerClose">取消</Button>
|
||||
<Button type="primary" @click="handleSave" :loading="saveLoading">
|
||||
保存
|
||||
确认
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
|
|
@ -257,7 +257,7 @@ const handleClose = () => {
|
|||
<Space>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" :loading="saveLoading" @click="handleSave">
|
||||
确定
|
||||
确认
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
|
|
@ -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>
|
|
@ -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', // 输出类型文件
|
||||
|
|
Loading…
Reference in New Issue