feat(wms):优化 antd、ele 的 order receipt 迁移

pull/345/head
YunaiV 2026-05-18 01:02:09 +08:00
parent f8c2d4b1ff
commit b42e9b36e5
23 changed files with 1148 additions and 1203 deletions

View File

@ -1,53 +1,9 @@
import type { PageParam, PageResult } from '@vben/request';
import { isEmpty } from '@vben/utils';
import { requestClient } from '#/api/request';
export namespace ThingModelApi {
/** IoT 物模型数据 VO */
export interface ThingModel {
id?: number;
productId?: number;
productKey?: string;
identifier: string;
name: string;
desc?: string;
type: string;
property?: ThingModelProperty;
event?: ThingModelEvent;
service?: ThingModelService;
}
/** IoT 物模型属性 */
export interface Property {
identifier: string;
name: string;
accessMode: string;
dataType: string;
dataSpecs?: any;
dataSpecsList?: any[];
desc?: string;
}
/** IoT 物模型服务 */
export interface Service {
identifier: string;
name: string;
callType: string;
inputData?: any[];
outputData?: any[];
desc?: string;
}
/** IoT 物模型事件 */
export interface Event {
identifier: string;
name: string;
type: string;
outputData?: any[];
desc?: string;
}
}
/** IoT 物模型数据 */
export interface ThingModelData {
id?: number;
@ -55,9 +11,9 @@ export interface ThingModelData {
productKey?: string;
identifier?: string;
name?: string;
desc?: string;
type?: string;
description?: string;
dataType?: string;
type?: number; // 参见 IoTThingModelTypeEnum 枚举类
property?: ThingModelProperty;
event?: ThingModelEvent;
service?: ThingModelService;
@ -68,29 +24,45 @@ export interface ThingModelProperty {
identifier?: string;
name?: string;
accessMode?: string;
required?: boolean;
dataType?: string;
description?: string;
dataSpecs?: any;
dataSpecsList?: any[];
desc?: string;
}
/** IoT 物模型服务 */
export interface ThingModelService {
identifier?: string;
name?: string;
required?: boolean;
callType?: string;
inputData?: any[];
outputData?: any[];
desc?: string;
description?: string;
inputParams?: ThingModelParam[];
outputParams?: ThingModelParam[];
method?: string;
}
/** IoT 物模型事件 */
export interface ThingModelEvent {
identifier?: string;
name?: string;
required?: boolean;
type?: string;
outputData?: any[];
desc?: string;
description?: string;
outputParams?: ThingModelParam[];
method?: string;
}
/** IoT 物模型参数 */
export interface ThingModelParam {
identifier?: string;
name?: string;
direction?: string;
paraOrder?: number;
dataType?: string;
dataSpecs?: any;
dataSpecsList?: any[];
}
/** IoT 数据定义(数值型) */
@ -108,23 +80,119 @@ export interface DataSpecsEnumOrBoolData {
name: string;
}
/** IoT 物模型表单校验规则 */
export interface ThingModelFormRules {
[key: string]: any;
/** 生成「必填 + 数字」类校验器:拼到 size / length / 枚举值上 */
function buildRequiredNumberValidator(label: string) {
return (_rule: any, value: any, callback: any) => {
if (isEmpty(value)) {
callback(new Error(`${label}不能为空`));
return;
}
if (Number.isNaN(Number(value))) {
callback(new Error(`${label}必须是数字`));
return;
}
callback();
};
}
/** 验证布尔型名称 */
export function validateBoolName(_rule: any, value: any, callback: any) {
if (value) {
/** 生成「标识符样式」名称校验器:开头需为中文 / 英文 / 数字,整体仅允许中文、英文、数字、下划线、短划线,长度 ≤ 20 */
export function buildIdentifierLikeNameValidator(label: string) {
return (_rule: any, value: string, callback: any) => {
if (isEmpty(value)) {
callback(new Error(`${label}不能为空`));
return;
}
if (!/^[一-龥A-Za-z0-9]/.test(value)) {
callback(new Error(`${label}必须以中文、英文字母或数字开头`));
return;
}
if (!/^[一-龥A-Za-z0-9][\w一-龥-]*$/.test(value)) {
callback(
new Error(`${label}只能包含中文、英文字母、数字、下划线和短划线`),
);
return;
}
if (value.length > 20) {
callback(new Error(`${label}长度不能超过 20 个字符`));
return;
}
callback();
} else {
callback(new Error('枚举描述不能为空'));
}
};
}
/** IoT 物模型表单校验规则 */
export const ThingModelFormRules = {
name: [
{ required: true, message: '功能名称不能为空', trigger: 'blur' },
{
pattern: /^[一-龥A-Za-z0-9][一-龥A-Za-z0-9\-_/.]{0,29}$/,
message:
'支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
trigger: 'blur',
},
],
type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
identifier: [
{ required: true, message: '标识符不能为空', trigger: 'blur' },
{
pattern: /^\w{1,50}$/,
message: '支持大小写字母、数字和下划线,不超过 50 个字符',
trigger: 'blur',
},
{
validator: (_rule: any, value: string, callback: any) => {
const reservedKeywords = [
'set',
'get',
'post',
'property',
'event',
'time',
'value',
];
if (reservedKeywords.includes(value)) {
callback(
new Error(
'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义',
),
);
return;
}
if (/^\d+$/.test(value)) {
callback(new Error('标识符不能是纯数字'));
return;
}
callback();
},
trigger: 'blur',
},
],
childDataType: [{ required: true, message: '元素类型不能为空' }],
size: [
{
required: true,
validator: buildRequiredNumberValidator('元素个数'),
trigger: 'blur',
},
],
length: [
{
required: true,
validator: buildRequiredNumberValidator('文本长度'),
trigger: 'blur',
},
],
accessMode: [{ required: true, message: '请选择读写类型', trigger: 'change' }],
callType: [{ required: true, message: '请选择调用方式', trigger: 'change' }],
eventType: [{ required: true, message: '请选择事件类型', trigger: 'change' }],
};
/** 校验布尔值名称 */
export const validateBoolName = buildIdentifierLikeNameValidator('布尔值名称');
/** 查询产品物模型分页 */
export function getThingModelPage(params: PageParam) {
return requestClient.get<PageResult<ThingModelApi.ThingModel>>(
return requestClient.get<PageResult<ThingModelData>>(
'/iot/thing-model/page',
{ params },
);
@ -132,17 +200,14 @@ export function getThingModelPage(params: PageParam) {
/** 查询产品物模型详情 */
export function getThingModel(id: number) {
return requestClient.get<ThingModelApi.ThingModel>(
`/iot/thing-model/get?id=${id}`,
);
return requestClient.get<ThingModelData>(`/iot/thing-model/get?id=${id}`);
}
/** 根据产品 ID 查询物模型列表 */
export function getThingModelListByProductId(productId: number) {
return requestClient.get<ThingModelApi.ThingModel[]>(
'/iot/thing-model/list',
{ params: { productId } },
);
return requestClient.get<ThingModelData[]>('/iot/thing-model/list', {
params: { productId },
});
}
/** 新增物模型 */
@ -162,25 +227,7 @@ export function deleteThingModel(id: number) {
/** 获取物模型 TSL */
export function getThingModelTSL(productId: number) {
return requestClient.get<ThingModelApi.ThingModel[]>(
'/iot/thing-model/get-tsl',
{ params: { productId } },
);
}
/** TSL
export function importThingModelTSL(productId: number, tslData: any) {
return requestClient.post('/iot/thing-model/import-tsl', {
productId,
tslData,
});
}
*/
/** TSL
export function exportThingModelTSL(productId: number) {
return requestClient.get<any>('/iot/thing-model/export-tsl', {
return requestClient.get<any>('/iot/thing-model/get-tsl', {
params: { productId },
});
}
*/

View File

@ -11,6 +11,7 @@ import { message, Tabs } from 'ant-design-vue';
import { getDeviceCount } from '#/api/iot/device/device';
import { getProduct } from '#/api/iot/product/product';
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import ProductDetailsHeader from './modules/header.vue';
import ProductDetailsInfo from './modules/info.vue';
@ -25,7 +26,8 @@ const loading = ref(true);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
const activeTab = ref('info');
provide('product', product); //
/** 向子组件提供产品信息 */
provide(IOT_PROVIDE_KEY.PRODUCT, product);
/** 获取产品详情 */
async function getProductData(productId: number) {
@ -82,10 +84,7 @@ onMounted(async () => {
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
</Tabs.TabPane>
<Tabs.TabPane key="thingModel" tab="物模型(功能定义)">
<IoTProductThingModel
v-if="activeTab === 'thingModel'"
:product-id="id"
/>
<IoTProductThingModel v-if="activeTab === 'thingModel'" />
</Tabs.TabPane>
</Tabs>
</Page>

View File

@ -4,6 +4,8 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDataTypeOptionsLabel } from '#/views/iot/utils/constants';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
@ -27,7 +29,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'type',
title: '功能类型',
minWidth: 20,
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_THING_MODEL_TYPE },
@ -41,17 +43,16 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'identifier',
title: '标识符',
minWidth: 20,
minWidth: 120,
},
{
field: 'dataType',
title: '数据类型',
minWidth: 50,
slots: { default: 'dataType' },
minWidth: 100,
formatter: ({ row }) =>
getDataTypeOptionsLabel(row.property?.dataType) || '-',
},
{
field: 'property',
title: '属性',
title: '数据定义',
minWidth: 200,
slots: { default: 'dataDefinition' },
},

View File

@ -1,99 +1,90 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { onMounted, provide, ref } from 'vue';
import { computed, inject } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProduct } from '#/api/iot/product/product';
import { deleteThingModel, getThingModelPage } from '#/api/iot/thingmodel';
import { $t } from '#/locales';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import { getDataTypeOptionsLabel, IOT_PROVIDE_KEY } from '../utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
import { DataDefinition } from './modules/components';
import ThingModelForm from './modules/thing-model-form.vue';
import ThingModelTsl from './modules/thing-model-tsl.vue';
import Form from './modules/form.vue';
import Tsl from './modules/tsl.vue';
defineOptions({ name: 'IoTThingModel' });
const props = defineProps<{
productId: number;
}>();
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const productId = computed(() => product?.value?.id);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product); //
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
provide(IOT_PROVIDE_KEY.PRODUCT, product); //
const [TslModal, tslModalApi] = useVbenModal({
connectedComponent: Tsl,
destroyOnClose: true,
});
// TODO @haohaoform web-antd/src/views/system/user/index.vue open
const thingModelFormRef = ref();
// TODO @haohaothingModelTSLRef modal
const thingModelTSLRef = ref();
// TODO @haohao
//
function handleCreate() {
thingModelFormRef.value?.open('create');
}
//
function handleEdit(row: any) {
thingModelFormRef.value?.open('update', row.id);
}
//
async function handleDelete(row: any) {
// TODO @haohao loading
try {
await deleteThingModel(row.id);
message.success('删除成功');
gridApi.reload();
} catch (error) {
console.error('删除失败:', error);
}
}
// TSL
function handleOpenTSL() {
thingModelTSLRef.value?.open();
}
//
// TODO @haohao data.ts
function getDataTypeLabel(row: any) {
return getDataTypeOptionsLabel(row.property?.dataType) || '-';
}
//
/** 刷新表格 */
function handleRefresh() {
gridApi.reload();
gridApi.query();
}
//
async function getProductData() {
/** 新增物模型 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑物模型 */
function handleEdit(row: ThingModelData) {
formModalApi.setData({ id: row.id }).open();
}
/** 删除物模型 */
async function handleDelete(row: ThingModelData) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
product.value = await getProduct(props.productId);
} catch (error) {
console.error('获取产品信息失败:', error);
await deleteThingModel(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
// TODO @haohao
/** 打开 TSL 弹窗 */
function handleOpenTsl() {
tslModalApi.open();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }: any, formValues: any) => {
query: async ({ page }, formValues) => {
return await getThingModelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
productId: props.productId,
productId: productId.value,
...formValues,
});
},
@ -108,64 +99,55 @@ const [Grid, gridApi] = useVbenVxeGrid({
search: true,
},
},
formOptions: {
schema: useGridFormSchema(),
},
});
//
onMounted(async () => {
await getProductData();
});
</script>
<template>
<Page auto-content-height>
<Grid>
<FormModal @success="handleRefresh" />
<TslModal />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '添加功能',
label: $t('ui.actionTitle.create', ['物模型']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:thing-model:create'],
onClick: handleCreate,
},
{
label: 'TSL',
type: 'default',
color: 'success', // TODO @haohao color ps icon
onClick: handleOpenTSL,
type: 'primary',
auth: ['iot:thing-model:query'],
onClick: handleOpenTsl,
},
]"
/>
</template>
<!-- 数据类型列 -->
<template #dataType="{ row }">
<span>{{ getDataTypeLabel(row) }}</span>
</template>
<!-- 数据定义列 -->
<!-- TODO @haohao可以在 data.ts 就写掉这个逻辑 -->
<template #dataDefinition="{ row }">
<DataDefinition :data="row" />
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '编辑',
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['iot:thing-model:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '删除',
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:thing-model:delete'],
popConfirm: {
title: '确认删除该功能吗?',
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
@ -173,10 +155,5 @@ onMounted(async () => {
/>
</template>
</Grid>
<!-- 物模型表单 -->
<ThingModelForm ref="thingModelFormRef" @success="handleRefresh" />
<!-- TSL 弹窗 -->
<ThingModelTsl ref="thingModelTSLRef" />
</Page>
</template>

View File

@ -1,4 +1,3 @@
<!-- TODO @haohao如果是模块内用的就用 modules 等后面点在看优先级 -->
<script lang="ts" setup>
import type { ThingModelData } from '#/api/iot/thingmodel';
@ -13,83 +12,63 @@ import {
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
/** 数据定义展示组件 */
defineOptions({ name: 'DataDefinition' });
const NUMBER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
]);
const PLACEHOLDER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
IoTDataSpecsDataTypeEnum.DATE,
]);
const LIST_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM,
]);
const props = defineProps<{ data: ThingModelData }>();
const formattedDataSpecsList = computed(() => {
if (
!props.data.property?.dataSpecsList ||
props.data.property.dataSpecsList.length === 0
) {
if (!props.data.property?.dataSpecsList?.length) {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.value}-${item.name}`)
.join('、');
}); //
});
const shortText = computed(() => {
if (
!props.data.property?.dataSpecsList ||
props.data.property.dataSpecsList.length === 0
) {
const list = props.data.property?.dataSpecsList;
if (!list?.length) {
return '-';
}
const first = props.data.property.dataSpecsList[0];
const count = props.data.property.dataSpecsList.length;
return count > 1
? `${first.value}-${first.name}${count}`
const first = list[0];
return list.length > 1
? `${first.value}-${first.name}${list.length}`
: `${first.value}-${first.name}`;
}); //
});
</script>
<template>
<!-- 属性 -->
<template v-if="Number(data.type) === IoTThingModelTypeEnum.PROPERTY">
<!-- 非列表型数值 -->
<div
v-if="
[
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
].includes(data.property?.dataType as any)
"
>
<template v-if="data.type === IoTThingModelTypeEnum.PROPERTY">
<div v-if="NUMBER_TYPES.has(data.property?.dataType as any)">
取值范围{{
`${data.property?.dataSpecs?.min}~${data.property?.dataSpecs?.max}`
}}
</div>
<!-- 非列表型文本 -->
<div v-if="IoTDataSpecsDataTypeEnum.TEXT === data.property?.dataType">
<div v-if="data.property?.dataType === IoTDataSpecsDataTypeEnum.TEXT">
数据长度{{ data.property?.dataSpecs?.length }}
</div>
<!-- 列表型: 数组结构时间特殊 -->
<div
v-if="
[
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
IoTDataSpecsDataTypeEnum.DATE,
].includes(data.property?.dataType as any)
"
>
-
</div>
<!-- 列表型: 布尔值枚举 -->
<div
v-if="
[IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.ENUM].includes(
data.property?.dataType as any,
)
"
>
<div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div>
<div v-if="LIST_TYPES.has(data.property?.dataType as any)">
<Tooltip :title="formattedDataSpecsList" placement="topLeft">
<span class="data-specs-text">
<span
class="cursor-help border-b border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-500"
>
{{
IoTDataSpecsDataTypeEnum.BOOL === data.property?.dataType
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
? '布尔值'
: '枚举值'
}}{{ shortText }}
@ -98,25 +77,12 @@ const shortText = computed(() => {
</div>
</template>
<!-- 服务 -->
<div v-if="Number(data.type) === IoTThingModelTypeEnum.SERVICE">
<div v-if="data.type === IoTThingModelTypeEnum.SERVICE">
调用方式
{{ getThingModelServiceCallTypeLabel(data.service?.callType as any) }}
</div>
<!-- 事件 -->
<div v-if="Number(data.type) === IoTThingModelTypeEnum.EVENT">
<div v-if="data.type === IoTThingModelTypeEnum.EVENT">
事件类型{{ getEventTypeLabel(data.event?.type as any) }}
</div>
</template>
<style lang="scss" scoped>
/** TODO @haohaotindwind */
.data-specs-text {
cursor: help;
border-bottom: 1px dashed #d9d9d9;
&:hover {
color: #1890ff;
border-bottom-color: #1890ff;
}
}
</style>

View File

@ -5,21 +5,29 @@ import type { Ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Form, Input, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelStructDataSpecs from './thing-model-struct-data-specs.vue';
import ThingModelStructDataSpecs from './struct.vue';
/** 数组型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelArrayDataSpecs' });
/** 数组元素禁止选择的类型 */
const EXCLUDED_CHILD_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ENUM,
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.DATE,
]);
const childDataTypeOptions = getDataTypeOptions().filter(
(item) => !EXCLUDED_CHILD_TYPES.has(item.value),
);
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 元素类型改变时间。当值为 struct 时,对 dataSpecs 中的 dataSpecsList 进行初始化 */
/** 元素类型切到 struct 时,初始化 dataSpecsList 占位 */
function handleChange(val: any) {
if (val !== IoTDataSpecsDataTypeEnum.STRUCT) {
return;
@ -31,29 +39,25 @@ function handleChange(val: any) {
<template>
<Form.Item
:name="['property', 'dataSpecs', 'childDataType']"
:rules="ThingModelFormRules.childDataType"
label="元素类型"
>
<Radio.Group v-model:value="dataSpecs.childDataType" @change="handleChange">
<template v-for="item in getDataTypeOptions()" :key="item.value">
<Radio
v-if="
!(
[
IoTDataSpecsDataTypeEnum.ENUM,
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.DATE,
] as any[]
).includes(item.value)
"
:value="item.value"
class="w-1/3"
>
{{ `${item.value}(${item.label})` }}
</Radio>
</template>
<Radio
v-for="item in childDataTypeOptions"
:key="item.value"
:value="item.value"
class="w-1/3"
>
{{ `${item.value}(${item.label})` }}
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item :name="['property', 'dataSpecs', 'size']" label="元素个数">
<Form.Item
:name="['property', 'dataSpecs', 'size']"
:rules="ThingModelFormRules.size"
label="元素个数"
>
<Input
v-model:value="dataSpecs.size"
placeholder="请输入数组中的元素个数"

View File

@ -0,0 +1,131 @@
<!-- dataTypeenum 数组类型 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Form, Input, message } from 'ant-design-vue';
import { buildIdentifierLikeNameValidator } from '#/api/iot/thingmodel';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const validateEnumName = buildIdentifierLikeNameValidator('枚举描述');
/** 添加枚举项 */
function addEnum() {
dataSpecsList.value.push({ name: '', value: '' } as any);
}
/** 删除枚举项 */
function deleteEnum(index: number) {
if (dataSpecsList.value.length === 1) {
message.warning('至少需要一个枚举项');
return;
}
dataSpecsList.value.splice(index, 1);
}
/** 校验单项枚举值:必填、数字、不重复 */
function validateEnumValue(_rule: any, value: any, callback: any) {
if (isEmpty(value)) {
callback(new Error('枚举值不能为空'));
return;
}
if (Number.isNaN(Number(value))) {
callback(new Error('枚举值必须是数字'));
return;
}
const sameCount = dataSpecsList.value.filter((it) => it.value === value)
.length;
if (sameCount > 1) {
callback(new Error('枚举值不能重复'));
return;
}
callback();
}
/** 校验整个枚举列表:非空、无空项、无非法数字、无重复 */
function validateEnumList(_rule: any, _value: any, callback: any) {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('请至少添加一个枚举项'));
return;
}
const hasEmpty = dataSpecsList.value.some(
(item) => isEmpty(item.value) || isEmpty(item.name),
);
if (hasEmpty) {
callback(new Error('存在未填写的枚举值或描述'));
return;
}
const hasInvalidNumber = dataSpecsList.value.some((item) =>
Number.isNaN(Number(item.value)),
);
if (hasInvalidNumber) {
callback(new Error('存在非数字的枚举值'));
return;
}
const values = dataSpecsList.value.map((item) => item.value);
if (new Set(values).size !== values.length) {
callback(new Error('存在重复的枚举值'));
return;
}
callback();
}
</script>
<template>
<Form.Item
:rules="[{ validator: validateEnumList, trigger: 'change' }]"
label="枚举项"
>
<div class="flex flex-col">
<div class="flex items-center">
<span class="flex-1"> 参数值 </span>
<span class="flex-1"> 参数描述 </span>
</div>
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="mb-[5px] flex items-center justify-between"
>
<Form.Item
:name="['property', 'dataSpecsList', index, 'value']"
:rules="[
{ required: true, message: '枚举值不能为空', trigger: 'blur' },
{ validator: validateEnumValue, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<Input v-model:value="item.value" placeholder="请输入枚举值如「0」" />
</Form.Item>
<span class="mx-2">~</span>
<Form.Item
:name="['property', 'dataSpecsList', index, 'name']"
:rules="[
{ required: true, message: '枚举描述不能为空', trigger: 'blur' },
{ validator: validateEnumName, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<Input v-model:value="item.name" placeholder="对该枚举项的描述" />
</Form.Item>
<Button class="ml-2.5" type="link" @click="deleteEnum(index)">
删除
</Button>
</div>
<Button type="link" @click="addEnum">+ </Button>
</div>
</Form.Item>
</template>
<style lang="scss" scoped>
:deep(.ant-form-item) {
.ant-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,4 @@
export { default as ThingModelArrayDataSpecs } from './array.vue';
export { default as ThingModelEnumDataSpecs } from './enum.vue';
export { default as ThingModelNumberDataSpecs } from './number.vue';
export { default as ThingModelStructDataSpecs } from './struct.vue';

View File

@ -10,9 +10,6 @@ import { getDictOptions } from '@vben/hooks';
import { useVModel } from '@vueuse/core';
import { Form, Input, Select } from 'ant-design-vue';
/** 数值型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelNumberDataSpecs' });
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecs = useVModel(
@ -21,13 +18,15 @@ const dataSpecs = useVModel(
emits,
) as Ref<DataSpecsNumberData>;
/** 单位发生变化时触发 */
const unitChange = (UnitSpecs: any) => {
if (!UnitSpecs) return;
const [unitName, unit] = String(UnitSpecs).split('-');
/** 单位下拉变化时,拆出 unitName 与 unit 回写 */
function unitChange(unitSpecs: any) {
if (!unitSpecs) {
return;
}
const [unitName, unit] = String(unitSpecs).split('-');
dataSpecs.value.unitName = unitName;
dataSpecs.value.unit = unit;
};
}
</script>
<template>
@ -52,7 +51,7 @@ const unitChange = (UnitSpecs: any) => {
"
show-search
placeholder="请选择单位"
class="w-1/1"
class="w-full"
@change="unitChange"
>
<Select.Option

View File

@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from '../property.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const structFormRef = ref();
const formData = ref<any>(buildEmptyFormData());
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await structFormRef.value?.validate();
} catch {
return;
}
const data = formData.value;
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: IoTDataSpecsDataTypeEnum.STRUCT,
childDataType: data.property.dataType,
dataSpecs:
!isEmpty(data.property.dataSpecs) &&
Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList)
? undefined
: data.property.dataSpecsList,
};
const existingIndex = dataSpecsList.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
if (existingIndex === -1) {
dataSpecsList.value.push(item);
} else {
dataSpecsList.value[existingIndex] = item;
}
await modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
formData.value = buildEmptyFormData();
structFormRef.value?.clearValidate?.();
const data = modalApi.getData<any>();
if (isEmpty(data)) {
return;
}
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.childDataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
},
};
},
});
/** 构造空白结构体表单 */
function buildEmptyFormData() {
return {
identifier: '',
name: '',
description: '',
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: { dataType: IoTDataSpecsDataTypeEnum.INT },
dataSpecsList: [],
},
};
}
/** 打开结构体表单 */
function openStructForm(val: any) {
modalApi.setData(val).open();
}
/** 删除结构体项 */
function deleteStructItem(index: number) {
dataSpecsList.value.splice(index, 1);
}
onMounted(() => {
if (isEmpty(dataSpecsList.value)) {
dataSpecsList.value = [];
}
});
</script>
<template>
<Form.Item label="属性对象">
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="mb-2.5 flex w-full justify-between bg-gray-100 px-2.5 dark:bg-gray-800"
>
<span>参数{{ item.name }}</span>
<div>
<Button type="link" @click="openStructForm(item)"></Button>
<Divider type="vertical" />
<Button danger type="link" @click="deleteStructItem(index)">
删除
</Button>
</div>
</div>
<Button type="link" @click="openStructForm(null)">+ </Button>
</Form.Item>
<!-- 结构体参数表单 -->
<Modal class="w-2/5" title="结构体参数">
<Form
ref="structFormRef"
:label-col="{ span: 6 }"
:model="formData"
:wrapper-col="{ span: 18 }"
class="mx-4"
>
<Form.Item
:rules="ThingModelFormRules.name"
label="参数名称"
name="name"
>
<Input v-model:value="formData.name" placeholder="请输入参数名称" />
</Form.Item>
<Form.Item
:rules="ThingModelFormRules.identifier"
label="标识符"
name="identifier"
>
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-struct-data-specs />
</Form>
</Modal>
</template>

View File

@ -1,4 +0,0 @@
export { default as ThingModelArrayDataSpecs } from './thing-model-array-data-specs.vue';
export { default as ThingModelEnumDataSpecs } from './thing-model-enum-data-specs.vue';
export { default as ThingModelNumberDataSpecs } from './thing-model-number-data-specs.vue';
export { default as ThingModelStructDataSpecs } from './thing-model-struct-data-specs.vue';

View File

@ -1,67 +0,0 @@
<!-- dataTypeenum 数组类型 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Button, Form, Input, message } from 'ant-design-vue';
/** 枚举型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelEnumDataSpecs' });
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
/** 添加枚举项 */
function addEnum() {
dataSpecsList.value.push({
name: '', //
value: '', //
} as any);
}
/** 删除枚举项 */
function deleteEnum(index: number) {
if (dataSpecsList.value.length === 1) {
message.warning('至少需要一个枚举项');
return;
}
dataSpecsList.value.splice(index, 1);
}
</script>
<template>
<Form.Item label="枚举项">
<div class="flex flex-col">
<div class="flex items-center">
<span class="flex-1"> 参数值 </span>
<span class="flex-1"> 参数描述 </span>
</div>
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="mb-5px flex items-center justify-between"
>
<div class="flex-1">
<Input v-model:value="item.value" placeholder="请输入枚举值,如'0'" />
</div>
<span class="mx-2">~</span>
<div class="flex-1">
<Input v-model:value="item.name" placeholder="对该枚举项的描述" />
</div>
<Button class="ml-10px" type="link" @click="deleteEnum(index)">
删除
</Button>
</div>
<Button type="link" @click="addEnum">+</Button>
</div>
</Form.Item>
</template>
<style lang="scss" scoped>
:deep(.ant-form-item) {
.ant-form-item {
margin-bottom: 0;
}
}
</style>

View File

@ -1,169 +0,0 @@
<!-- dataTypestruct 数组类型 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { nextTick, onMounted, ref, unref } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input, Modal } from 'ant-design-vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from '../thing-model-property.vue';
/** Struct 型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelStructDataSpecs' });
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const dialogVisible = ref(false); //
const dialogTitle = ref('新增参数'); //
const formLoading = ref(false); // 12
const structFormRef = ref(); // ref
const formData = ref<any>({
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
dataSpecsList: [],
},
});
/** 打开 struct 表单 */
function openStructForm(val: any) {
dialogVisible.value = true;
resetForm();
if (isEmpty(val)) {
return;
}
//
const valData = val as any;
formData.value = {
identifier: valData?.identifier || '',
name: valData?.name || '',
description: valData?.description || '',
property: {
dataType: valData?.childDataType || IoTDataSpecsDataTypeEnum.INT,
dataSpecs: valData?.dataSpecs ?? {},
dataSpecsList: valData?.dataSpecsList ?? [],
},
};
// property.dataType
if (!formData.value.property.dataType) {
formData.value.property.dataType = IoTDataSpecsDataTypeEnum.INT;
}
}
/** 删除 struct 项 */
function deleteStructItem(index: number) {
dataSpecsList.value.splice(index, 1);
}
/** 添加参数 */
async function submitForm() {
await structFormRef.value.validate();
try {
const data = unref(formData);
//
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: IoTDataSpecsDataTypeEnum.STRUCT,
childDataType: data.property.dataType,
dataSpecs:
!!data.property.dataSpecs &&
Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList)
? undefined
: data.property.dataSpecsList,
};
// identifier
const existingIndex = dataSpecsList.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
if (existingIndex === -1) {
dataSpecsList.value.push(item);
} else {
dataSpecsList.value[existingIndex] = item;
}
} finally {
dialogVisible.value = false;
}
}
/** 重置表单 */
function resetForm() {
formData.value = {
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
dataSpecsList: [],
},
};
structFormRef.value?.resetFields();
}
/** 组件初始化 */
onMounted(async () => {
await nextTick();
// dataSpecsList
isEmpty(dataSpecsList.value) && (dataSpecsList.value = []);
});
</script>
<template>
<!-- struct 数据展示 -->
<Form.Item label="属性对象">
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="px-10px mb-10px flex w-full justify-between bg-gray-100"
>
<span>参数{{ item.name }}</span>
<div class="btn">
<Button type="link" @click="openStructForm(item)"> </Button>
<Divider type="vertical" />
<Button type="link" danger @click="deleteStructItem(index)">
删除
</Button>
</div>
</div>
<Button type="link" @click="openStructForm(null)"> + </Button>
</Form.Item>
<!-- struct 表单 -->
<Modal
v-model:open="dialogVisible"
:title="dialogTitle"
:confirm-loading="formLoading"
@ok="submitForm"
>
<Form
ref="structFormRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="参数名称" name="name">
<Input v-model:value="formData.name" placeholder="请输入功能名称" />
</Form.Item>
<Form.Item label="标识符" name="identifier">
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-struct-data-specs />
</Form>
</Modal>
</template>

View File

@ -9,17 +9,15 @@ import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelEventTypeEnum,
IoTThingModelParamDirectionEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './thing-model-input-output-param.vue';
import ThingModelInputOutputParam from './input-output-param.vue';
/** IoT 物模型事件 */
defineOptions({ name: 'ThingModelEvent' });
const props = defineProps<{ isStructDataSpecs?: boolean; modelValue: any }>();
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const thingModelEvent = useVModel(props, 'modelValue', emits) as Ref<any>;
@ -36,7 +34,7 @@ watch(
<template>
<Form.Item
:name="['event', 'type']"
:rules="[{ required: true, message: '请选择事件类型', trigger: 'change' }]"
:rules="ThingModelFormRules.eventType"
label="事件类型"
>
<Radio.Group v-model:value="thingModelEvent.type">

View File

@ -0,0 +1,246 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { cloneDeep, isEmpty } from '@vben/utils';
import { Form, Input, message, Radio } from 'ant-design-vue';
import {
createThingModel,
getThingModel,
ThingModelFormRules,
updateThingModel,
} from '#/api/iot/thingmodel';
import {
IOT_PROVIDE_KEY,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelEvent from './event.vue';
import ThingModelProperty from './property.vue';
import ThingModelService from './service.vue';
const emit = defineEmits(['success']);
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const formRef = ref();
const formData = ref<ThingModelData>(buildEmptyFormData());
const getTitle = computed(() =>
formData.value.id
? $t('ui.actionTitle.edit', ['物模型'])
: $t('ui.actionTitle.create', ['物模型']),
);
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalApi.lock();
try {
const data = cloneDeep(formData.value);
data.productId = product!.value.id;
data.productKey = product!.value.productKey;
fillExtraAttributes(data);
await (data.id ? updateThingModel(data) : createThingModel(data));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
formData.value = buildEmptyFormData();
formRef.value?.clearValidate?.();
const data = modalApi.getData<{ id?: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
const result = await getThingModel(data.id);
formData.value = normalizeFormData(result);
} finally {
modalApi.unlock();
}
},
});
/** 构造空白表单数据 */
function buildEmptyFormData(): ThingModelData {
return {
type: IoTThingModelTypeEnum.PROPERTY,
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
},
service: {
inputParams: [],
outputParams: [],
},
event: {
outputParams: [],
},
};
}
/** 回显数据时,规整各分支字段确保子表单可绑定 */
function normalizeFormData(result: ThingModelData): ThingModelData {
const next: any = { ...result, type: Number(result.type) };
if (isEmpty(next.property)) {
next.dataType = IoTDataSpecsDataTypeEnum.INT;
next.property = {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: { dataType: IoTDataSpecsDataTypeEnum.INT },
};
} else {
next.property.dataSpecs ??= {};
next.property.dataSpecsList ??= [];
next.property.dataType ??= IoTDataSpecsDataTypeEnum.INT;
}
if (isEmpty(next.service)) {
next.service = { inputParams: [], outputParams: [] };
} else {
next.service.inputParams ??= [];
next.service.outputParams ??= [];
}
if (isEmpty(next.event)) {
next.event = { outputParams: [] };
} else {
next.event.outputParams ??= [];
}
return next;
}
/** 按功能类型将子表单数据回写到顶层,并清理无关分支 */
function fillExtraAttributes(data: any) {
if (data.type === IoTThingModelTypeEnum.PROPERTY) {
removeDataSpecs(data.property);
data.dataType = data.property.dataType;
data.property.identifier = data.identifier;
data.property.name = data.name;
delete data.service;
delete data.event;
} else if (data.type === IoTThingModelTypeEnum.SERVICE) {
removeDataSpecs(data.service);
data.dataType = data.service.dataType;
data.service.identifier = data.identifier;
data.service.name = data.name;
if (isEmpty(data.service.inputParams)) {
delete data.service.inputParams;
}
if (isEmpty(data.service.outputParams)) {
delete data.service.outputParams;
}
delete data.property;
delete data.event;
} else if (data.type === IoTThingModelTypeEnum.EVENT) {
removeDataSpecs(data.event);
data.dataType = data.event.dataType;
data.event.identifier = data.identifier;
data.event.name = data.name;
if (isEmpty(data.event.outputParams)) {
delete data.event.outputParams;
}
delete data.property;
delete data.service;
}
}
/** 清理空的 dataSpecs / dataSpecsList */
function removeDataSpecs(val: any) {
if (isEmpty(val.dataSpecs)) {
delete val.dataSpecs;
}
if (isEmpty(val.dataSpecsList)) {
delete val.dataSpecsList;
}
}
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form
ref="formRef"
:label-col="{ span: 6 }"
:model="formData"
:wrapper-col="{ span: 18 }"
class="mx-4"
>
<Form.Item
:rules="ThingModelFormRules.type"
label="功能类型"
name="type"
>
<Radio.Group v-model:value="formData.type">
<Radio.Button
v-for="dict in getDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
:key="String(dict.value)"
:value="Number(dict.value)"
>
{{ dict.label }}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
:rules="ThingModelFormRules.name"
label="功能名称"
name="name"
>
<Input v-model:value="formData.name" placeholder="请输入功能名称" />
</Form.Item>
<Form.Item
:rules="ThingModelFormRules.identifier"
label="标识符"
name="identifier"
>
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty
v-if="formData.type === IoTThingModelTypeEnum.PROPERTY"
v-model="formData.property"
/>
<!-- 服务配置 -->
<ThingModelService
v-if="formData.type === IoTThingModelTypeEnum.SERVICE"
v-model="formData.service"
/>
<!-- 事件配置 -->
<ThingModelEvent
v-if="formData.type === IoTThingModelTypeEnum.EVENT"
v-model="formData.event"
/>
<Form.Item label="描述" name="description">
<Input.TextArea
v-model:value="formData.description"
:maxlength="200"
:rows="3"
placeholder="请输入物模型描述"
/>
</Form.Item>
</Form>
</Modal>
</template>

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from './property.vue';
const props = defineProps<{ direction: string; modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const paramFormRef = ref();
const formData = ref<any>(buildEmptyFormData());
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await paramFormRef.value?.validate();
} catch {
return;
}
if (!thingModelParams.value) {
thingModelParams.value = [];
}
const data = formData.value;
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: data.property.dataType,
paraOrder: 0,
direction: props.direction,
dataSpecs:
!isEmpty(data.property.dataSpecs) &&
Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList)
? undefined
: data.property.dataSpecsList,
};
// identifier
const existingIndex = thingModelParams.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
if (existingIndex === -1) {
thingModelParams.value.push(item);
} else {
thingModelParams.value[existingIndex] = item;
}
await modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
formData.value = buildEmptyFormData();
paramFormRef.value?.clearValidate?.();
const data = modalApi.getData<any>();
if (isEmpty(data)) {
return;
}
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.dataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
},
};
},
});
/** 构造空白参数表单 */
function buildEmptyFormData() {
return {
identifier: '',
name: '',
description: '',
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: { dataType: IoTDataSpecsDataTypeEnum.INT },
dataSpecsList: [],
},
};
}
/** 打开参数表单(新增或编辑) */
function openParamForm(val: any) {
modalApi.setData(val).open();
}
/** 删除参数项 */
function deleteParamItem(index: number) {
thingModelParams.value.splice(index, 1);
}
</script>
<template>
<div
v-for="(item, index) in thingModelParams"
:key="index"
class="mb-2.5 flex w-full justify-between bg-gray-100 px-2.5 dark:bg-gray-800"
>
<span>参数名称{{ item.name }}</span>
<div>
<Button type="link" @click="openParamForm(item)"></Button>
<Divider type="vertical" />
<Button danger type="link" @click="deleteParamItem(index)"></Button>
</div>
</div>
<Button type="link" @click="openParamForm(null)">+ </Button>
<!-- 参数表单 -->
<Modal class="w-2/5" title="参数配置">
<Form
ref="paramFormRef"
:label-col="{ span: 6 }"
:model="formData"
:wrapper-col="{ span: 18 }"
class="mx-4"
>
<Form.Item
:rules="ThingModelFormRules.name"
label="参数名称"
name="name"
>
<Input v-model:value="formData.name" placeholder="请输入参数名称" />
</Form.Item>
<Form.Item
:rules="ThingModelFormRules.identifier"
label="标识符"
name="identifier"
>
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-params />
</Form>
</Modal>
</template>

View File

@ -11,6 +11,7 @@ import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Input, Radio, Select } from 'ant-design-vue';
import { ThingModelFormRules, validateBoolName } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
@ -22,10 +23,22 @@ import {
ThingModelEnumDataSpecs,
ThingModelNumberDataSpecs,
ThingModelStructDataSpecs,
} from './dataSpecs';
} from './data-specs';
/** IoT 物模型属性 */
defineOptions({ name: 'ThingModelProperty' });
/** 嵌套在结构体里时,禁止再选数组 / 结构体(最多支持两层嵌套) */
const NESTED_EXCLUDED_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
]);
const STRUCT_CHILD_OPTIONS = getDataTypeOptions().filter(
(item) => !NESTED_EXCLUDED_TYPES.has(item.value),
);
/** 数值型数据类型集合 */
const NUMERIC_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
]);
const props = defineProps<{
isParams?: boolean;
@ -38,65 +51,54 @@ const property = useVModel(
'modelValue',
emits,
) as Ref<ThingModelProperty>;
const getDataTypeOptions2 = computed(() => {
if (!props.isStructDataSpecs) {
return getDataTypeOptions();
}
const excludedTypes = new Set([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
]);
return getDataTypeOptions().filter(
(item: any) => !excludedTypes.has(item.value),
);
}); //
/** 属性值的数据类型切换时初始化相关数据 */
const dataTypeOptions = computed(() =>
props.isStructDataSpecs ? STRUCT_CHILD_OPTIONS : getDataTypeOptions(),
);
/** 数据类型切换时,重置 dataSpecs / dataSpecsList 并按新类型初始化 */
function handleChange(dataType: any) {
property.value.dataSpecs = {};
property.value.dataSpecsList = [];
// dataSpecs.dataType
![
// / / / dataType dataSpecs / / dataSpecsList
const listLike = [
IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM,
IoTDataSpecsDataTypeEnum.STRUCT,
].includes(dataType) && (property.value.dataSpecs.dataType = dataType);
switch (dataType) {
case IoTDataSpecsDataTypeEnum.BOOL: {
for (let i = 0; i < 2; i++) {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.BOOL,
name: '', //
value: i, //
});
}
break;
}
case IoTDataSpecsDataTypeEnum.ENUM: {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.ENUM,
name: '', //
value: undefined, //
});
break;
}
];
if (!listLike.includes(dataType)) {
property.value.dataSpecs.dataType = dataType;
}
if (dataType === IoTDataSpecsDataTypeEnum.BOOL) {
for (let i = 0; i < 2; i++) {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.BOOL,
name: '', //
value: i, //
});
}
} else if (dataType === IoTDataSpecsDataTypeEnum.ENUM) {
property.value.dataSpecsList.push({
dataType: IoTDataSpecsDataTypeEnum.ENUM,
name: '', //
value: undefined, //
});
}
// useVModel emit
}
/** 默认选中读写 */
watch(
() => property.value.accessMode,
(val: string | undefined) => {
if (props.isStructDataSpecs || props.isParams) {
return;
}
if (isEmpty(val)) {
property.value.accessMode = IoTThingModelAccessModeEnum.READ_WRITE.value;
}
},
{ immediate: true },
);
/** 顶层属性表单首次进入时,默认选中读写 */
if (!props.isStructDataSpecs && !props.isParams) {
watch(
() => property.value.accessMode,
(val: string | undefined) => {
if (isEmpty(val)) {
property.value.accessMode =
IoTThingModelAccessModeEnum.READ_WRITE.value;
}
},
{ immediate: true },
);
}
</script>
<template>
@ -108,7 +110,7 @@ watch(
>
<!-- ARRAY STRUCT 类型数据相互嵌套时最多支持递归嵌套 2 父和子 -->
<Select.Option
v-for="option in getDataTypeOptions2"
v-for="option in dataTypeOptions"
:key="option.value"
:value="option.value"
>
@ -118,13 +120,7 @@ watch(
</Form.Item>
<!-- 数值型配置 -->
<ThingModelNumberDataSpecs
v-if="
[
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
].includes(property.dataType || '')
"
v-if="NUMERIC_TYPES.has(property.dataType || '')"
v-model="property.dataSpecs"
/>
<!-- 枚举型配置 -->
@ -137,17 +133,27 @@ watch(
v-if="property.dataType === IoTDataSpecsDataTypeEnum.BOOL"
label="布尔值"
>
<template v-for="item in property.dataSpecsList" :key="item.value">
<div class="w-1/1 mb-5px flex items-center justify-start">
<template
v-for="(item, index) in property.dataSpecsList"
:key="item.value"
>
<div class="mb-[5px] flex w-full items-center justify-start">
<span>{{ item.value }}</span>
<span class="mx-2">-</span>
<div class="flex-1">
<Form.Item
:name="['property', 'dataSpecsList', index, 'name']"
:rules="[
{ required: true, message: '布尔描述不能为空', trigger: 'blur' },
{ validator: validateBoolName, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<Input
v-model:value="item.name"
:placeholder="`如:${item.value === 0 ? '关' : '开'}`"
class="w-255px!"
class="!w-[255px]"
/>
</div>
</Form.Item>
</div>
</template>
</Form.Item>
@ -155,11 +161,12 @@ watch(
<Form.Item
v-if="property.dataType === IoTDataSpecsDataTypeEnum.TEXT"
:name="['property', 'dataSpecs', 'length']"
:rules="ThingModelFormRules.length"
label="数据长度"
>
<Input
v-model:value="property.dataSpecs.length"
class="w-255px!"
class="!w-[255px]"
placeholder="请输入文本字节长度"
>
<template #addonAfter>字节</template>
@ -172,7 +179,7 @@ watch(
name="date"
>
<Input
class="w-255px!"
class="!w-[255px]"
disabled
placeholder="String 类型的 UTC 时间戳(毫秒)"
/>
@ -190,6 +197,7 @@ watch(
<Form.Item
v-if="!isStructDataSpecs && !isParams"
:name="['property', 'accessMode']"
:rules="ThingModelFormRules.accessMode"
label="读写类型"
>
<Radio.Group v-model:value="property.accessMode">

View File

@ -9,17 +9,15 @@ import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelParamDirectionEnum,
IoTThingModelServiceCallTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './thing-model-input-output-param.vue';
import ThingModelInputOutputParam from './input-output-param.vue';
/** IoT 物模型服务 */
defineOptions({ name: 'ThingModelService' });
const props = defineProps<{ isStructDataSpecs?: boolean; modelValue: any }>();
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const service = useVModel(props, 'modelValue', emits) as Ref<any>;
@ -36,7 +34,7 @@ watch(
<template>
<Form.Item
:name="['service', 'callType']"
:rules="[{ required: true, message: '请选择调用方式', trigger: 'change' }]"
:rules="ThingModelFormRules.callType"
label="调用方式"
>
<Radio.Group v-model:value="service.callType">

View File

@ -1,298 +0,0 @@
<!-- 产品的物模型表单 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
// TODO @haohao使 form.vue
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { inject, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { Form, Input, message, Modal, Radio } from 'ant-design-vue';
import {
createThingModel,
getThingModel,
updateThingModel,
} from '#/api/iot/thingmodel';
import {
IOT_PROVIDE_KEY,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelEvent from './thing-model-event.vue';
import ThingModelProperty from './thing-model-property.vue';
import ThingModelService from './thing-model-service.vue';
/** IoT 物模型数据表单 */
defineOptions({ name: 'IoTThingModelForm' });
/** 提交表单 */
const emit = defineEmits(['success']);
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT); //
const dialogVisible = ref(false); //
const dialogTitle = ref(''); //
const formLoading = ref(false); // 12
const formType = ref(''); // create - update -
const formData = ref<any>({
type: IoTThingModelTypeEnum.PROPERTY,
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
},
service: {
inputParams: [],
outputParams: [],
},
event: {
outputParams: [],
},
});
const formRef = ref(); // Ref
/** 打开弹窗 */
// TODO @haohaoModal
async function open(type: string, id?: number) {
dialogVisible.value = true;
// create -> update ->
dialogTitle.value =
type === 'create' ? $t('page.action.add') : $t('page.action.edit');
formType.value = type;
resetForm();
if (id) {
formLoading.value = true;
try {
const result = await getThingModel(id);
//
formData.value = {
...result,
type: Number(result.type),
};
//
if (
!formData.value.property ||
Object.keys(formData.value.property).length === 0
) {
formData.value.dataType = IoTDataSpecsDataTypeEnum.INT;
formData.value.property = {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
};
} else {
// dataSpecs dataSpecsList
if (!formData.value.property.dataSpecs) {
formData.value.property.dataSpecs = {};
}
if (!formData.value.property.dataSpecsList) {
formData.value.property.dataSpecsList = [];
}
// property.dataType
if (!formData.value.property.dataType) {
formData.value.property.dataType = IoTDataSpecsDataTypeEnum.INT;
}
}
//
if (
!formData.value.service ||
Object.keys(formData.value.service).length === 0
) {
formData.value.service = {
inputParams: [],
outputParams: [],
};
} else {
//
if (!formData.value.service.inputParams) {
formData.value.service.inputParams = [];
}
if (!formData.value.service.outputParams) {
formData.value.service.outputParams = [];
}
}
//
if (
!formData.value.event ||
Object.keys(formData.value.event).length === 0
) {
formData.value.event = {
outputParams: [],
};
} else {
//
if (!formData.value.event.outputParams) {
formData.value.event.outputParams = [];
}
}
} finally {
formLoading.value = false;
}
}
}
defineExpose({ open, close: () => (dialogVisible.value = false) });
async function submitForm() {
await formRef.value.validate();
formLoading.value = true;
try {
const data = cloneDeep(formData.value) as ThingModelData;
//
data.productId = product!.value.id;
data.productKey = product!.value.productKey;
fillExtraAttributes(data);
await (formType.value === 'create'
? createThingModel(data)
: updateThingModel(data));
message.success($t('ui.actionMessage.operationSuccess'));
//
dialogVisible.value = false;
emit('success');
} finally {
formLoading.value = false;
}
}
/** 填写额外的属性(处理不同类型的情况) */
function fillExtraAttributes(data: any) {
//
if (data.type === IoTThingModelTypeEnum.PROPERTY) {
removeDataSpecs(data.property);
data.dataType = data.property.dataType;
data.property.identifier = data.identifier;
data.property.name = data.name;
delete data.service;
delete data.event;
}
//
if (data.type === IoTThingModelTypeEnum.SERVICE) {
removeDataSpecs(data.service);
data.dataType = data.service.dataType;
data.service.identifier = data.identifier;
data.service.name = data.name;
//
if (!data.service.inputParams || data.service.inputParams.length === 0) {
delete data.service.inputParams;
}
if (!data.service.outputParams || data.service.outputParams.length === 0) {
delete data.service.outputParams;
}
delete data.property;
delete data.event;
}
//
if (data.type === IoTThingModelTypeEnum.EVENT) {
removeDataSpecs(data.event);
data.dataType = data.event.dataType;
data.event.identifier = data.identifier;
data.event.name = data.name;
//
if (!data.event.outputParams || data.event.outputParams.length === 0) {
delete data.event.outputParams;
}
delete data.property;
delete data.service;
}
}
/** 处理 dataSpecs 为空的情况 */
function removeDataSpecs(val: any) {
if (!val.dataSpecs || Object.keys(val.dataSpecs).length === 0) {
delete val.dataSpecs;
}
if (!val.dataSpecsList || val.dataSpecsList.length === 0) {
delete val.dataSpecsList;
}
}
/** 重置表单 */
function resetForm() {
formData.value = {
type: IoTThingModelTypeEnum.PROPERTY,
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
},
service: {
inputParams: [],
outputParams: [],
},
event: {
outputParams: [],
},
};
formRef.value?.resetFields();
}
</script>
<template>
<Modal
v-model:open="dialogVisible"
:title="dialogTitle"
:confirm-loading="formLoading"
@ok="submitForm"
>
<!-- TODO @haohao这个可以改造成 data.ts schema 形式么可能是有一定成本后续迁移 ele 版本会容易很多 -->
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="功能类型" name="type">
<Radio.Group v-model:value="formData.type">
<Radio.Button
v-for="dict in getDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
:key="String(dict.value)"
:value="Number(dict.value)"
>
{{ dict.label }}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="功能名称" name="name">
<Input v-model:value="formData.name" placeholder="请输入功能名称" />
</Form.Item>
<Form.Item label="标识符" name="identifier">
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty
v-if="formData.type === IoTThingModelTypeEnum.PROPERTY"
v-model="formData.property"
/>
<!-- 服务配置 -->
<ThingModelService
v-if="formData.type === IoTThingModelTypeEnum.SERVICE"
v-model="formData.service"
/>
<!-- 事件配置 -->
<ThingModelEvent
v-if="formData.type === IoTThingModelTypeEnum.EVENT"
v-model="formData.event"
/>
<Form.Item label="描述" name="desc">
<Input.TextArea
v-model:value="formData.desc"
:maxlength="200"
:rows="3"
placeholder="请输入属性描述"
/>
</Form.Item>
</Form>
</Modal>
</template>

View File

@ -1,163 +0,0 @@
<!-- 产品的物模型表单eventservice 项里的参数 -->
<script lang="ts" setup>
import type { Ref } from 'vue';
import { ref, unref } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input, Modal } from 'ant-design-vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from './thing-model-property.vue';
/** 输入输出参数配置组件 */
defineOptions({ name: 'ThingModelInputOutputParam' });
const props = defineProps<{ direction: string; modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const dialogVisible = ref(false); //
const formLoading = ref(false); // 12
const paramFormRef = ref(); // ref
const formData = ref<any>({
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
dataSpecsList: [],
},
});
/** 打开 param 表单 */
function openParamForm(val: any) {
dialogVisible.value = true;
resetForm();
if (isEmpty(val)) {
return;
}
//
const valData = val as any;
formData.value = {
identifier: valData?.identifier || '',
name: valData?.name || '',
description: valData?.description || '',
property: {
dataType: valData?.dataType || IoTDataSpecsDataTypeEnum.INT,
dataSpecs: valData?.dataSpecs ?? {},
dataSpecsList: valData?.dataSpecsList ?? [],
},
};
// property.dataType
if (!formData.value.property.dataType) {
formData.value.property.dataType = IoTDataSpecsDataTypeEnum.INT;
}
}
/** 删除 param 项 */
function deleteParamItem(index: number) {
thingModelParams.value.splice(index, 1);
}
/** 添加参数 */
async function submitForm() {
//
if (isEmpty(thingModelParams.value)) {
thingModelParams.value = [];
}
//
await paramFormRef.value.validate();
try {
//
const data = unref(formData);
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: data.property.dataType,
paraOrder: 0, // TODO @puhui999:
direction: props.direction,
dataSpecs:
!!data.property.dataSpecs &&
Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList)
? undefined
: data.property.dataSpecsList,
};
// identifier
const existingIndex = thingModelParams.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
if (existingIndex === -1) {
thingModelParams.value.push(item);
} else {
thingModelParams.value[existingIndex] = item;
}
} finally {
dialogVisible.value = false;
}
}
/** 重置表单 */
function resetForm() {
formData.value = {
dataType: IoTDataSpecsDataTypeEnum.INT,
property: {
dataType: IoTDataSpecsDataTypeEnum.INT,
dataSpecs: {
dataType: IoTDataSpecsDataTypeEnum.INT,
},
dataSpecsList: [],
},
};
paramFormRef.value?.resetFields();
}
</script>
<template>
<div
v-for="(item, index) in thingModelParams"
:key="index"
class="w-1/1 px-10px mb-10px flex justify-between bg-gray-100"
>
<span>参数名称{{ item.name }}</span>
<div class="btn">
<Button type="link" @click="openParamForm(item)"></Button>
<Divider type="vertical" />
<Button type="link" danger @click="deleteParamItem(index)"></Button>
</div>
</div>
<Button type="link" @click="openParamForm(null)">+</Button>
<!-- param 表单 -->
<Modal
v-model:open="dialogVisible"
title="新增参数"
:confirm-loading="formLoading"
@ok="submitForm"
>
<Form
ref="paramFormRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="参数名称" name="name">
<Input v-model:value="formData.name" placeholder="请输入功能名称" />
</Form.Item>
<Form.Item label="标识符" name="identifier">
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-params />
</Form>
</Modal>
</template>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, inject, ref, watch } from 'vue';
import { Modal, Radio, Textarea } from 'ant-design-vue';
import { getThingModelTSL } from '#/api/iot/thingmodel';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
defineOptions({ name: 'ThingModelTsl' });
const dialogVisible = ref(false); //
const dialogTitle = ref('物模型 TSL'); //
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT); //
const viewMode = ref('view'); // view-editor-
/** 打开弹窗 */
async function open() {
dialogVisible.value = true;
await getTsl();
}
defineExpose({ open });
/** 获取 TSL */
const thingModelTSL = ref<any>({});
const tslString = ref(''); //
async function getTsl() {
try {
thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0);
// JSON
tslString.value = JSON.stringify(thingModelTSL.value, null, 2);
} catch (error) {
console.error('获取 TSL 失败:', error);
thingModelTSL.value = {};
tslString.value = '{}';
}
}
/** 格式化的 TSL 用于只读展示 */
const formattedTSL = computed(() => {
try {
if (typeof thingModelTSL.value === 'string') {
return JSON.stringify(JSON.parse(thingModelTSL.value), null, 2);
}
return JSON.stringify(thingModelTSL.value, null, 2);
} catch {
return JSON.stringify(thingModelTSL.value, null, 2);
}
});
/** 监听编辑器内容变化,实时更新数据 */
watch(tslString, (newValue) => {
try {
thingModelTSL.value = JSON.parse(newValue);
} catch {
// JSON
}
});
</script>
<template>
<Modal
v-model:open="dialogVisible"
:title="dialogTitle"
:footer="null"
width="800px"
>
<div class="mb-4">
<Radio.Group v-model:value="viewMode" size="small">
<Radio.Button value="view">代码视图</Radio.Button>
<Radio.Button value="editor">编辑器视图</Radio.Button>
</Radio.Group>
</div>
<!-- 代码视图 - 只读展示 -->
<div v-if="viewMode === 'view'" class="json-viewer-container">
<pre class="json-code"><code>{{ formattedTSL }}</code></pre>
</div>
<!-- 编辑器视图 - 可编辑 -->
<Textarea
v-else
v-model:value="tslString"
:rows="20"
placeholder="请输入 JSON 格式的物模型 TSL"
class="json-editor"
/>
</Modal>
</template>
<style scoped>
.json-viewer-container {
max-height: 600px;
padding: 12px;
overflow-y: auto;
background-color: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.json-code {
margin: 0;
font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: #333;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.json-editor {
font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, inject, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Radio, Textarea } from 'ant-design-vue';
import { getThingModelTSL } from '#/api/iot/thingmodel';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
const viewMode = ref<'editor' | 'view'>('view');
const thingModelTSL = ref<any>({});
const tslString = ref('');
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0);
tslString.value = JSON.stringify(thingModelTSL.value, null, 2);
} finally {
modalApi.unlock();
}
},
});
/** 只读视图下,格式化后的 TSL 字符串 */
const formattedTSL = computed(() =>
JSON.stringify(thingModelTSL.value, null, 2),
);
/** 编辑器内容变化时,同步到数据对象 */
watch(tslString, (newValue) => {
try {
thingModelTSL.value = JSON.parse(newValue);
} catch {
// JSON
}
});
</script>
<template>
<Modal :footer="false" class="w-3/5" title="物模型 TSL">
<div class="mx-4">
<div class="mb-4">
<Radio.Group v-model:value="viewMode" size="small">
<Radio.Button value="view">代码视图</Radio.Button>
<Radio.Button value="editor">编辑器视图</Radio.Button>
</Radio.Group>
</div>
<!-- 代码视图只读展示 -->
<div
v-if="viewMode === 'view'"
class="max-h-[600px] overflow-y-auto rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<pre
class="m-0 whitespace-pre-wrap break-words font-mono text-[13px] leading-normal"
><code>{{ formattedTSL }}</code></pre>
</div>
<!-- 编辑器视图可编辑 -->
<Textarea
v-else
v-model:value="tslString"
:rows="20"
class="font-mono text-[13px]"
placeholder="请输入 JSON 格式的物模型 TSL"
/>
</div>
</Modal>
</template>

View File

@ -1,9 +1,6 @@
// TODO @AI感觉这块放到 biz-iot-enum 里好点。
/** 检查值是否为空 */
const isEmpty = (value: any): boolean => {
return value === null || value === undefined || value === '';
};
import { isEmpty } from '@vben/utils';
/** IoT 依赖注入 KEY */
export const IOT_PROVIDE_KEY = {
@ -104,19 +101,19 @@ export const IoTDataSpecsDataTypeEnum = {
ARRAY: 'array',
};
export const getDataTypeOptions = () => {
return [
{ value: IoTDataSpecsDataTypeEnum.INT, label: '整数型' },
{ value: IoTDataSpecsDataTypeEnum.FLOAT, label: '单精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.DOUBLE, label: '双精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.ENUM, label: '枚举型' },
{ value: IoTDataSpecsDataTypeEnum.BOOL, label: '布尔型' },
{ value: IoTDataSpecsDataTypeEnum.TEXT, label: '文本型' },
{ value: IoTDataSpecsDataTypeEnum.DATE, label: '时间型' },
{ value: IoTDataSpecsDataTypeEnum.STRUCT, label: '结构体' },
{ value: IoTDataSpecsDataTypeEnum.ARRAY, label: '数组' },
];
};
const DATA_TYPE_OPTIONS = Object.freeze([
{ value: IoTDataSpecsDataTypeEnum.INT, label: '整数型' },
{ value: IoTDataSpecsDataTypeEnum.FLOAT, label: '单精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.DOUBLE, label: '双精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.ENUM, label: '枚举型' },
{ value: IoTDataSpecsDataTypeEnum.BOOL, label: '布尔型' },
{ value: IoTDataSpecsDataTypeEnum.TEXT, label: '文本型' },
{ value: IoTDataSpecsDataTypeEnum.DATE, label: '时间型' },
{ value: IoTDataSpecsDataTypeEnum.STRUCT, label: '结构体' },
{ value: IoTDataSpecsDataTypeEnum.ARRAY, label: '数组' },
]);
export const getDataTypeOptions = () => DATA_TYPE_OPTIONS;
/** 获得物体模型数据类型配置项名称 */
export const getDataTypeOptionsLabel = (value: string) => {