feat(iot): 新增设备地图功能并优化产品编辑界面

- 新增设备地图组件,用于展示设备在地图上的位置
-优化产品编辑界面,增加预览图和图标上传功能
- 修改 API 调用路径,使用 SSE (Server-Sent Events) 实现实时数据推送
- 更新表格展示,增加设备上报记录的详细信息
This commit is contained in:
Kven 2025-03-10 20:55:56 +08:00
parent 085d0ae690
commit 3113bac668
10 changed files with 301 additions and 25 deletions

View File

@ -34,12 +34,15 @@
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@amap/amap-jsapi-types": "^0.0.15", "@amap/amap-jsapi-types": "^0.0.15",
"@arco-design/web-vue": "^2.44.7", "@arco-design/web-vue": "^2.44.7",
"@commitlint/load": "^19.8.0",
"@vueuse/core": "^9.3.0", "@vueuse/core": "^9.3.0",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"arco-design-pro-vue": "^2.7.2", "arco-design-pro-vue": "^2.7.2",
"axios": "^0.24.0", "axios": "^0.24.0",
"cosmiconfig": "^9.0.0",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"echarts": "^5.4.0", "echarts": "^5.4.0",
"event-source-polyfill": "^1.0.31",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -3,20 +3,20 @@ import axios from 'axios';
// 获取设备信息 // 获取设备信息
export function getDeviceInfo() { export function getDeviceInfo() {
return axios.get('/api/rest/device/status'); return axios.get('/api/rest/device/sse/status');
} }
// 获取消息信息 // 获取消息信息
export function getMessageInfo() { export function getMessageInfo() {
return axios.get('/api/rest/device/record/status'); return axios.get('/api/rest/device/sse/record/status');
} }
// 获取告警信息 // 获取告警信息
export function getAlarmInfo() { export function getAlarmInfo() {
return axios.get('/api/rest/device/data/status'); return axios.get('/api/rest/device/sse/data/status');
} }
// 获取产品信息 // 获取产品信息
export function getProductInfo() { export function getProductInfo() {
return axios.get('/api/rest/product/status'); return axios.get('/api/rest/product/sse/status');
} }

View File

