Browse Source

feat: 云端命令、属性、消息下发

main
刘凯 1 year ago
parent
commit
46fe56cd7d
  1. 16
      src/api/device-manage/device/cloud-command.ts
  2. 19
      src/api/device-manage/device/types.ts
  3. 12
      src/api/product/model.ts
  4. 9
      src/api/product/topic.ts
  5. 26
      src/api/product/types.ts
  6. 36
      src/views/device-manage/device/components/CloudCommand.vue
  7. 10
      src/views/device-manage/device/components/DeviceInfo.vue
  8. 175
      src/views/device-manage/device/components/SendCommandModal.vue
  9. 12
      src/views/device-manage/device/components/composables/useDeviceInfo.ts
  10. 10
      src/views/device-manage/device/detail.vue
  11. 20
      src/views/product/components/ModelAttributeFormModal.vue

16
src/api/device-manage/device/cloud-command.ts

@ -1,4 +1,4 @@
import type { GetCloudCommandLogsParams } from './types'
import type { CommandToDeviceData, GetCloudCommandLogsParams, MessageToDeviceData } from './types'
import { defHttp } from '@/utils/http/axios'
export function getCloudCommandLogs(params: GetCloudCommandLogsParams) {
@ -16,3 +16,17 @@ export function getMessageContent(id: string) {
},
})
}
export function sendMessageToDevice(data: MessageToDeviceData) {
return defHttp.post({
url: '/cloud/sendMessage',
data,
})
}
export function sendCommandToDevice(data: CommandToDeviceData) {
return defHttp.post({
url: '/cloud/sendCommand',
data,
})
}

19
src/api/device-manage/device/types.ts

@ -25,3 +25,22 @@ export interface DevicePropertie {
export interface GetCloudCommandLogsParams extends PageParam {
messageType?: number
}
export enum CloudCommandType {
Attribute = 1,
Command = 2,
Message = 3,
}
export interface MessageToDeviceData {
deviceId: string
topic: string
message: string
}
export interface CommandToDeviceData {
deviceId: string
modelId: string
itemId: string
value: string | number
}

12
src/api/product/model.ts

@ -1,4 +1,4 @@
import type { GetModelAttributeListParams, ModelAttribute, ModelAttributeWithForm, ModelService } from './types'
import type { GetModelAttributeListParams, ModelAttribute, ModelAttributeWithForm, ModelService, SimpleAttribute } from './types'
import { defHttp } from '@/utils/http/axios'
export function getAllModelServices(productId: string) {
@ -56,3 +56,13 @@ export function deleteModelAttribute(id: string) {
url: `/thingModel/item/remove?id=${id}`,
})
}
export function getAllModelAttributes(productId: string, itemType: number) {
return defHttp.get<SimpleAttribute[]>({
url: '/thingModel/item/queryList',
params: {
productId,
itemType,
},
})
}

9
src/api/product/topic.ts

@ -27,3 +27,12 @@ export function deleteTopic(id: string) {
url: `/product/topic/remove?id=${id}`,
})
}
export function getAllTopics(productId: string) {
return defHttp.get<Topic[]>({
url: '/product/topic/list',
params: {
productId,
},
})
}

26
src/api/product/types.ts

