feat: 设备详情modbus和opcua

This commit is contained in:
100011797 2023-02-22 18:09:48 +08:00
parent ea98e6c788
commit 474bc29aab
18 changed files with 1573 additions and 198 deletions

View File

@ -83,22 +83,22 @@ export const batchDeleteDevice = (data: string[]) => server.put(`/device-instanc
* @param type
* @returns
*/
export const deviceTemplateDownload = (productId: string, type: string) => `${BASE_API_PATH}/device-instance/${productId}/template.${type}`
export const deviceTemplateDownload = (productId: string, type: string) => `${BASE_API_PATH}/device-instance/${productId}/template.${type}`
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceImport = (productId: string, fileUrl: string, autoDeploy: boolean) => `${BASE_API_PATH}/device-instance/${productId}/import?fileUrl=${fileUrl}&autoDeploy=${autoDeploy}&:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`
/**
*
* @param productId id
* @param type
* @returns
*/
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceImport = (productId: string, fileUrl: string, autoDeploy: boolean) => `${BASE_API_PATH}/device-instance/${productId}/import?fileUrl=${fileUrl}&autoDeploy=${autoDeploy}&:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceExport = (productId: string, type: string) => `${BASE_API_PATH}/device-instance${!!productId ? '/' + productId : ''}/export.${type}`
/**
@ -143,7 +143,7 @@ export const _disconnect = (id: string) => server.post(`/device-instance/${id}/d
*/
export const queryUserListNoPaging = () => server.post(`/user/_query/no-paging`, {
paging: false,
sorts: [{name: 'name', order: "asc"}]
sorts: [{ name: 'name', order: "asc" }]
})
/**
@ -347,4 +347,59 @@ export const settingProperties = (deviceId: string, data: any) => server.put(`/d
* @param data
* @returns
*/
export const execute = (id: string, action: string, data: any) => server.post(`/device/invoked/${id}/function/${action}`, data)
export const execute = (id: string, action: string, data: any) => server.post(`/device/invoked/${id}/function/${action}`, data)
/**
*
* @param data
* @returns
*/
export const queryChannelNoPaging = (data: any) => server.post(`data-collect/channel/_query/no-paging`, data)
/**
*
* @param data
* @returns
*/
export const queryCollectorNoPaging = (data: any) => server.post(`/data-collect/collector/_query/no-paging`, data)
/**
*
* @param data
* @returns
*/
export const queryPointNoPaging = (data: any) => server.post(`/data-collect/point/_query/no-paging`, data)
/**
*
* @param thingType
* @param thingId
* @param params
* @returns
*/
export const queryMapping = (thingType: string, thingId: any, params?: any) => server.get(`/things/collector/${thingType}/${thingId}/_query`, params)
/**
*
* @param thingType
* @param thingId
* @param data
* @returns
*/
export const removeMapping = (thingType: string, thingId: any, data?: any) => server.post(`/things/collector/${thingType}/${thingId}/_delete`, data)
/**
*
* @param data
* @returns
*/
export const treeMapping = (data?: any) => server.post(`/data-collect/channel/_all/tree`, data)
/**
*
* @param thingId
* @param provider
* @param data
* @returns
*/
export const saveMapping = (thingId: any, provider: string, data?: any) => server.patch(`/things/collector/device/${thingId}/${provider}`, data)

View File

@ -49,6 +49,7 @@ const iconKeys = [
'PartitionOutlined',
'ShareAltOutlined',
'playCircleOutlined',
'RightOutlined'
]
const Icon = (props: {type: string}) => {

View File

@ -1,20 +1,47 @@
<template>
<div class="dialog-item" :key="data.key" :class="{'dialog-active' : !data?.upstream}">
<div
class="dialog-item"
:key="data.key"
:class="{ 'dialog-active': !data?.upstream }"
>
<div class="dialog-card">
<div class="dialog-list" v-for="item in data.list" :key="item.key">
<div class="dialog-icon">
<AIcon :type="visible.includes(item.key) ? 'DownOutlined' : 'RightOutlined'" />
<div class="dialog-icon" @click="getDetail(item)">
<AIcon
v-if="visible.includes(item.key)"
type="DownOutlined"
/>
<AIcon v-else type="RightOutlined" />
</div>
<div class="dialog-box">
<div class="dialog-header">
<div class="dialog-title">
<a-badge :color="statusColor.get(item.error ? 'error' : 'success')" style="margin-right: 5px" />
{{operationMap.get(item.operation) || item?.operation}}
<a-badge
:color="
statusColor.get(
item.error ? 'error' : 'success',
)
"
style="margin-right: 5px"
/>
{{
operationMap.get(item.operation) ||
item?.operation
}}
</div>
<div class="dialog-time">
{{
moment(item.endTime).format(
'YYYY-MM-DD HH:mm:ss',
)
}}
</div>
<div class="dialog-item">{{moment(item.endTime).format('YYYY-MM-DD HH:mm:ss')}}</div>
</div>
<div class="dialog-editor" v-if="visible.includes(item.key)">
<a-textarea :bordered="false" :value="item?.detail" />
<div
class="dialog-editor"
v-if="visible.includes(item.key)"
>
<a-textarea autoSize :bordered="false" :value="item?.detail" />
</div>
</div>
</div>
@ -24,7 +51,7 @@
<script lang="ts" setup>
const operationMap = new Map();
import moment from 'moment'
import moment from 'moment';
operationMap.set('connection', '连接');
operationMap.set('auth', '权限验证');
operationMap.set('decode', '解码');
@ -41,102 +68,113 @@ statusColor.set('success', '#24B276');
const props = defineProps({
data: {
type: Object,
default: () => {}
default: () => {},
},
});
const visible = ref<string[]>([]);
const getDetail = (item: any) => {
const index = visible.value.indexOf(item.key);
if (index === -1) {
visible.value.push(item.key);
} else {
visible.value.splice(index, 1);
}
})
const visible = ref<string[]>([])
};
watchEffect(() => {
console.log(props.data)
})
</script>
<style lang="less" scoped>
@import 'ant-design-vue/es/style/themes/default.less';
:root {
--dialog-primary-color: @primary-color;
--dialog-primary-color: @primary-color;
}
.dialog-item {
display: flex;
justify-content: flex-start;
width: 100%;
padding-bottom: 12px;
.dialog-card {
display: flex;
flex-direction: column;
width: 60%;
padding: 24px;
background-color: #fff;
justify-content: flex-start;
width: 100%;
padding-bottom: 12px;
.dialog-list {
display: flex;
.dialog-icon {
margin-right: 10px;
color: rgba(0, 0, 0, 0.75);
font-weight: 500;
font-size: 12px;
}
.dialog-box {
.dialog-card {
display: flex;
flex-direction: column;
width: 100%;
width: 60%;
padding: 24px;
background-color: #fff;
.dialog-header {
.dialog-title {
color: rgba(0, 0, 0, 0.75);
font-weight: 700;
font-size: 14px;
}
.dialog-list {
display: flex;
.dialog-time {
color: rgba(0, 0, 0, 0.65);
font-size: 12px;
}
.dialog-icon {
margin-right: 10px;
color: rgba(0, 0, 0, 0.75);
font-weight: 500;
font-size: 12px;
}
.dialog-box {
display: flex;
flex-direction: column;
width: 100%;
.dialog-header {
.dialog-title {
color: rgba(0, 0, 0, 0.75);
font-weight: 700;
font-size: 14px;
}
.dialog-time {
color: rgba(0, 0, 0, 0.65);
font-size: 12px;
}
}
.dialog-editor {
width: 100%;
margin-top: 10px;
color: rgba(0, 0, 0, 0.75);
textarea::-webkit-scrollbar {
width: 5px !important;
}
}
}
}
.dialog-editor {
width: 100%;
margin-top: 10px;
color: rgba(0, 0, 0, 0.75);
textarea::-webkit-scrollbar {
width: 5px !important;
}
}
}
}
}
}
.dialog-active {
display: flex;
justify-content: flex-end;
.dialog-card {
background-color: @primary-color;
display: flex;
justify-content: flex-end;
.dialog-card {
background-color: @primary-color;
.dialog-list {
.dialog-icon {
color: #fff;
}
.dialog-list {
.dialog-icon {
color: #fff;
}
.dialog-box {
.dialog-header {
.dialog-title,
.dialog-time {
color: #fff;
}
.dialog-box {
.dialog-header {
.dialog-title,
.dialog-time {
color: #fff;
}
}
.dialog-editor {
textarea {
color: #fff !important;
background-color: @primary-color !important;
}
}
}
}
.dialog-editor {
textarea {
color: #fff !important;
background-color: @primary-color !important;
}
}
}
}
}
}
</style>

View File

@ -67,7 +67,7 @@
message: '请输入值',
}"
>
<a-input v-model:value="propertyValue" />
<a-input v-model:value="modelRef.propertyValue" />
</a-form-item>
</a-col>
<a-col :span="6" v-if="modelRef.type === 'INVOKE_FUNCTION'">

View File

@ -1,11 +1,17 @@
<template>
<a-row :gutter="24">
<a-col :span="16">
<a-row :gutter="24" style="margin-bottom: 20px;">
<a-row :gutter="24" style="margin-bottom: 20px">
<a-col :span="12" v-for="item in messageArr" :key="item">
<div :style="messageStyleMap.get(item.status)" class="message-status">
<a-badge :status="messageStatusMap.get(item.status)" style="margin-right: 5px;" />
<span>{{item.text}}</span>
<div
:style="messageStyleMap.get(item.status)"
class="message-status"
>
<a-badge
:status="messageStatusMap.get(item.status)"
style="margin-right: 5px"
/>
<span>{{ item.text }}</span>
</div>
</a-col>
</a-row>
@ -26,7 +32,11 @@
<TitleComponent data="日志" />
<div :style="{ marginTop: '10px' }">
<template v-if="logList.length">
<Log v-for="item in logList" :data="item" :key="item.key" />
<Log
v-for="item in logList"
:data="item"
:key="item.key"
/>
</template>
<a-empty v-else />
</div>
@ -36,58 +46,146 @@
</template>
<script lang="ts" setup>
import type { MessageType } from './util'
import { messageStatusMap, messageStyleMap } from './util'
import Dialog from './Dialog/index.vue'
import Function from './Function/index.vue'
import Log from './Log/index.vue'
import type { MessageType } from './util';
import { messageStatusMap, messageStyleMap } from './util';
import Dialog from './Dialog/index.vue';
import Function from './Function/index.vue';
import Log from './Log/index.vue';
import { map } from 'rxjs/operators';
import { useInstanceStore } from '@/store/instance';
import { getWebSocket } from '@/utils/websocket';
import { randomString } from '@/utils/utils';
import _ from 'lodash';
const message = reactive<MessageType>({
up: {
text: '上行消息诊断中',
status: 'loading',
text: '上行消息诊断中',
status: 'loading',
},
down: {
text: '下行消息诊断中',
status: 'loading',
text: '下行消息诊断中',
status: 'loading',
},
})
});
const dialogList = ref<Record<string, any>>([])
const logList = ref<Record<string, any>>([])
const instanceStore = useInstanceStore();
const allDialogList = ref<Record<string, any>[]>([]);
const dialogList = ref<Record<string, any>[]>([]);
const logList = ref<Record<string, any>[]>([]);
const diagnoseRef = ref();
const messageArr = computed(() => {
const arr = Object.keys(message) || []
return arr.map(i => { return {...message[i], key: i}})
})
const arr = Object.keys(message) || [];
return arr.map((i) => {
return { ...message[i], key: i };
});
});
const subscribeLog = () => {
const id = `device-debug-${instanceStore.current?.id}`;
const topic = `/debug/device/${instanceStore.current?.id}/trace`;
diagnoseRef.value = getWebSocket(id, topic, {})
?.pipe(map((res: any) => res.payload))
.subscribe((payload) => {
if (payload.type === 'log') {
logList.value.push({
key: randomString(),
...payload,
});
} else {
const data = { key: randomString(), ...payload };
allDialogList.value.push(data);
const flag = allDialogList.value
.filter(
(i: any) =>
i.traceId === data.traceId &&
(data.downstream === i.downstream ||
data.upstream === i.upstream),
)
.every((item: any) => {
return !item.error;
});
if (!data.upstream) {
message.down = {
text: !flag ? '下行消息通信异常' : '下行消息通信正常',
status: !flag ? 'error' : 'success',
};
} else {
message.up = {
text: !flag ? '上行消息通信异常' : '上行消息通信正常',
status: !flag ? 'error' : 'success',
};
}
const list: any[] = _.cloneDeep(dialogList.value);
const t = list.find(
(item) =>
item.traceId === data.traceId &&
data.downstream === item.downstream &&
data.upstream === item.upstream,
);
if (t) {
const arr = list.map((item) => {
if (item.traceId === data.traceId) {
item.list.push(data);
}
return item;
});
dialogList.value = _.cloneDeep(arr);
} else {
list.push({
key: randomString(),
traceId: data.traceId,
downstream: data.downstream,
upstream: data.upstream,
list: [data],
});
dialogList.value = _.cloneDeep(list);
}
}
const chatBox = document.getElementById('dialog');
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
}
});
};
}
const topState: any = inject('topState') || '';
watchEffect(() => {
if (topState && topState?.value === 'success') {
subscribeLog();
}
});
onUnmounted(() => {
if (diagnoseRef.value) {
diagnoseRef.value.unsubscribe();
}
});
</script>
<style lang="less" scoped>
.message-status {
padding: 8px 24px;
padding: 8px 24px;
}
.content {
width: 100%;
width: 100%;
}
.dialog {
width: 100%;
min-height: 300px;
max-height: 500px;
padding: 24px;
overflow: hidden;
overflow-y: auto;
background-color: #f2f5f7;
width: 100%;
min-height: 300px;
max-height: 500px;
padding: 24px;
overflow: hidden;
overflow-y: auto;
background-color: #f2f5f7;
}
.right-log {
padding-left: 20px;
border-left: 1px solid rgba(0, 0, 0, .09);
border-left: 1px solid rgba(0, 0, 0, 0.09);
overflow: hidden;
max-height: 600px;
overflow-y: auto;

View File

@ -34,8 +34,8 @@
</div>
</div>
<div>
<Message v-if="activeKey === 'message'" />
<Status v-else :providerType="providerType" @countChange="countChange" @percentChange="percentChange" @stateChange="stateChange" />
<Message v-show="activeKey === 'message'" />
<Status v-show="activeKey !== 'message'" :providerType="providerType" @countChange="countChange" @percentChange="percentChange" @stateChange="stateChange" />
</div>
</div>
</a-card>
@ -70,6 +70,7 @@ const percent = ref<number>(0)
const activeKey = ref<'status' | 'message'>('status')
const providerType = ref()
provide('topState', topState)
const onTabChange = (key: 'status' | 'message') => {
if(topState.value === 'success'){

View File

@ -0,0 +1,289 @@
<template>
<a-spin :spinning="loading">
<a-card>
<template #extra>
<a-space>
<a-button @click="visible = true">批量映射</a-button>
<a-button type="primary" @click="onSave">保存</a-button>
</a-space>
</template>
<a-form ref="formRef" :model="modelRef">
<a-table :dataSource="modelRef.dataSource" :columns="columns">
<template #headerCell="{ column }">
<template v-if="column.key === 'collectorId'">
采集器
<a-tooltip title="边缘网关代理的真实物理设备">
<AIcon type="QuestionCircleOutlined" />
</a-tooltip>
</template>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'channelId'">
<a-form-item
:name="['dataSource', index, 'channelId']"
>
<a-select
style="width: 100%"
v-model:value="record[column.dataIndex]"
placeholder="请选择"
allowClear
:filter-option="filterOption"
>
<a-select-option
v-for="item in channelList"
:key="item.value"
:value="item.value"
:label="item.label"
>{{ item.label }}</a-select-option
>
</a-select>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'collectorId'">
<a-form-item
:name="['dataSource', index, 'collectorId']"
:rules="[
{
required: !!record.channelId,
message: '请选择采集器',
},
]"
>
<MSelect
v-model="record[column.dataIndex]"
:id="record.channelId"
type="COLLECTOR"
/>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'pointId'">
<a-form-item
:name="['dataSource', index, 'pointId']"
:rules="[
{
required: !!record.channelId,
message: '请选择点位',
},
]"
>
<MSelect
v-model="record[column.dataIndex]"
:id="record.collectorId"
type="POINT"
/>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'id'">
<a-badge
v-if="record[column.dataIndex]"
status="success"
text="已绑定"
/>
<a-badge v-else status="error" text="未绑定" />
</template>
<template v-if="column.key === 'action'">
<a-tooltip title="解绑">
<a-popconfirm
title="确认解绑"
@confirm="unbind(record.id)"
>
<a-button type="link" :disabled="!record.id"
><AIcon type="icon-jiebang"
/></a-button>
</a-popconfirm>
</a-tooltip>
</template>
</template>
</a-table>
</a-form>
</a-card>
<!-- <PatchMapping
:deviceId="instanceStore.current.id"
v-if="visible"
@close="visible = false"
@save="onPatchBind"
:type="provider"
:metaData="modelRef.dataSource"
/> -->
</a-spin>
</template>
<script lang="ts" setup>
import { useInstanceStore } from '@/store/instance';
import {
queryMapping,
saveMapping,
removeMapping,
queryChannelNoPaging,
} from '@/api/device/instance';
import MSelect from '../components/MSelect.vue';
// import PatchMapping from '../components/PatchMapping.vue';
import { message } from 'ant-design-vue/es';
const columns = [
{
title: '名称',
dataIndex: 'metadataName',
key: 'metadataName',
width: '20%',
},
{
title: '通道',
dataIndex: 'channelId',
key: 'channelId',
width: '20%',
},
{
title: '采集器',
dataIndex: 'collectorId',
key: 'collectorId',
width: '20%',
},
{
title: '点位',
key: 'pointId',
dataIndex: 'pointId',
width: '20%',
},
{
title: '状态',
key: 'id',
dataIndex: 'id',
width: '10%',
},
{
title: '操作',
key: 'action',
width: '10%',
},
];
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
const props = defineProps({
provider: {
type: String,
default: 'MODBUS_TCP',
},
});
const instanceStore = useInstanceStore();
const metadata = JSON.parse(instanceStore.current?.metadata || '{}');
const loading = ref<boolean>(false);
const channelList = ref([]);
const modelRef = reactive({
dataSource: [],
});
const formRef = ref();
const visible = ref<boolean>(false);
const getChannel = async () => {
const resp: any = await queryChannelNoPaging({
paging: false,
terms: [
{
terms: [
{
column: 'provider',
value: props.provider,
},
],
},
],
});
if (resp.status === 200) {
channelList.value = resp.result?.map((item: any) => ({
label: item.name,
value: item.id,
provider: item.provider,
}));
}
};
const handleSearch = async () => {
loading.value = true;
getChannel();
const _metadata = metadata.properties.map((item: any) => ({
metadataId: item.id,
metadataName: `${item.name}(${item.id})`,
metadataType: 'property',
name: item.name,
}));
if (_metadata && _metadata.length) {
const resp: any = await queryMapping(
'device',
instanceStore.current.id,
);
if (resp.status === 200) {
const array = resp.result.reduce((x: any, y: any) => {
const metadataId = _metadata.find(
(item: any) => item.metadataId === y.metadataId,
);
if (metadataId) {
Object.assign(metadataId, y);
} else {
x.push(y);
}
return x;
}, _metadata);
modelRef.dataSource = array;
}
}
loading.value = false;
};
const unbind = async (id: string) => {
if (id) {
const resp = await removeMapping('device', instanceStore.current.id, [
id,
]);
if (resp.status === 200) {
message.success('操作成功!');
handleSearch();
}
}
};
const onPatchBind = () => {
visible.value = false;
handleSearch();
};
onMounted(() => {
handleSearch();
});
const onSave = () => {
formRef.value
.validate()
.then(async () => {
const arr = toRaw(modelRef).dataSource.filter(
(i: any) => i.channelId,
);
if (arr && arr.length !== 0) {
const resp = await saveMapping(
instanceStore.current.id,
props.provider,
arr,
);
if (resp.status === 200) {
message.success('操作成功!');
handleSearch();
}
}
})
.catch((err: any) => {
console.log('error', err);
});
};
</script>
<style lang="less" scoped>
:deep(.ant-form-item) {
margin: 0 !important;
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<EditTable provider="MODBUS_TCP" />
</template>
<script lang="ts" setup>
import EditTable from '../components/EditTable/index.vue'
</script>

View File

@ -0,0 +1,7 @@
<template>
<EditTable provider="OPC_UA" />
</template>
<script lang="ts" setup>
import EditTable from '../components/EditTable/index.vue'
</script>

View File

@ -24,7 +24,7 @@
</div>
</div>
<div class="value">
<ValueRender :data="data" :value="_props.data" />
<ValueRender :data="data" :value="_props.data" type="card" />
</div>
<div class="bottom">
<div style="color: rgba(0, 0, 0, .65); font-size: 12px">更新时间</div>

View File

@ -0,0 +1,55 @@
<template>
<a-modal
:maskClosable="false"
width="600px"
:visible="true"
title="详情"
okText="确定"
cancelText="取消"
@ok="handleCancel"
@cancel="handleCancel"
>
<template v-if="['.jpg', '.png'].includes(type)">
<a-image :src="value?.formatValue" />
</template>
<template v-else-if="['.flv', '.m3u8', '.mp4'].includes(type)">
<!-- TODO 视频组件缺失 -->
</template>
<template v-else>
<!-- <json-viewer
:value="{
'id': '123'
}"
copyable
boxed
sort
></json-viewer> -->
</template>
</a-modal>
</template>
<script lang="ts" setup>
// import JsonViewer from 'vue3-json-viewer';
const _data = defineProps({
type: {
type: String,
default: '',
},
value: {
type: [Object, String],
default: () => {},
},
});
const _emit = defineEmits(['close']);
const handleCancel = () => {
_emit('close');
};
// watchEffect(() => {
// console.log(_data.value?.formatValue)
// })
</script>
<style lang="less" scoped>
</style>

View File

@ -1,11 +1,58 @@
<template>
<div class="value">
{{value?.value || '--'}}
<div v-if="value?.formatValue !== 0 && !value?.formatValue" :class="valueClass">--</div>
<div v-else-if="data?.valueType?.type === 'file'">
<template v-if="data?.valueType?.fileType === 'base64'">
<div :class="valueClass" v-if="!!getType(value?.formatValue)">
<img :src="imgMap.get(_type)" @error="onError" />
</div>
<div v-else :class="valueClass">
<img :src="imgMap.get('other')" />
</div>
</template>
<div v-else-if="data?.valueType?.fileType === 'Binary(二进制)'" :class="valueClass">
<img :src="imgMap.get('other')" />
</div>
<template v-else>
<template v-if="imgList.some((item) => value?.formatValue.includes(item))">
<div :class="valueClass" @click="getDetail('img')">
<img :src="value?.formatValue" @error="imgError" />
</div>
</template>
<template v-else-if="videoList.some((item) => value?.formatValue.includes(item))">
<div :class="valueClass" @click="getDetail('video')">
<img :src="imgMap.get('video')" />
</div>
</template>
<template v-else-if="fileList.some((item) => value?.formatValue.includes(item))">
<div :class="valueClass">
<img :src="imgMap.get(fileList.find((item) => value?.formatValue.includes(item)).slice(1))" />
</div>
</template>
<template v-else>
<div :class="valueClass">
<img :src="imgMap.get('other')" />
</div>
</template>
</template>
</div>
<div v-else-if="data?.valueType?.type === 'object'" @click="getDetail('obj')" :class="valueClass">
<img :src="imgMap.get('obj')" />
</div>
<div v-else-if="data?.valueType?.type === 'geoPoint' || data?.valueType?.type === 'array'" :class="valueClass">
{{JSON.stringify(value?.formatValue)}}
</div>
<div v-else :class="valueClass">
{{String(value?.formatValue)}}
</div>
<ValueDetail v-if="visible" :type="_types" :value="value" @close="visible = false" />
</div>
</template>
<script lang="ts" setup>
import { getImage } from "@/utils/comm";
import { message } from "ant-design-vue";
import ValueDetail from './ValueDetail.vue'
const _data = defineProps({
data: {
@ -22,6 +69,10 @@ const _data = defineProps({
}
});
const valueClass = computed(() => {
return _data.type === 'card' ? 'cardValue' : 'otherValue'
})
const imgMap = new Map<any, any>();
imgMap.set('txt', getImage('/running/txt.png'));
imgMap.set('doc', getImage('/running/doc.png'));
@ -41,6 +92,64 @@ const imgList = ['.jpg', '.png', '.swf', '.tiff'];
const videoList = ['.m3u8', '.flv', '.mp4', '.rmvb', '.mvb'];
const fileList = ['.txt', '.doc', '.xls', '.pdf', '.ppt', '.docx', '.xlsx', '.pptx'];
const isHttps = document.location.protocol === 'https:';
const _types = ref<string>('')
const visible = ref<boolean>(false)
const temp = ref<boolean>(false)
const getType = (url: string) => {
let t: string = '';
[...imgList, ...videoList, ...fileList].map((item) => {
const str = item.slice(1, item.length);
if (url && String(url).indexOf(str) !== -1) {
if (imgList.includes(item)) {
t = 'img';
} else if (videoList.includes(item)) {
t = 'video';
} else {
t = str;
}
}
});
return t;
};
const onError = (e: any) => {
e.target.src = imgMap.get('other')
}
const imgError = (e: any) => {
e.target.src = imgMap.get('error')
temp.value = true
}
const getDetail = (_type: string) => {
const value = _data.value
let flag: string = ''
if(_type === 'img'){
if (isHttps && value?.formatValue.indexOf('http:') !== -1) {
message.error('域名为https时不支持访问http地址');
} else if (temp.value) {
message.error('该图片无法访问');
} else {
flag = ['.jpg', '.png'].find((item) => value?.formatValue.includes(item)) || '--';
}
} else if(_type === 'video'){
if (isHttps && value?.formatValue.indexOf('http:') !== -1) {
message.error('域名为https时不支持访问http地址');
} else if (['.rmvb', '.mvb'].some((item) => value?.formatValue.includes(item))) {
message.error('当前仅支持播放.mp4,.flv,.m3u8格式的视频');
} else {
flag = ['.m3u8', '.flv', '.mp4'].find((item) => value?.formatValue.includes(item)) || '--';
}
}else if(_type === 'obj'){
flag = 'obj'
}
_types.value = flag
visible.value = true
}
</script>
<style lang="less" scoped>

View File

@ -22,6 +22,7 @@
</template>
<template #value="slotProps">
<ValueRender
type="table"
:data="slotProps"
:value="propertyValue[slotProps?.id]"
/>
@ -332,6 +333,10 @@ watch(
const onSearch = () => {
query(0, 8, value.value);
};
onUnmounted(() => {
subRef.value && subRef.value?.unsubscribe()
})
</script>
<style scoped lang="less">

View File

@ -109,6 +109,7 @@ const tabChange = (key: string) => {
<style lang="less" scoped>
.property-box {
display: flex;
overflow: hidden;
.property-box-left {
width: 200px;
}

View File

@ -0,0 +1,192 @@
<template>
<a-modal
width="900px"
title="批量映射"
visible
@ok="handleClick"
@cancel="handleClose"
>
<div class="map-tree">
<div class="map-tree-top">
采集器的点位名称与属性名称一致时将自动映射绑定有多个采集器点位名称与属性名称一致时以第1个采集器的点位数据进行绑定
</div>
<a-spin :spinning="loading">
<div class="map-tree-content">
<a-card class="map-tree-content-card" title="源数据">
<a-tree
checkable
:height="300"
:tree-data="dataSource"
:checkedKeys="checkedKeys"
@check="onCheck"
/>
</a-card>
<div style="width: 100px">
<a-button
:disabled="rightList.length >= leftList.length"
@click="onRight"
>加入右侧</a-button
>
</div>
<a-card class="map-tree-content-card" title="采集器">
<a-list
size="small"
:data-source="rightList"
class="map-tree-content-card-list"
>
<template #renderItem="{ item }">
<a-list-item>
{{ item.title }}
<template #actions>
<a-popconfirm
title="确定删除?"
@confirm="_delete(item.key)"
>
<AIcon type="DeleteOutlined" />
</a-popconfirm>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
</a-spin>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { treeMapping, saveMapping } from '@/api/device/instance';
import { message } from 'ant-design-vue/es';
const _props = defineProps({
type: {
type: String,
default: 'MODBUS_TCP',
},
metaData: {
type: Array,
default: () => []
},
deviceId: {
type: String,
default: '',
}
});
const _emits = defineEmits(['close', 'save']);
const checkedKeys = ref<string[]>([]);
const leftList = ref<any[]>([]);
const rightList = ref<any[]>([]);
const dataSource = ref<any[]>([]);
const loading = ref<boolean>(false);
const handleData = (data: any[], type: string) => {
data.forEach((item) => {
item.key = item.id;
item.title = item.name;
item.checkable = type === 'collectors';
if (
item.collectors &&
Array.isArray(item.collectors) &&
item.collectors.length
) {
item.children = handleData(item.collectors, 'collectors');
}
if (item.points && Array.isArray(item.points) && item.points.length) {
item.children = handleData(item.points, 'points');
}
});
return data as any[];
};
const handleSearch = async () => {
loading.value = true;
const resp = await treeMapping({
terms: [
{
column: 'provider',
value: _props.type,
},
],
});
loading.value = false;
if (resp.status === 200) {
dataSource.value = handleData(resp.result as any[], 'channel');
}
};
const onCheck = (keys: string[], e: any) => {
checkedKeys.value = [...keys];
leftList.value = e?.checkedNodes || [];
};
const onRight = () => {
rightList.value = leftList.value;
};
const _delete = (_key: string) => {
const _index = rightList.value.findIndex((i) => i.key === _key);
rightList.value.splice(_index, 1);
checkedKeys.value = rightList.value.map((i) => i.key);
leftList.value = rightList.value;
};
const handleClick = async () => {
if (!rightList.value.length) {
message.warning('请选择采集器');
} else {
const params: any[] = [];
rightList.value.map((item: any) => {
const array = (item.children || []).map((element: any) => ({
channelId: item.parentId,
collectorId: element.collectorId,
pointId: element.id,
metadataType: 'property',
metadataId: (_props.metaData as any[]).find((i: any) => i.name === element.name)
?.metadataId,
provider: _props.type
}));
params.push(...array);
});
const filterParms = params.filter((item) => !!item.metadataId);
if (filterParms && filterParms.length !== 0) {
const res = await saveMapping(_props.deviceId, _props.type, filterParms);
if (res.status === 200) {
message.success('操作成功');
_emits('save');
}
} else {
message.error('暂无对应属性的映射');
}
}
};
const handleClose = () => {
_emits('close');
};
watchEffect(() => {
if (_props.type) {
handleSearch();
}
});
</script>
<style lang="less" scoped>
.map-tree-content {
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
.map-tree-content-card {
width: 350px;
height: 400px;
.map-tree-content-card-list {
overflow-y: auto;
height: 300px;
}
}
}
</style>

View File

@ -0,0 +1,289 @@
<template>
<a-spin :spinning="loading">
<a-card>
<template #extra>
<a-space>
<a-button @click="visible = true">批量映射</a-button>
<a-button type="primary" @click="onSave">保存</a-button>
</a-space>
</template>
<a-form ref="formRef" :model="modelRef">
<a-table :dataSource="modelRef.dataSource" :columns="columns">
<template #headerCell="{ column }">
<template v-if="column.key === 'collectorId'">
采集器
<a-tooltip title="数据采集中配置的真实物理设备">
<AIcon type="QuestionCircleOutlined" />
</a-tooltip>
</template>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'channelId'">
<a-form-item
:name="['dataSource', index, 'channelId']"
>
<a-select
style="width: 100%"
v-model:value="record[column.dataIndex]"
placeholder="请选择"
allowClear
:filter-option="filterOption"
>
<a-select-option
v-for="item in channelList"
:key="item.value"
:value="item.value"
:label="item.label"
>{{ item.label }}</a-select-option
>
</a-select>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'collectorId'">
<a-form-item
:name="['dataSource', index, 'collectorId']"
:rules="[
{
required: !!record.channelId,
message: '请选择采集器',
},
]"
>
<MSelect
v-model="record[column.dataIndex]"
:id="record.channelId"
type="COLLECTOR"
/>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'pointId'">
<a-form-item
:name="['dataSource', index, 'pointId']"
:rules="[
{
required: !!record.channelId,
message: '请选择点位',
},
]"
>
<MSelect
v-model="record[column.dataIndex]"
:id="record.collectorId"
type="POINT"
/>
</a-form-item>
</template>
<template v-if="column.dataIndex === 'id'">
<a-badge
v-if="record[column.dataIndex]"
status="success"
text="已绑定"
/>
<a-badge v-else status="error" text="未绑定" />
</template>
<template v-if="column.key === 'action'">
<a-tooltip title="解绑">
<a-popconfirm
title="确认解绑"
@confirm="unbind(record.id)"
>
<a-button type="link" :disabled="!record.id"
><AIcon type="icon-jiebang"
/></a-button>
</a-popconfirm>
</a-tooltip>
</template>
</template>
</a-table>
</a-form>
</a-card>
<PatchMapping
:deviceId="instanceStore.current.id"
v-if="visible"
@close="visible = false"
@save="onPatchBind"
:type="provider"
:metaData="modelRef.dataSource"
/>
</a-spin>
</template>
<script lang="ts" setup>
import { useInstanceStore } from '@/store/instance';
import {
queryMapping,
saveMapping,
removeMapping,
queryChannelNoPaging,
} from '@/api/device/instance';
import MSelect from '../MSelect.vue';
import PatchMapping from './PatchMapping.vue';
import { message } from 'ant-design-vue/es';
const columns = [
{
title: '名称',
dataIndex: 'metadataName',
key: 'metadataName',
width: '20%',
},
{
title: '通道',
dataIndex: 'channelId',
key: 'channelId',
width: '20%',
},
{
title: '采集器',
dataIndex: 'collectorId',
key: 'collectorId',
width: '20%',
},
{
title: '点位',
key: 'pointId',
dataIndex: 'pointId',
width: '20%',
},
{
title: '状态',
key: 'id',
dataIndex: 'id',
width: '10%',
},
{
title: '操作',
key: 'action',
width: '10%',
},
];
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
const props = defineProps({
provider: {
type: String,
default: 'MODBUS_TCP',
},
});
const instanceStore = useInstanceStore();
const metadata = JSON.parse(instanceStore.current?.metadata || '{}');
const loading = ref<boolean>(false);
const channelList = ref([]);
const modelRef = reactive({
dataSource: [],
});
const formRef = ref();
const visible = ref<boolean>(false);
const getChannel = async () => {
const resp: any = await queryChannelNoPaging({
paging: false,
terms: [
{
terms: [
{
column: 'provider',
value: props.provider,
},
],
},
],
});
if (resp.status === 200) {
channelList.value = resp.result?.map((item: any) => ({
label: item.name,
value: item.id,
provider: item.provider,
}));
}
};
const handleSearch = async () => {
loading.value = true;
getChannel();
const _metadata = metadata.properties.map((item: any) => ({
metadataId: item.id,
metadataName: `${item.name}(${item.id})`,
metadataType: 'property',
name: item.name,
}));
if (_metadata && _metadata.length) {
const resp: any = await queryMapping(
'device',
instanceStore.current.id,
);
if (resp.status === 200) {
const array = resp.result.reduce((x: any, y: any) => {
const metadataId = _metadata.find(
(item: any) => item.metadataId === y.metadataId,
);
if (metadataId) {
Object.assign(metadataId, y);
} else {
x.push(y);
}
return x;
}, _metadata);
modelRef.dataSource = array;
}
}
loading.value = false;
};
const unbind = async (id: string) => {
if (id) {
const resp = await removeMapping('device', instanceStore.current.id, [
id,
]);
if (resp.status === 200) {
message.success('操作成功!');
handleSearch();
}
}
};
const onPatchBind = () => {
visible.value = false;
handleSearch();
};
onMounted(() => {
handleSearch();
});
const onSave = () => {
formRef.value
.validate()
.then(async () => {
const arr = toRaw(modelRef).dataSource.filter(
(i: any) => i.channelId,
);
if (arr && arr.length !== 0) {
const resp = await saveMapping(
instanceStore.current.id,
props.provider,
arr,
);
if (resp.status === 200) {
message.success('操作成功!');
handleSearch();
}
}
})
.catch((err: any) => {
console.log('error', err);
});
};
</script>
<style lang="less" scoped>
:deep(.ant-form-item) {
margin: 0 !important;
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<a-select allowClear v-model:value="_value" @change="onChange" placeholder="请选择" style="width: 100%">
<a-select-option
v-for="item in list"
:key="item.id"
:value="item.id"
:label="item.name"
:filter-option="filterOption"
>{{ item.name }}</a-select-option
>
</a-select>
</template>
<script lang="ts" setup>
import {
queryCollectorNoPaging,
queryPointNoPaging,
} from '@/api/device/instance';
const _props = defineProps({
modelValue: {
type: String,
default: undefined,
},
type: {
type: String,
default: 'POINT',
},
id: {
type: String,
default: '',
},
});
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
type Emits = {
(e: 'update:modelValue', data: string | undefined): void;
};
const emit = defineEmits<Emits>();
const list = ref<any[]>([]);
const _value = ref<string | undefined>(undefined);
watchEffect(() => {
_value.value = _props.modelValue;
});
const onChange = (_val: string) => {
emit('update:modelValue', _val);
};
const getCollector = async (_val: string) => {
if (!_val) {
return [];
} else {
const resp = await queryCollectorNoPaging({
terms: [
{
terms: [
{
column: 'channelId',
value: _val,
},
],
},
],
});
if (resp.status === 200) {
list.value = resp.result as any[];
}
}
};
const getPoint = async (_val: string) => {
if (!_val) {
return [];
} else {
const resp = await queryPointNoPaging({
terms: [
{
terms: [
{
column: 'collectorId',
value: _val,
},
],
},
],
});
if (resp.status === 200) {
list.value = resp.result as any[];
}
}
};
watchEffect(() => {
if (_props.id) {
if (_props.type === 'POINT') {
getPoint(_props.id);
} else {
getCollector(_props.id);
}
} else {
list.value = [];
}
});
</script>
<style lang="less" scoped>
</style>

View File

@ -1,146 +1,261 @@
<template>
<page-container :tabList="list" @back="onBack" :tabActiveKey="instanceStore.active" @tabChange="onTabChange">
<page-container
:tabList="list"
@back="onBack"
:tabActiveKey="instanceStore.active"
@tabChange="onTabChange"
>
<template #title>
<div>
<div style="display: flex; align-items: center;">
<div>{{instanceStore.current.name}}</div>
<div style="display: flex; align-items: center">
<div>{{ instanceStore.current.name }}</div>
<a-divider type="vertical" />
<a-space>
<a-badge :text="instanceStore.current.state?.text" :status="statusMap.get(instanceStore.current.state?.value)" />
<a-popconfirm title="确认启用设备" @confirm="handleAction" v-if="instanceStore.current.state?.value === 'notActive'">
<a-badge
:text="instanceStore.current.state?.text"
:status="
statusMap.get(
instanceStore.current.state?.value,
)
"
/>
<a-popconfirm
title="确认启用设备"
@confirm="handleAction"
v-if="
instanceStore.current.state?.value ===
'notActive'
"
>
<a-button type="link">启用设备</a-button>
</a-popconfirm>
<a-popconfirm title="确认断开连接" @confirm="handleDisconnect" v-if="instanceStore.current.state?.value === 'online'">
<a-popconfirm
title="确认断开连接"
@confirm="handleDisconnect"
v-if="
instanceStore.current.state?.value === 'online'
"
>
<a-button type="link">断开连接</a-button>
</a-popconfirm>
<a-tooltip v-if="instanceStore.current?.accessProvider === 'child-device' &&
instanceStore.current?.state?.value === 'offline'" :title="instanceStore.current?.features?.find((item) => item.id === 'selfManageState')
? '该设备的在线状态与父设备(网关设备)保持一致'
: '该设备在线状态由设备自身运行状态决定,不继承父设备(网关设备)的在线状态'">
<AIcon type="QuestionCircleOutlined" style="font-size: 14px" />
<a-tooltip
v-if="
instanceStore.current?.accessProvider ===
'child-device' &&
instanceStore.current?.state?.value ===
'offline'
"
:title="
instanceStore.current?.features?.find(
(item) => item.id === 'selfManageState',
)
? '该设备的在线状态与父设备(网关设备)保持一致'
: '该设备在线状态由设备自身运行状态决定,不继承父设备(网关设备)的在线状态'
"
>
<AIcon
type="QuestionCircleOutlined"
style="font-size: 14px"
/>
</a-tooltip>
</a-space>
</div>
<div style="padding-top: 10px">
<a-descriptions size="small" :column="4">
<a-descriptions-item label="ID">{{ instanceStore.current.id }}</a-descriptions-item>
<a-descriptions-item label="ID">{{
instanceStore.current.id
}}</a-descriptions-item>
<a-descriptions-item label="所属产品">
<a-button style="margin-top: -5px; padding: 0" type="link" @click="jumpProduct">{{ instanceStore.current.productName }}</a-button>
<a-button
style="margin-top: -5px; padding: 0"
type="link"
@click="jumpProduct"
>{{
instanceStore.current.productName
}}</a-button
>
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</template>
<template #extra>
<img @click="handleRefresh" :src="getImage('/device/button.png')" style="margin-right: 20px; cursor: pointer;" />
<img
@click="handleRefresh"
:src="getImage('/device/button.png')"
style="margin-right: 20px; cursor: pointer"
/>
</template>
<component :is="tabs[instanceStore.tabActiveKey]" v-bind="{ type: 'device' }" @onJump="onTabChange" />
<component
:is="tabs[instanceStore.tabActiveKey]"
v-bind="{ type: 'device' }"
@onJump="onTabChange"
/>
</page-container>
</template>
<script lang="ts" setup>
import { useInstanceStore } from '@/store/instance';
import Info from './Info/index.vue';
import Running from './Running/index.vue'
import Running from './Running/index.vue';
import Metadata from '../../components/Metadata/index.vue';
import ChildDevice from './ChildDevice/index.vue';
import Diagnose from './Diagnose/index.vue'
import Function from './Function/index.vue'
import { _deploy, _disconnect } from '@/api/device/instance'
import Diagnose from './Diagnose/index.vue';
import Function from './Function/index.vue';
import Modbus from './Modbus/index.vue';
import OPCUA from './OPCUA/index.vue';
import EdgeMap from './EdgeMap/index.vue';
import { _deploy, _disconnect } from '@/api/device/instance';
import { message } from 'ant-design-vue';
import { getImage } from '@/utils/comm';
import { getWebSocket } from '@/utils/websocket';
const route = useRoute();
const instanceStore = useInstanceStore()
const instanceStore = useInstanceStore();
const statusMap = new Map();
statusMap.set('online', 'success');
statusMap.set('offline', 'error');
statusMap.set('notActive', 'warning');
const list = [
const statusRef = ref();
const list = ref([
{
key: 'Info',
tab: '实例信息'
tab: '实例信息',
},
{
key: 'Running',
tab: '运行状态'
tab: '运行状态',
},
{
key: 'Metadata',
tab: '物模型'
tab: '物模型',
},
{
key: 'Function',
tab: '设备功能'
tab: '设备功能',
},
{
key: 'ChildDevice',
tab: '子设备'
tab: '子设备',
},
{
key: 'Diagnose',
tab: '设备诊断'
},
]
]);
const tabs = {
Info,
Metadata,
Running,
ChildDevice,
Diagnose,
Function
}
Info,
Metadata,
Running,
ChildDevice,
Diagnose,
Function,
Modbus,
OPCUA,
EdgeMap,
};
const getStatus = (id: string) => {
statusRef.value = getWebSocket(
`instance-editor-info-status-${id}`,
`/dashboard/device/status/change/realTime`,
{
deviceId: id,
},
).subscribe(() => {
instanceStore.refresh(id);
});
};
watch(
() => route.params.id,
(newId) => {
if(newId){
instanceStore.tabActiveKey = 'Info'
instanceStore.refresh(newId as string)
if (newId) {
instanceStore.tabActiveKey = 'Info';
instanceStore.refresh(newId as string);
getStatus(String(newId));
}
},
{immediate: true, deep: true}
{ immediate: true, deep: true },
);
const onBack = () => {
}
const onBack = () => {};
const onTabChange = (e: string) => {
instanceStore.tabActiveKey = e
}
instanceStore.tabActiveKey = e;
};
const handleAction = async () => {
if(instanceStore.current.id){
const resp = await _deploy(instanceStore.current.id)
if(resp.status === 200){
message.success('操作成功!')
instanceStore.refresh(instanceStore.current.id)
if (instanceStore.current.id) {
const resp = await _deploy(instanceStore.current.id);
if (resp.status === 200) {
message.success('操作成功!');
instanceStore.refresh(instanceStore.current.id);
}
}
}
};
const handleDisconnect = async () => {
if(instanceStore.current.id){
const resp = await _disconnect(instanceStore.current.id)
if(resp.status === 200){
message.success('操作成功!')
instanceStore.refresh(instanceStore.current.id)
if (instanceStore.current.id) {
const resp = await _disconnect(instanceStore.current.id);
if (resp.status === 200) {
message.success('操作成功!');
instanceStore.refresh(instanceStore.current.id);
}
}
}
};
const handleRefresh = async () => {
if(instanceStore.current.id){
await instanceStore.refresh(instanceStore.current.id)
message.success('操作成功')
if (instanceStore.current.id) {
await instanceStore.refresh(instanceStore.current.id);
message.success('操作成功');
}
}
};
const jumpProduct = () => {
message.warn('暂未开发')
}
message.warn('暂未开发');
};
watchEffect(() => {
const keys = list.value.map((i) => i.key);
if (instanceStore.current.protocol && !(['modbus-tcp', 'opc-ua'].includes(instanceStore.current.protocol)) && !keys.includes('Diagnose')) {
list.value.push({
key: 'Diagnose',
tab: '设备诊断',
});
}
if (
instanceStore.current.protocol === 'modbus-tcp' &&
!keys.includes('Modbus')
) {
list.value.push({
key: 'Modbus',
tab: 'Modbus TCP',
});
}
if (
instanceStore.current.protocol === 'opc-ua' &&
!keys.includes('OPCUA')
) {
list.value.push({
key: 'OPCUA',
tab: 'OPC UA',
});
}
if (
instanceStore.current.accessProvider === 'edge-child-device' &&
instanceStore.current.parentId &&
!keys.includes('EdgeMap')
) {
list.value.push({
key: 'EdgeMap',
tab: '边缘端映射',
});
}
});
onUnmounted(() => {
statusRef.value && statusRef.value.unsubscribe();
});
</script>