@ -66,3 +66,13 @@ export function updateProduct(data: ProductUpdateRecord) {
export function deleteProduct(id: number) { export function deleteProduct(id: number) {
return axios.delete(`/api/rest/product/${id}`); return axios.delete(`/api/rest/product/${id}`);
} }
// 文件上传
export function addAttachments(data: any) {
return axios.post(`/api/rest/product/upload`, data);
}
// 文件删除
export function deleteAttachment(id: number) {
return axios.delete(`/api/rest/product/attachments/${id}`);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -3,7 +3,7 @@
<div class="left-side"> <div class="left-side">
<div class="panel"> <div class="panel">
<Banner /> <Banner />
<DataPanel v-if="computedDeviceInfo && alarmInfo && productInfo" :deviceInfo="computedDeviceInfo" :alarmInfo='alarmInfo' :productInfo='productInfo' /> <DataPanel :deviceInfo="computedDeviceInfo" :alarmInfo='alarmInfo' :productInfo='productInfo' />
<ContentPublishingSource v-if="computedDeviceInfo && alarmInfo && productInfo" :deviceInfo="computedDeviceInfo" :alarmInfo='alarmInfo' :productInfo='productInfo' /> <ContentPublishingSource v-if="computedDeviceInfo && alarmInfo && productInfo" :deviceInfo="computedDeviceInfo" :alarmInfo='alarmInfo' :productInfo='productInfo' />
</div> </div>
<a-grid :cols="24" :col-gap="16" :row-gap="16" style="margin-top: 16px"> <a-grid :cols="24" :col-gap="16" :row-gap="16" style="margin-top: 16px">
@ -21,8 +21,11 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { getAlarmInfo, getDeviceInfo, getProductInfo } from '@/api/dashboard'; import { getAlarmInfo, getDeviceInfo, getProductInfo } from '@/api/dashboard';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { getToken } from '@/utils/auth';
import Banner from './components/banner.vue'; import Banner from './components/banner.vue';
import DataPanel from './components/data-panel.vue'; import DataPanel from './components/data-panel.vue';
import ContentPublishingSource from './components/content-publishing-source.vue'; import ContentPublishingSource from './components/content-publishing-source.vue';
@ -34,19 +37,53 @@
const alarmInfo = ref<any>(); const alarmInfo = ref<any>();
const productInfo = ref<any>(); const productInfo = ref<any>();
const fetchData = async () => { // const fetchData = async () => {
const res = await getDeviceInfo(); // const res = await getDeviceInfo();
deviceInfo.value = res.data; // deviceInfo.value = res.data;
const res1 = await getAlarmInfo(); // const res1 = await getAlarmInfo();
alarmInfo.value = res1.data; // alarmInfo.value = res1.data;
const res2 = await getProductInfo(); // const res2 = await getProductInfo();
productInfo.value = res2.data; // productInfo.value = res2.data;
console.log(computedDeviceInfo.value); // }
console.log(deviceInfo.value); // onMounted(() => {
} // fetchData();
// })
let eventSource: EventSourcePolyfill | null = null;
const token = getToken();
const startSSE = () => {
// EventSourcePolyfill SSE
eventSource = new EventSourcePolyfill(`http://127.0.0.1:8081/api/rest/device/sse/status`, {
headers: {
'X-CSRF-TOKEN': token
}
});
//
eventSource.onmessage = (event:any) => {
const data = JSON.parse(event.data); // JSON
console.log('Received data:', data);
//
};
//
eventSource.onerror = (error:any) => {
console.error('SSE error:', error);
//
};
};
onMounted(() => { onMounted(() => {
fetchData(); startSSE();
}) });
onUnmounted(() => {
// SSE
if (eventSource) {
eventSource.close();
}
});
</script> </script>

View File

@ -84,7 +84,22 @@
</a-table> </a-table>
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="4" tab="上报记录" title="上报记录"> <a-tab-pane key="4" tab="上报记录" title="上报记录">
<a-table :columns="deviceReportColumns" :data="deviceReportData" /> <a-table :columns="deviceReportColumns" :data="deviceReportData" >
<template #recordTime="{ record }">
<div>{{ dayjs(record.recordTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
</template>
<template #content="{ record }">
<div v-for="(value, key) in record.content" :key="key">
<strong>{{ key }}:</strong> {{ value }}
</div>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="5" tab="设备地图" title="设备地图">
<DeviceMap :renderData=renderData />
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</a-card> </a-card>
@ -116,7 +131,8 @@
import { queryDeviceDetail, queryDeviceRecord, sendCommand } from '@/api/device'; import { queryDeviceDetail, queryDeviceRecord, sendCommand } from '@/api/device';
import { queryServeDetail, queryServeList } from '@/api/tsl'; import { queryServeDetail, queryServeList } from '@/api/tsl';
import useVisible from '@/hooks/visible'; import useVisible from '@/hooks/visible';
import dynamicForm from './dynamic-form.vue' import dynamicForm from './dynamic-form.vue';
import DeviceMap from './device-map.vue';
const route = useRoute(); const route = useRoute();
const { visible, setVisible } = useVisible(); const { visible, setVisible } = useVisible();

View File

@ -0,0 +1,117 @@
<template>
<a-layout>
<a-layout-content>
<div id="container"></div>
</a-layout-content>
</a-layout>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import AMapLoader from '@amap/amap-jsapi-loader';
import { DeviceRecord, queryDeviceList } from '@/api/device';
import "@amap/amap-jsapi-types";
import router from '@/router';
let map: any = null;
const porps = defineProps({
renderData: {
type: Object,
default: () => {
},
},
});
AMapLoader.load({
key: 'a4e80eed798a56451b226dcfca81b846', // WebKey load
version: '2.0', // JSAPI 1.4.15
plugins: [], // 使'AMap.Scale'
})
.then((AMap) => {
map = new AMap.Map('container', {
// id
viewMode: '3D', // 3D
zoom: 11, //
center: [porps.renderData.longitude, porps.renderData.latitude], //
});
if (porps.renderData.longitude && porps.renderData.latitude) {
const lngLat = [porps.renderData.longitude, porps.renderData.latitude];
const content = document.createElement('div');
content.className = 'custom-content-marker';
content.innerHTML = `
<img src="https://webapi.amap.com/theme/v1.3/markers/n/mark_b.png" alt="标记点" />`;
//
const marker = new AMap.Marker({
position: lngLat,
content,
});
//
map.setCenter(lngLat);
map.setZoom(20);
map.add(marker);
// const infoContent = `
// <div style="width: 220px; padding: 10px; background: #fff; border-radius: 4px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
// <h4 style="margin: 0 0 10px; font-size: 16px; color: #333;"></h4>
// <p style="margin: 0; font-size: 14px; color: #555;"><strong>:</strong> ${device.name}</p>
// <p style="margin: 0; font-size: 14px; color: #555;"><strong>:</strong> ${device.hardwareVersion}</p>
// <p style="margin: 0; font-size: 14px; color: #555;"><strong>:</strong> ${device.firmwareVersion}</p>
// <p style="margin: 0; font-size: 14px; color: #555;"><strong>:</strong> ${device.productId}</p>
// </div>
// `;
const infoContent = [
`<br/><div style="width: 300px;margin-left: 20px;padding-bottom: 10px"><b>设备名称: ${porps.renderData.name}</b>`,
`<span style="font-size: 16px; color: #333;">硬件版本: ${porps.renderData.hardwareVersion}</span>`,
`<span style="font-size: 16px; color: #333;">固件版本: ${porps.renderData.firmwareVersion}</span>`,
`<span style="font-size: 16px; color: #333;">所属产品: ${porps.renderData.productName}</span>`,
`<a-button style="margin-left: 200px;color: #0960bd" onclick="handleViewDetail('${porps.renderData.id}')">查看详情
</a-button></div>`
]
const handleViewDetail = (deviceId: number) => {
router.push({ name: 'deviceDetail', params: { id: deviceId } });
};
window.handleViewDetail = handleViewDetail;
//
const infoWindow = new AMap.InfoWindow({
content: infoContent.join('<br>'),
offset: new AMap.Pixel(10, 10), //
});
//
marker.on('click', () => {
infoWindow.open(map, lngLat); //
});
} else {
console.warn('设备缺少经纬度信息', porps.renderData);
}
})
.catch((e) => {
console.log(e);
});
// onUnmounted(() => {
// map?.destroy();
// });
</script>
<style scoped>
#container {
width: 100%;
height: 800px;
}
.custom-content-marker img {
width: 100%;
height: 100%;
}
</style>

View File

@ -55,7 +55,7 @@
// //
map.setCenter(lngLat); map.setCenter(lngLat);
map.setZoom(16); map.setZoom(20);
map.add(marker); map.add(marker);
// const infoContent = ` // const infoContent = `

View File

@ -30,7 +30,7 @@
<a-form <a-form
ref="CreateRef" ref="CreateRef"
:model="formData" :model="formData"
:style="{ width: '800px', height: '420px' }" :style="{ width: '800px', height: '420px'}"
> >
<!-- 产品名称 --> <!-- 产品名称 -->
<a-form-item <a-form-item
@ -77,6 +77,34 @@
<a-form-item field="remark" label="备注"> <a-form-item field="remark" label="备注">
<a-textarea v-model="formData.remark" placeholder="请输入备注" /> <a-textarea v-model="formData.remark" placeholder="请输入备注" />
</a-form-item> </a-form-item>
<!-- 上传预览图 -->
<a-form-item
label="预览图"
>
<a-upload
v-model="formData.attachment"
placeholder="请选择预览图"
show-file-list
:file-list="fileList"
:custom-request="customRequest"
@before-remove="beforeRemove"
/>
</a-form-item>
<!-- 上传预览图 -->
<a-form-item
label="图标"
>
<a-upload
v-model="formData.icon"
placeholder="请选择图标"
show-file-list
:file-list="fileList"
:custom-request="customRequest"
@before-remove="beforeRemove"
/>
</a-form-item>
<!-- 参数 --> <!-- 参数 -->
<a-form-item field="params" label="参数"> <a-form-item field="params" label="参数">
<div style="width: 100%"> <div style="width: 100%">
@ -209,6 +237,7 @@
ProductCreateRecord, ProductCreateRecord,
queryProductDetail, queryProductDetail,
updateProduct, updateProduct,
addAttachments, deleteAttachment
} from '@/api/product'; } from '@/api/product';
const props = defineProps({ const props = defineProps({
@ -298,6 +327,42 @@
formData.value = res.data; formData.value = res.data;
paramsData.value = res.data.params; paramsData.value = res.data.params;
}; };
//
const fileList = computed(() => {
return formData.value.attachments?.map((item: any) => {
return {
name: item.fileName,
url: item.url,
uid: item.id,
};
});
});
//
const customRequest = async (option: any) => {
const { fileItem, onSuccess, onError } = option;
const formDataFile = new FormData();
formDataFile.append('file', fileItem.file);
formDataFile.append('type', '其他');
const res = await addAttachments(formDataFile);
if (res.status === 200) {
onSuccess(res.data);
formData.value.attachmentIds?.push(res.data.id);
console.log(formData.value);
} else {
onError(res.data);
}
};
//
const beforeRemove = async (file: any) => {
if (!file.uid) {
const res = await deleteAttachment(file.response.id);
return res.status === 200;
}
const res = await deleteAttachment(file.uid);
return res.status === 200;
};
// //
const handleClick = () => { const handleClick = () => {
setVisible(true); setVisible(true);

View File

@ -310,7 +310,7 @@
</a-modal> </a-modal>
<a-modal <a-modal
width="900px" width="1300px"
height="500px" height="500px"
:visible="visible && keyValue === '3'" :visible="visible && keyValue === '3'"
@cancel="handleCancel" @cancel="handleCancel"
@ -319,7 +319,7 @@
<a-form <a-form
ref="eventCreateRef" ref="eventCreateRef"
:model="eventAddData" :model="eventAddData"
:style="{ width: '800px', height: '420px' }" :style="{ width: '1110px', height: '420px' }"
> >
<!-- 事件名称 --> <!-- 事件名称 -->
<a-form-item <a-form-item
@ -362,11 +362,12 @@
:key="index" :key="index"
style="margin-bottom: 5px" style="margin-bottom: 5px"
> >
<a-input v-model="param.name" placeholder="名称" allow-clear /> <a-input v-model="param.name" placeholder="属性" allow-clear style="width: 140px" />
<a-input <a-input
v-model="param.identifier" v-model="param.identifier"
placeholder="标识" placeholder="标识"
allow-clear allow-clear
style="width: 140px"
/> />
<a-select <a-select
v-model="param.dataType" v-model="param.dataType"
@ -382,6 +383,19 @@
placeholder="类型" placeholder="类型"
style="width: 140px" style="width: 140px"
/> />
<a-select
v-model="param.comparisonType"
:options="comparisonTypeOptions"
allow-search
placeholder="比较类型"
style="width: 140px"
/>
<a-input
v-model="param.default"
placeholder="默认值"
allow-clear
style="width: 140px"
/>
<a-button <a-button
type="text" type="text"
@click="handleDeleteParams(index, 'eventOutputData')" @click="handleDeleteParams(index, 'eventOutputData')"
@ -511,6 +525,20 @@
value: 'RW', value: 'RW',
}, },
]); ]);
const comparisonTypeOptions = computed<SelectOptionData[]>(() => [
{
label: '大于',
value: 'GREATER',
},
{
label: '小于',
value: 'LESS',
},
{
label: '等于',
value: 'EQUAL',
},
]);
const eventTypeOptions = computed<SelectOptionData[]>(() => [ const eventTypeOptions = computed<SelectOptionData[]>(() => [
{ {
label: '主动', label: '主动',