@ -57,6 +57,13 @@ export interface GetModelAttributeListParams extends PageParam {
modelId?: string
}
export enum ModelAttributeDataTypesEnum {
Int32 = 'int32',
Float = 'float',
Bool = 'bool',
Text = 'text',
}
export interface ModelAttribute {
id: string
name: string
@ -67,6 +74,7 @@ export interface ModelAttribute {
tenantId: string
method: string
sort: number
dataType: ModelAttributeDataTypesEnum
dataSpecs?: {
min?: number
max?: number
@ -82,7 +90,7 @@ export interface ModelAttributeWithForm {
name: string
itemType: number
identifier: string
dataType: number
dataType: ModelAttributeDataTypesEnum
min: number
max: number
maxLength: number
@ -94,3 +102,19 @@ export interface ModelAttributeWithForm {
sort: number
modelId: string
}
export interface SimpleAttribute {
dataType: ModelAttributeDataTypesEnum
itemType: number
modelId: string
modelName: string
name: string
dataSpecs: {
min: number
max: number
maxLength: number
trueDesc: string
falseDesc: string
scale: string
}
}

36
src/views/device-manage/device/components/CloudCommand.vue

@ -1,33 +1,41 @@
<script lang="ts" setup>
import { computed, h, ref } from 'vue'
import { computed, h, ref, unref } from 'vue'
import { CloudSyncOutlined } from '@ant-design/icons-vue'
import { Alert, Segmented } from 'ant-design-vue'
import MessageModal from './MessageModal.vue'
import SendCommandModal from './SendCommandModal.vue'
import { BasicTable, useTable } from '@/components/Table'
import { useModal } from '@/components/Modal'
import { getCloudCommandLogs, getMessageContent } from '@/api/device-manage/device/cloud-command'
import type { Device } from '@/api/device-manage/device/types'
import { CloudCommandType } from '@/api/device-manage/device/types'
defineProps<{ device?: Device }>()
const [registerModal, { openModal }] = useModal<CloudCommandType>()
const commandTypes = [
{
label: '命令下发',
value: 2,
value: CloudCommandType.Command,
desc: '如果设备所属产品定义了命令功能,则您可以通过应用调用平台接口或者操作 “下发” 按钮下发命令,当前MQTT设备仅支持同步命令下发,NB设备仅支持异步命令下发。',
},
{
label: '属性下发',
value: 1,
value: CloudCommandType.Attribute,
desc: '属性下发依赖产品模型,平台会以异步方式(属性下发后无需等待设备侧回复相应)下发消息给设备,当前仅MQTT设备支持属性下发。',
},
{
label: '消息下发',
value: 3,
value: CloudCommandType.Message,
desc: '消息下发不依赖产品模型,平台会以异步方式(消息下发后无需等待设备侧回复相应)下发消息给设备,当前仅MQTT设备支持消息下发。',
},
]
const currentCommandType = ref(2)
const currentCommandTypeDesc = computed(() => commandTypes.find(item => item.value === currentCommandType.value)?.desc)
const selectedCommonType = ref(CloudCommandType.Command)
const selectedCommon = computed(() => commandTypes.find(item => item.value === selectedCommonType.value))
const [register, { reload }] = useTable({
api: params => getCloudCommandLogs({ ...params, messageType: currentCommandType.value }),
api: params => getCloudCommandLogs({ ...params, messageType: unref(selectedCommon)?.value }),
columns: [
{
title: '追踪 ID',
@ -62,18 +70,26 @@ const [register, { reload }] = useTable({
<template>
<div>
<div flex="~ items-center gap-12px" mb="12px">
<Segmented v-model:value="currentCommandType" :options="commandTypes" @change="() => reload()" />
<a-button type="primary">
<Segmented v-model:value="selectedCommonType" :options="commandTypes" @change="() => reload()" />
<a-button type="primary" @click="openModal(true, selectedCommon?.value)">
<CloudSyncOutlined />下发
</a-button>
<Alert
type="info"
show-icon
class="py-4px text-13px"
:message="currentCommandTypeDesc"
:message="selectedCommon?.desc"
/>
</div>
<BasicTable @register="register" />
<SendCommandModal
:title="selectedCommon?.label"
:type="selectedCommon?.value"
:product-id="device?.productId"
@register="registerModal"
@success="reload"
/>
</div>
</template>

10
src/views/device-manage/device/components/DeviceInfo.vue

@ -1,4 +1,5 @@
<script lang="ts" setup>
import { toRef } from 'vue'
import { Card, Empty } from 'ant-design-vue'
import { FieldTimeOutlined, SyncOutlined } from '@ant-design/icons-vue'
import { useDeviceInfo } from './composables/useDeviceInfo'
@ -7,20 +8,23 @@ import { Description } from '@/components/Description'
import { useModelService } from '@/views/product/components/composables/useModelService'
import { usePermission } from '@/hooks/web/usePermission'
import { ProductTabEnums } from '@/views/product/data'
import type { Device } from '@/api/device-manage/device/types'
const { data, scheam } = useDeviceInfo()
const props = defineProps<{ data?: Device }>()
const { scheam } = useDeviceInfo(toRef(props, 'data'))
const {
modelServiceList,
selectedModelId,
setSelectedModelId,
} = useModelService(() => data.value?.productId)
} = useModelService(() => props.data?.productId)
const {
isLoading,
deviceProperties,
reloadReviceProperties,
} = useDeviceProperties(() => selectedModelId.value, () => data.value?.deviceSn)
} = useDeviceProperties(() => selectedModelId.value, () => props.data?.deviceSn)
const { hasPermission } = usePermission()
</script>

175
src/views/device-manage/device/components/SendCommandModal.vue

@ -0,0 +1,175 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import { BasicModal, useModalInner } from '@/components/Modal'
import type { FormSchema, FormSchemaInner, Rule } from '@/components/Form'
import { BasicForm, useForm } from '@/components/Form'
import { CloudCommandType } from '@/api/device-manage/device/types'
import { getAllTopics } from '@/api/product/topic'
import { sendCommandToDevice, sendMessageToDevice } from '@/api/device-manage/device/cloud-command'
import CodeEditor from '@/components/CodeEditor/src/CodeEditor.vue'
import { getAllModelAttributes } from '@/api/product/model'
import { ModelAttributeDataTypesEnum, type SimpleAttribute } from '@/api/product/types'
const props = defineProps<{ productId?: string, type?: CloudCommandType }>()
const emit = defineEmits(['register', 'success'])
const [registerForm, { updateSchema, resetSchema, validate, setFieldsValue, clearValidate }] = useForm({
schemas: [],
labelWidth: 100,
baseColProps: { span: 24 },
showActionButtonGroup: false,
actionColOptions: { span: 23 },
})
const CommandAndAttributeSchemas: FormSchema[] = [
{
field: 'itemId',
label: '选择属性',
required: true,
component: 'ApiSelect',
componentProps: {
api: () => getAllModelAttributes(props.productId!, props.type!),
valueField: 'id',
labelField: 'name',
allowClear: false,
onChange(_, option: SimpleAttribute & { label: string }) { // label is SimpleAttribute.name
if (!option)
return
const { dataType, label, dataSpecs, modelId } = option
setFieldsValue({ modelId, value: undefined })
// avoid non-null check
clearValidate()
let schema: Partial<FormSchemaInner> = {}
if (dataType === ModelAttributeDataTypesEnum.Bool) {
schema = {
rules: [], // remove rules cache
component: 'RadioGroup',
componentProps: {
options: [
{ label: dataSpecs.falseDesc, value: 0 },
{ label: dataSpecs.trueDesc, value: 1 },
],
},
}
}
else {
let rules: Rule[] = []
switch (dataType) {
case ModelAttributeDataTypesEnum.Text:
rules = [{ max: dataSpecs.maxLength, message: `限制最大长度为 ${dataSpecs.maxLength}` }]
break
case ModelAttributeDataTypesEnum.Float:
case ModelAttributeDataTypesEnum.Int32:
rules = [
{ type: 'number', min: +dataSpecs.min, max: +dataSpecs.max, message: `数值范围为: ${dataSpecs.min}-${dataSpecs.max}` },
]
break
}
schema = {
rules,
// if it isn't text, then it's a float or int32
component: dataType === ModelAttributeDataTypesEnum.Text ? 'InputTextArea' : 'InputNumber',
componentProps: dataType !== ModelAttributeDataTypesEnum.Text
? {
precision: dataType === ModelAttributeDataTypesEnum.Float ? dataSpecs.scale : 0,
}
: { rows: 3 },
}
}
updateSchema({
label,
field: 'value',
ifShow: true,
...schema,
})
},
},
},
{
field: 'value',
fields: ['modelId'],
required: true,
component: 'Input',
ifShow: false,
},
]
const MessageSchemas: FormSchema[] = [
{
field: 'topic',
label: 'Topic',
required: true,
component: 'ApiSelect',
componentProps: {
api: () => getAllTopics(props.productId!),
valueField: 'id',
labelField: 'topic',
},
},
{
field: 'message',
label: '指令内容',
rules: [
{
async validator(_, value) {
try {
const code = JSON.stringify(JSON.parse(value))
if (code === '{}')
// eslint-disable-next-line unicorn/error-message
throw new Error()
}
catch {
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject('指令内容必须是 JSON 格式, 且不能为空')
}
},
required: true,
},
],
slot: 'Message',
helpMessage: '指令内容必须是 JSON 格式',
defaultValue: {},
componentProps: {
rows: 10,
},
},
]
const [register, { closeModal, setModalProps }] = useModalInner((type: CloudCommandType) => {
resetSchema(type === CloudCommandType.Message ? MessageSchemas : CommandAndAttributeSchemas)
})
const route = useRoute()
async function handleSubmit() {
try {
const values = await validate<any>()
setModalProps({ confirmLoading: true })
values.deviceId = route.params.id
const isSendMessage = props.type === CloudCommandType.Message
await (isSendMessage ? sendMessageToDevice(values) : sendCommandToDevice(values))
emit('success')
closeModal()
}
catch {
setModalProps({ confirmLoading: false })
}
}
</script>
<template>
<BasicModal
v-bind="$attrs"
:min-height="100"
@register="register"
@ok="handleSubmit"
>
<BasicForm @register="registerForm">
<template #Message="{ model, field }">
<div h="200px" border="1px solid gray-200" w-full overflow-hidden rounded>
<CodeEditor v-model:value="model[field]" />
</div>
</template>
</BasicForm>
</BasicModal>
</template>

12
src/views/device-manage/device/components/composables/useDeviceInfo.ts

@ -1,14 +1,14 @@
import type { Ref } from 'vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import { useRoute } from 'vue-router'
import { useAsyncState } from '@vueuse/core'
import MqttParamsModal from '../MqttParamsModal.vue'
import MessageModal from '../MessageModal.vue'
import type { DescItem } from '@/components/Description'
import { getDeviceDetail, getReportExample } from '@/api/device-manage/device'
import { getReportExample } from '@/api/device-manage/device'
import { usePermission } from '@/hooks/web/usePermission'
import type { Device } from '@/api/device-manage/device/types'
export function useDeviceInfo() {
export function useDeviceInfo(data: Ref<Device | undefined>) {
const { hasPermission } = usePermission()
const scheam: DescItem[] = [
@ -78,11 +78,7 @@ export function useDeviceInfo() {
},
]
const route = useRoute()
const { state: data } = useAsyncState(() => getDeviceDetail(route.params.id as string), undefined)
return {
data,
scheam,
}
}

10
src/views/device-manage/device/detail.vue

@ -1,10 +1,16 @@
<script lang='ts' setup>
import { Card, Tabs } from 'ant-design-vue'
import { useAsyncState } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { CloudCommand, DeviceInfo, TopicList } from './components'
import { usePermission } from '@/hooks/web/usePermission'
import { getDeviceDetail } from '@/api/device-manage/device'
defineOptions({ name: 'DeviceDetail' })
const route = useRoute()
const { state: data } = useAsyncState(() => getDeviceDetail(route.params.id as string), undefined)
const { hasPermission } = usePermission()
</script>
@ -13,13 +19,13 @@ const { hasPermission } = usePermission()
<Card title="设备详情">
<Tabs>
<Tabs.TabPane key="1" tab="设备信息">
<DeviceInfo />
<DeviceInfo :data="data" />
</Tabs.TabPane>
<Tabs.TabPane v-if="hasPermission('device_topic_view')" key="2" tab="已订阅 Topic">
<TopicList />
</Tabs.TabPane>
<Tabs.TabPane v-if="hasPermission('device_cloud_command_view')" key="3" tab="云端下发">
<CloudCommand />
<CloudCommand :device="data" />
</Tabs.TabPane>
</Tabs>
</Card>

20
src/views/product/components/ModelAttributeFormModal.vue

@ -5,6 +5,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { BasicForm, useForm } from '@/components/Form'
import { BasicModal, useModalInner } from '@/components/Modal'
import { createModelAttribute, updateModelAttribute } from '@/api/product/model'
import { ModelAttributeDataTypesEnum } from '@/api/product/types'
import type { ModelAttribute, ModelAttributeWithForm } from '@/api/product/types'
defineOptions({ name: 'ModelAttributeFormModal' })
@ -12,13 +13,6 @@ const props = defineProps<{ modelId: string }>()
const emit = defineEmits(['success', 'register'])
enum DataTypesEnum {
Int32 = 'int32',
Float = 'float',
Bool = 'bool',
Text = 'text',
}
const isUpdate = ref(false)
const [registerForm, { setFieldsValue, validate }] = useForm({
labelWidth: 80,
@ -61,7 +55,7 @@ const [registerForm, { setFieldsValue, validate }] = useForm({
required: true,
component: 'Select',
componentProps: {
options: Object.values(DataTypesEnum).map(value => ({ label: value, value })),
options: Object.values(ModelAttributeDataTypesEnum).map(value => ({ label: value, value })),
},
dynamicDisabled: () => isUpdate.value,
},
@ -77,7 +71,7 @@ const [registerForm, { setFieldsValue, validate }] = useForm({
autoLink: false,
},
defaultValue: '_', // skip the required check
ifShow: ({ values }) => [DataTypesEnum.Int32, DataTypesEnum.Float].includes(values.dataType),
ifShow: ({ values }) => [ModelAttributeDataTypesEnum.Int32, ModelAttributeDataTypesEnum.Float].includes(values.dataType),
},
{
field: 'scale',
@ -88,13 +82,13 @@ const [registerForm, { setFieldsValue, validate }] = useForm({
componentProps: {
precision: 0,
},
ifShow: ({ values }) => values.dataType === DataTypesEnum.Float,
ifShow: ({ values }) => values.dataType === ModelAttributeDataTypesEnum.Float,
},
{
field: 'unit',
label: '单位',
component: 'Input',
ifShow: ({ values }) => [DataTypesEnum.Int32, DataTypesEnum.Float].includes(values.dataType),
ifShow: ({ values }) => [ModelAttributeDataTypesEnum.Int32, ModelAttributeDataTypesEnum.Float].includes(values.dataType),
},
// bool schemas
@ -108,7 +102,7 @@ const [registerForm, { setFieldsValue, validate }] = useForm({
autoLink: false,
},
defaultValue: '_', // skip the required check
ifShow: ({ values }) => values.dataType === DataTypesEnum.Bool,
ifShow: ({ values }) => values.dataType === ModelAttributeDataTypesEnum.Bool,
},
// text schemas
@ -120,7 +114,7 @@ const [registerForm, { setFieldsValue, validate }] = useForm({
componentProps: {
precision: 0,
},
ifShow: ({ values }) => values.dataType === DataTypesEnum.Text,
ifShow: ({ values }) => values.dataType === ModelAttributeDataTypesEnum.Text,
},
{

Loading…
Cancel
Save