降温物联网项目
首页
text
|--src
|----views
|----home
|------BotChart
|--------TableCard
|----------index.vue
|--------index.vue
|------MidChart
|--------Map
|----------data
|------------geoJson.ts
|------------srcData.ts
|----------Gaode
|------------index.vue
|----------index.vue
|--------Pie
|----------index.vue
|--------Table
|----------TableCard
|------------index.vue
|----------index.vue
|--------index.vue
|------TopChart
|--------Card
|----------index.vue
|--------Number
|----------index.vue
|--------index.vue
|------index.vue
高德地图可视化
基本使用
https://vue-amap.guyixi.cn/zh-cn/introduction/install.html
ts
安装
// 安装核心库
npm install @vuemap/vue-amap --save
// 安装loca库
npm install @vuemap/vue-amap-loca --save
// 安装扩展库
npm install @vuemap/vue-amap-extra --save
npm install vue-amap
npm install @vuemap/vue-amap
npm install @types/amap-js-api
在src目录下新建 shims-vue-amap.d.ts 文件类型检查
// shims-vue-amap.d.ts
declare module 'vue-amap' {
import { App } from 'vue';
const VueAMap: {
install: (app: App) => void;
initAMapApiLoader: (config: {
key: string;
plugin?: string[];
v?: string;
uiVersion?: string;
}) => void;
};
export default VueAMap;
}
在 main.ts 中引入
import VueAMap, { initAMapApiLoader } from '@vuemap/vue-amap';
import '@vuemap/vue-amap/dist/style.css';
初始化
// 初始化vue-amap
initAMapApiLoader({
// 高德的key
key: '00266de077c5944c71bc80c4d339c1c9',
securityJsCode: 'a8de0fa258d29c6d039fc65e2c158e99', // 新版key需要配合安全密钥使用
//Loca:{
// version: '2.0.0'
//} // 如果需要使用loca组件库,需要加载Loca
});
挂载
app.use(pinia)
.use(router)
.use(ElementPlus, {
locale: zhCn,
})
.use(VueAMap)
.mount('#app')
使用 @vuemap/vue-amap 插件
ts
使用 @vuemap/vue-amap 中的海量标注 (AMap.LabelMarker)章节
海量标注
ts
<template>
<div class="map-page-container">
<el-amap
:show-label="false"
:center="center"
:zoom="zoom"
@click="clickMap"
@init="initMap"
>
<el-amap-layer-labels>
<el-amap-label-marker
:visible="labelOptions.visible"
:position="labelOptions.position"
:text="labelOptions.text"
:icon="labelOptions.icon"
@click="clickMarker"
/>
</el-amap-layer-labels>
</el-amap>
</div>
<div class="toolbar">
<button @click="changeVisible">
{{ labelOptions.visible? '隐藏' : '显示' }}
</button>
</div>
</template>
<script lang="ts" setup>
import {ref} from "vue";
import {ElAmap, ElAmapLayerLabels, ElAmapLabelMarker} from "@vuemap/vue-amap";
const zoom = ref(16);
const center = ref([121.5495395, 31.21515044]);
const labelOptions = ref({
visible: true,
position: [121.5495395, 31.21515044],
text: {
content: '测试content',
direction: 'right',
style: {
fontSize: 15,
fillColor: '#fff',
strokeColor: 'rgba(255,0,0,0.5)',
strokeWidth: 2,
padding: [3, 10],
backgroundColor: 'yellow',
borderColor: '#ccc',
borderWidth: 3,
}
},
icon: {
image: 'https://a.amap.com/jsapi_demos/static/images/poi-marker.png',
anchor: 'bottom-center',
size: [25, 34],
clipOrigin: [459, 92],
clipSize: [50, 68]
}
});
const changeVisible = () => {
labelOptions.value.visible = !labelOptions.value.visible;
}
const clickMap = (e: any) => {
console.log('click map: ', e);
}
const initMap = (map: any) => {
console.log('init map: ', map);
}
const clickMarker = () => {
alert('点击了标号');
}
</script>
<style scoped>
</style>
信息窗体
ts
<template>
<div class="map-page-container">
<el-amap
:show-label="false"
:center="center"
:zoom="zoom"
@click="clickMap"
@init="initMap"
@complete="completeMap"
@moveend="moveendMap"
>
<el-amap-info-window
v-model:visible="visible"
:position="center"
>
<div>hello world</div>
</el-amap-info-window>
</el-amap>
</div>
<div class="toolbar">
<button @click="changeVisible">
显隐
</button>
<button @click="changeCenter">
更换中心点
</button>
</div>
</template>
<script lang="ts" setup>
import {ref} from "vue";
import {ElAmap, ElAmapInfoWindow} from "@vuemap/vue-amap";
const zoom = ref(16);
const center = ref([120,31]);
const visible = ref(true)
const changeVisible = () => {
visible.value = !visible.value;
}
const clickMap = (e: any) => {
console.log('click map: ', e);
}
const initMap = (map: any) => {
console.log('init map: ', map);
}
const completeMap = (e: any) => {
console.log(e);
}
const moveendMap = (e: any) => {
console.log('moveendMap: ', e);
}
const changeCenter = () => {
const lng = center.value[0]+0.01;
const lat = center.value[1]+0.01;
center.value = [lng, lat];
}
</script>
<style scoped>
</style>
gaode组件
vue
<template>
<div class="map-page-container">
<el-amap ref="mapRef" :center="center" :zoom="zoom" @init="init" mapStyle="amap://styles/grey" @click="click"
@complete="completeMap">
<!-- 控制工具栏 -->
<el-amap-control-tool-bar :visible="true" position="LB" />
<!-- 标记点 -->
<el-amap-layer-labels v-for="(marker, index) in labelOptions.markerList" :key="index + '1'">
<el-amap-label-marker :visible="labelOptions.visible" :position="(marker as any).position"
:icon="labelOptions.icon" :extData="(marker as any).snName" @click="clickMarker" :zooms="[7, 16]"
:offset="[-24, -24]" />
</el-amap-layer-labels>
<!-- 信息窗体 -->
<el-amap-info-window v-model:visible="currentWindow.visible" :position="currentWindow.position"
:offset="[-140, -220]" :closeWhenClickMap="true" anchor="bottom-center">
<div class="infowindow">
<div style="display: flex;align-items: center; border-bottom: 1px solid #ccc; padding-bottom: 5px;">
<svg ></svg>
<div style="font-size: 16px; font-weight: 600; margin-left: 10px;">SN: {{ currentWindow.data.sn
}}
</div>
</div>
<div style="display: flex; margin-top: 5px;flex: 1;background-color: #EEF4FF;">
<div
style="display: flex; justify-content: space-around;flex-direction: column;width: 50%;border-right: 1px solid #fff;">
<div style="font-size: 15px;margin-bottom: 5px;text-align: center;">设备状态</div>
<el-tag :type="currentWindow.data.onlineSt === 1 ? 'success' : 'danger'" effect="light"
:round="true">
{{ currentWindow.data.onlineSt === 1 ? '在线' : '离线' }}
</el-tag>
</div>
<div
style="display: flex; justify-content: space-around;flex-direction: column;width: 50%;border-right: 1px solid #fff;">
<div style="font-size: 15px;margin-bottom: 5px;text-align: center;">设备类型</div>
<el-tag type="warning" effect="light" :round="true">
{{ currentWindow.data.dtype === 1 ? '机房' : '机柜' }}
</el-tag>
</div>
<div style="display: flex; justify-content: space-around;flex-direction: column;width: 50%;">
<div style="font-size: 15px;margin-bottom: 5px;text-align: center;">设备版本</div>
<el-tag type="warning" effect="light" :round="true">
{{ currentWindow.data.ver }}
</el-tag>
</div>
</div>
<div style="display: flex; margin-top: 5px; font-size: 12px">
<span style="width: 50px;">经纬度:</span>
<span>{{ currentWindow.data.lon }}</span>,<span>{{ currentWindow.data.lat }}</span>
</div>
<div style="margin-top: 5px; font-size: 12px;">
设备名称:
<span>{{ currentWindow.data.snName }}</span>
</div>
<div style="border-top: 1px solid #ccc; margin-top: 5px;">
<el-button style="font-size: 14px;">
<svg ></svg>
详情
</el-button>
</div>
</div>
</el-amap-info-window>
</el-amap>
</div>
<!-- <div class="toolbar">
<button @click="getMap()">
获取地图实例
</button>
</div>
<div class="toolbar">
<button @click="changeVisible">
{{ labelOptions.visible ? '隐藏分布' : '显示分布' }}
</button>
</div> -->
</template>
<script setup lang='ts'>
import { ref, onBeforeMount, watch } from 'vue';
import { ElAmap, ElAmapControlToolBar } from "@vuemap/vue-amap";
const mapRef = ref();
const zoom = ref(11);
const toolBarvisible = ref(true)
// 接收数据
const props = defineProps({
dataList: {
type: Array,
required: true
},
center: {
type: Array,
required: true
}
})
// 监听
watch(() => props.dataList, (newVal) => {
labelOptions.value.markerList = newVal;
});
watch(() => props.center, (newVal) => {
currentWindow.value.position = newVal;
});
// 窗口信息
const currentWindow = ref({
position: props.center,
visible: false,
content: '测试',
data: {
sn: '',
snReal: '',
snName: '',
dtype: null,
onlineSt: null,
ver: 0,
lon: '',
lat: ''
}
})
let map = null;
// 点标记 https://vue-amap.guyixi.cn/zh-cn/component/marker/label-marker.html#%E5%9F%BA%E7%A1%80%E7%A4%BA%E4%BE%8B
const labelOptions = ref({
visible: true,
markerList: props.dataList,
icon: {
image: 'src/assets/deviceicon.png',
// anchor: 'bottom-center',
// size: [25, 34],
// clipOrigin: [459, 92],
// clipSize: [50, 68]
}
})
// 获取map实例
const getMap = () => {
// bmap vue component
// console.log('setup $refs: ', mapRef.value.$$getInstance())
};
// 初始化map
const init = (map: any) => {
}
// 点击map
const click = () => {
console.log("点击了map")
}
// 隐藏显示标点
const changeVisible = () => {
labelOptions.value.visible = !labelOptions.value.visible;
}
// 点击点标记回调
const clickMarker = (e: any) => {
labelOptions.value.markerList.forEach((item: any) => {
currentWindow.value.visible = true
if (e.target._opts.extData === item.snName) {
currentWindow.value.position = item.position
currentWindow.value.data.sn = item.sn
currentWindow.value.data.snReal = item.sn.snReal
currentWindow.value.data.snName = item.snName
currentWindow.value.data.onlineSt = item.onlineSt
currentWindow.value.data.dtype = item.dtype
currentWindow.value.data.ver = item.ver
currentWindow.value.data.lon = item.lon
currentWindow.value.data.lat = item.lat
}
})
}
// 鼠标按下回调
const mouserdownMarker = (e: any) => {
}
// 鼠标拂过回调
const mouseroverMarker = (e: any) => {
}
// 加载完成回调
const completeMap = (e: any) => {
console.log('加载完成回调', e);
}
// 跳转到详情页面
const jumpToDetail = (e: any) => {
console.log("点击了这个", e)
}
// 天气信息
// onBeforeMount(() => {
// lazyAMapApiLoaderInstance.then(() => {
// useWeather().then(res => {
// const { getLive, getForecast } = res;
// getLive('郑州市').then(liveResult => {
// console.log('liveResult: ', liveResult)
// });
// })
// })
// })
</script>
<script lang="ts">
export default {
name: 'Gaode'
}
</script>
<style scoped lang="scss">
.map-page-container {
width: 1120px;
height: 580px;
margin: auto;
}
// 信息窗口
.infowindow {
width: 300px;
max-height: 200px;
border-radius: 10px;
}
</style>
优化加载
思路
ts
你有一个总集合(所有设备的经纬度)。
你有一个集合,根据视口的中心经纬度,筛选出该区域内的设备。
当视口的中心经纬度发生变化时,你需要更新这个集合,筛选出新的附近设备。
你需要先计算出设备与视口中心点的距离。
筛选出距离视口中心点一定范围内的设备。
每次视口中心点移动时,重新计算并更新设备列表。
Echarts地图可视化
转换数据格式(正则 Set去重)
ts
// 转换数据格式:toolTip标签所需数据
function extractProvinceData(data: any) {
// 用于存储统计结果
const provinceMap: any = {};
// 遍历数据,提取省份和区域信息
data.forEach((item: any) => {
const { snName } = item; // 获取包含省份和区域信息的字段
if (snName) {
const match = snName.replace(/^X/, '').match(/(.*省|.*市)(.*市|.*区)/);
if (match) {
const province: string = match[1]; // 省或直辖市部分
const area = match[2]; // 市或区部分
// if(province === '河南省'){
// console.log('snName',snName)
// }
// 初始化省份数据
if (!provinceMap[province]) {
provinceMap[province] = {
name: province.replace('省', ''),
value: 0,
areas: new Set(), // 使用 Set 去重
};
}
// 更新省份数据
provinceMap[province].value += 1;
provinceMap[province].areas.add(area.replace('市', '').replace('区', ''));
} else {
console.log("match不存在", snName)
}
}
});
// 转换为目标数组格式
const result = Object.values(provinceMap).map((province: any) => ({
name: province.name,
value: province.value,
areas: Array.from(province.areas), // 将 Set 转为数组
}));
return result;
}
获取地图Json数据
ts
// 获取阿里GeoJson数据
// const getGeoJsonData = async () => {
// const url = 'https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json';
// try {
// const response = await axios.get(url)
// if (response.status === 200) {
// featuresList.value = response.data
// console.log(featuresList.value)
// }
// } catch (error) {
// console.error('请求出错:', error);
// }
// }
转换数据给gaode组件
ts
// 转换数据格式给gaode组件使用
const filterDataToGaode = (data: any, filterKeyword: string) => {
// 构建正则表达式
const regex = new RegExp(`^X?${filterKeyword}`); // 支持 "河南" 或 "X河南"
return data
.filter((item: any) => item.snName && regex.test(item.snName)) // 动态变量过滤并使用正则表达式匹配
.map((item: any) => ({
sn: item.sn,
snReal: item.snReal ? item.snReal : '',
snName: item.snName,
dtype: item.dtype,
lon: item.lon,
lat: item.lat,
ver: item.ver,
position: [item.lon, item.lat], // lon 和 lat 转为数组
onlineSt: item.onlineSt,
visible: false // gaode windowinfo 默认隐藏
}))
}
Map组件
vue
<template>
<el-card class="el-card-wrapper" shadow="always">
<template #header>
<div class="map-header">
<div class="map-header-left">
<svg</svg>
<span class="header-title">{{ headerTitle === 'detail' ? '设备具体分布' : '设备整体分布' }}</span>
</div>
<div class="map-detail" v-if="headerTitle === 'detail'">
<el-input v-model="input" style="width: 240px" placeholder="输入设备站名搜索" @change="handleSearch" />
<el-tooltip class="box-item" effect="light" content="搜索" placement="top">
<el-button style="background-color: #CED1D6;" @click="handleSearch">
<svg</svg>
</el-button>
</el-tooltip>
<el-tooltip class="box-item" effect="light" content="返回" placement="top">
<el-button style="background-color: #CED1D6;" @click="handleBack">
<svg </svg>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<v-chart v-if="headerTitle === 'summary'" @click="onChartClick" :option="getOption()" :autoresize="true"
class="chart-wrapper"></v-chart>
<Gaode v-if="headerTitle === 'detail'" :dataList="gaodeDataList" :center="center"></Gaode>
</el-card>
</template>
<script setup lang='ts'>
import { onMounted, ref } from 'vue';
import * as echarts from 'echarts'
import { data, geoCoordMap } from './data/srcData'
import { geoJson } from './data/geoJson.js'
import axios, { type AxiosResponse } from 'axios';
import { reqDeviceList } from '@/api/device/index'
import Gaode from './Gaode/index.vue'
import { storage } from '@/utils/storage-utils';
import { ElMessage } from 'element-plus'
const img2 = 'image://data:image/png;base64,i;
const mapName = 'china'
// 标签数据列表
const toolTipData = ref<any>([])
// 标题
const headerTitle = ref('summary')
// gaode数据列表
const gaodeDataList = ref([])
// 设备数据列表
const deviceInfoList = ref([])
// gaode 地图中心
const center = ref([])
const input = ref('')
// label 名字
const keyword = ref('')
onMounted(() => {
// 注册地图
echarts.registerMap('china', geoJson as any);
// 获取地图数据
// const mapFeatures = echarts.getMap(mapName).geoJSON.features
// 遍历geoJson数据修改geoJson数据
// mapFeatures.forEach((item: any) => {
// // 地区名称
// let name = item.properties.name;
// // 地区经纬度
// geoCoordMap[name] = item.properties.center;
// });
// 获取省市区数据
getProvinceData()
})
// 转换数据格式:地图所需数据
const convertData = (data: any) => {
var res = [];
for (var i = 0; i < data?.length; i++) {
var geoCoord = geoCoordMap[data[i].name];
if (geoCoord) {
res.push({
name: data[i].name,
value: geoCoord.concat(data[i].value),
});
}
}
return res;
}
// 转换数据格式:柱状体的主干数据
function lineData() {
return toolTipData.value?.map((item: any) => {
return {
coords: [geoCoordMap[item.name], [geoCoordMap[item.name][0], geoCoordMap[item.name][1] + 1.5]]
}
})
}
// 转换数据格式:柱状体的顶部数据
function scatterData() {
return toolTipData.value?.map((item: any) => {
return [geoCoordMap[item.name][0], geoCoordMap[item.name][1] + 2, item]
})
}
// 转换数据格式:toolTip标签所需数据
function extractProvinceData(data: any) {
// 用于存储统计结果
const provinceMap: any = {};
// 遍历数据,提取省份和区域信息
data.forEach((item: any) => {
const { snName } = item; // 获取包含省份和区域信息的字段
if (snName) {
const match = snName.replace(/^X/, '').match(/(.*省|.*市)(.*市|.*区)/);
if (match) {
const province: string = match[1]; // 省或直辖市部分
const area = match[2]; // 市或区部分
// if(province === '河南省'){
// console.log('snName',snName)
// }
// 初始化省份数据
if (!provinceMap[province]) {
provinceMap[province] = {
name: province.replace('省', ''),
value: 0,
areas: new Set(), // 使用 Set 去重
};
}
// 更新省份数据
provinceMap[province].value += 1;
provinceMap[province].areas.add(area.replace('市', '').replace('区', ''));
} else {
console.log("match不存在", snName)
}
}
});
// 转换为目标数组格式
const result = Object.values(provinceMap).map((province: any) => ({
name: province.name,
value: province.value,
areas: Array.from(province.areas), // 将 Set 转为数组
}));
return result;
}
// 地图配置项
const getOption = () => {
return {
backgroundColor: "#003366",
title: {
show: true,
text: "项目分布图",
x: 'center',
top: "10",
textStyle: {
color: "#fff",
fontFamily: "等线",
fontSize: 18,
},
},
tooltip: {
trigger: 'none',
formatter: function (params: any) {
if (typeof params.value[2] == 'undefined') {
var toolTiphtml = '';
for (var i = 0; i < toolTipData.value.length; i++) {
if (params.name == toolTipData.value[i].name) {
toolTiphtml += toolTipData.value[i].name + ":" + toolTipData.value[i].value;
}
}
// console.log(toolTiphtml);
// console.log(convertData(data))
return toolTiphtml;
} else {
var toolTiphtml = '';
for (var i = 0; i < toolTipData.value.length; i++) {
if (params.name == toolTipData.value[i].name) {
toolTiphtml += toolTipData.value[i].name + ":" + toolTipData.value[i].value;
}
}
// console.log(toolTiphtml);
return toolTiphtml;
}
},
backgroundColor: "#fff",
borderColor: "#333",
padding: [5, 10],
textStyle: {
color: "#fff",
fontSize: "14"
}
},
geo: [{
layoutCenter: ['32%', '60%'],//位置
layoutSize: '180%',//大小
show: true,
map: mapName,
roam: false,
zoom: 1.3,
aspectScale: 1.2,
label: {
show: false,
color: '#fff',
},
emphasis: {
label: {
show: true,
color: '#fff'
},
itemStyle: {
areaColor: "rgba(0,254,233,0.6)",
// borderWidth: 0
}
},
itemStyle: {
areaColor: {
type: "linear",
x: 1200,
y: 0,
x2: 0,
y2: 0,
colorStops: [{
offset: 0,
color: "rgba(3,27,78,0.75)", // 0% 处的颜色
}, {
offset: 1,
color: "rgba(58,149,253,0.75)", // 50% 处的颜色
},],
global: true, // 缺省为 false
},
borderColor: "#c0f3fb",
borderWidth: 1,
shadowColor: "#8cd3ef",
shadowOffsetY: 10,
shadowBlur: 120,
}
}, {
type: "map",
map: mapName,
zlevel: -1,
aspectScale: 1.2,
zoom: 1.3,
layoutCenter: ['32%', '61%'],
layoutSize: "180%",
roam: false,
silent: true,
itemStyle: {
borderWidth: 1,
// borderColor:"rgba(17, 149, 216,0.6)",
borderColor: "rgba(58,149,253,0.8)",
shadowColor: "rgba(172, 122, 255,0.5)",
shadowOffsetY: 5,
shadowBlur: 15,
areaColor: "rgba(5,21,35,0.1)",
},
}, {
type: "map",
map: mapName,
zlevel: -2,
aspectScale: 1.2,
zoom: 1.3,
layoutCenter: ['32%', '62%'],
layoutSize: "180%",
roam: false,
silent: true,
itemStyle: {
borderWidth: 1,
// borderColor: "rgba(57, 132, 188,0.4)",
borderColor: "rgba(58,149,253,0.6)",
shadowColor: "rgba(65, 214, 255,1)",
shadowOffsetY: 5,
shadowBlur: 15,
areaColor: "transpercent",
},
}, {
type: "map",
map: mapName,
zlevel: -3,
aspectScale: 1.2,
zoom: 1.3,
layoutCenter: ['32%', '63%'],
layoutSize: "180%",
roam: false,
silent: true,
itemStyle: {
borderWidth: 1,
// borderColor: "rgba(11, 43, 97,0.8)",
borderColor: "rgba(58,149,253,0.4)",
shadowColor: "rgba(58,149,253,1)",
shadowOffsetY: 15,
shadowBlur: 10,
areaColor: "transpercent",
},
}, {
type: "map",
map: mapName,
zlevel: -4,
aspectScale: 1.2,
zoom: 1.3,
layoutCenter: ['32%', '64%'],
layoutSize: "180%",
roam: false,
silent: true,
itemStyle: {
borderWidth: 5,
// borderColor: "rgba(11, 43, 97,0.8)",
borderColor: "rgba(5,9,57,0.8)",
shadowColor: "rgba(29, 111, 165,0.8)",
shadowOffsetY: 15,
shadowBlur: 10,
areaColor: "rgba(5,21,35,0.1)",
},
},],
series: [
{
type: 'map',
map: mapName,
geoIndex: 0,
aspectScale: 1.1, //长宽比
zoom: 1.1,
showLegendSymbol: true,
roam: true,
label: {
show: true,
color: "#fff",
fontSize: "120%"
},
emphasis: {
label: {
// show: false,
},
},
itemStyle: {
areaColor: {
type: "linear",
x: 1200,
y: 0,
x2: 0,
y2: 0,
colorStops: [{
offset: 0,
color: "rgba(3,27,78,0.75)", // 0% 处的颜色
}, {
offset: 1,
color: "rgba(58,149,253,0.75)", // 50% 处的颜色
},],
global: true, // 缺省为 false
},
borderColor: "#fff",
borderWidth: 0.2,
},
layoutCenter: ["50%", "50%"],
layoutSize: "180%",
animation: false,
markPoint: {
symbol: "none"
},
data: data,
},
//柱状体的主干
{
type: 'lines',
zlevel: 5,
effect: {
show: false,
symbolSize: 3 // 图标大小
},
lineStyle: {
width: 6, // 尾迹线条宽度
color: 'rgba(249, 105, 13, .6)',
opacity: 1, // 尾迹线条透明度
curveness: 0 // 尾迹线条曲直度
},
label: {
show: 0,
position: 'end',
formatter: '245'
},
silent: true,
data: lineData()
},
// 柱状体的顶部
{
type: 'scatter',
coordinateSystem: 'geo',
geoIndex: 0,
zlevel: 5,
label: {
show: true,
formatter: function (params: any) {
var name = params.data[2].name
var value = params.data[2].value
var text = `{tline|${name}} : {fline|${value}}台`
// var text = `{tline|项目个数} : {fline|${value}}`
return text;
},
color: '#fff',
rich: {
fline: {
// padding: [0, 25],
color: '#fff',
fontSize: 14,
fontWeight: 600
},
tline: {
// padding: [0, 27],
color: '#ABF8FF',
fontSize: 12,
},
},
},
emphasis: {
label: {
show: true,
color: "#000"
}
},
itemStyle: {
color: '#00FFF6',
opacity: 1
},
symbol: img2,
symbolSize: [90, 50],
symbolOffset: [0, -20],
z: 999,
data: scatterData(),
},
{
name: 'Top 5',
type: 'effectScatter',
coordinateSystem: 'geo',
data: convertData(toolTipData.value),
showEffectOn: 'render',
rippleEffect: {
scale: 5,
brushType: 'stroke',
},
label: {
formatter: '{b}',
position: 'bottom',
show: false,
color: "#fff",
distance: 10,
},
symbol: 'circle',
symbolSize: [20, 10],
itemStyle: {
color: '#16ffff',
shadowBlur: 10,
shadowColor: '#16ffff',
opacity: 1
},
zlevel: 4,
},
],
}
}
// 获取阿里GeoJson数据
// const getGeoJsonData = async () => {
// const url = 'https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json';
// try {
// const response = await axios.get(url)
// if (response.status === 200) {
// featuresList.value = response.data
// console.log(featuresList.value)
// }
// } catch (error) {
// console.error('请求出错:', error);
// }
// }
// 获取省市区数据
const getProvinceData = async () => {
// 查询字符串
const queryParams = {
current: 1,
size: 400,
filter: JSON.stringify({ selOrgId: 1 }),
}
const res = await reqDeviceList(queryParams)
deviceInfoList.value = res.list.records
// 存储 toolTipData数据
toolTipData.value = extractProvinceData(deviceInfoList.value)
}
// card导航栏 返回按钮回调
const handleBack = () => {
headerTitle.value = 'summary'
}
// 监听label事件
const onChartClick = async (params: any) => {
// 自定义逻辑
if (params.componentSubType === 'scatter') {
// 切换页面
headerTitle.value = 'detail'
// 获取关键字
keyword.value = params.value[2].name
const res = filterDataToGaode(deviceInfoList.value, keyword.value)
// 存储gaode数据
gaodeDataList.value = res
// 给center赋值
// center.value = geoCoordMap[keyword.value]
center.value = res[0].position
}
}
// 转换数据格式给gaode组件使用
const filterDataToGaode = (data: any, filterKeyword: string) => {
// 构建正则表达式
const regex = new RegExp(`^X?${filterKeyword}`); // 支持 "河南" 或 "X河南"
return data
.filter((item: any) => item.snName && regex.test(item.snName)) // 动态变量过滤并使用正则表达式匹配
.map((item: any) => ({
sn: item.sn,
snReal: item.snReal ? item.snReal : '',
snName: item.snName,
dtype: item.dtype,
lon: item.lon,
lat: item.lat,
ver: item.ver,
position: [item.lon, item.lat], // lon 和 lat 转为数组
onlineSt: item.onlineSt,
visible: false // gaode windowinfo 默认隐藏
}))
}
// 搜索
const handleSearch = () => {
// 获取input输入框中的数据
console.log(input.value)
getDeviceList()
}
// 搜索设备请求
const getDeviceList = async () => {
// 当前用户所属组织的所有下级组织ID
// const organs =
const organs = Array.from(JSON.parse(storage.getItem('organs') as string)).join(",")
const filter = {
sn: '',
snName: input.value,
selOrgId: '1',
status: '',
sort: '',
state: '',
dtype: '',
organs,
}
const queryParams = {
current: 1,
size: 20,
filter: JSON.stringify(filter)
}
// 发送请求
try {
const res = await reqDeviceList(queryParams)
const searchDataList = res.list.records
const res1 = filterDataToGaode(searchDataList, keyword.value)
// 存储gaode数据
gaodeDataList.value = res1
console.log('res1', res1);
// 设置gaode组件center
if (res1.length > 0) {
// 把第一个设备的位置设置为gaode组件的center
center.value = res1[0].position
} else {
// 没有搜索到设备
ElMessage({
message: '该设备不存在',
type: 'warning',
duration: 2 * 1000
})
}
} catch (error) {
console.log(error)
}
}
</script>
<style scoped lang="scss">
::v-deep(.el-card__header) {
padding: 0;
}
::v-deep(.el-card__body) {
padding: 0;
}
::v-deep(.el-button) {
margin-left: 0px;
}
::v-deep(.el-button:hover) {
margin-left: 0px;
background-color: #D7E4FF !important;
}
.el-card-wrapper {
border-radius: 10px;
margin-top: 10px;
.map-header {
border-bottom: none;
height: 40px;
background-image: linear-gradient(to bottom, #D5E3FF, #FCFDFF);
}
.chart-wrapper {
width: 1120px;
height: 580px;
margin: auto;
}
.map-header {
display: flex;
justify-content: space-between;
align-items: center;
.map-header-left {
display: flex;
flex-direction: start;
align-items: center;
.icon {
margin-left: 10px;
}
.header-title {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 17px;
font-weight: 600;
margin-left: 10px;
}
}
}
.map-detail {
display: flex;
align-items: center;
justify-content: end;
}
}
</style>
Pie组件
计算属性 inject数据
使用 inject private 传递数据
vue
<template>
<el-card class="card-wrapper">
<template #header>
<div class="header-wrapper">
<svg </svg>
<span class="header-title">设备状态分布</span>
</div>
</template>
<!-- 展示饼图 -->
<v-chart ref="charts" style="height: 250px" :option="getPie()" @mouseover="handlerPie"></v-chart>
</el-card>
</template>
<script setup lang='ts'>
import { ref, inject,computed } from 'vue'
// 注入
const deviceNum = inject('deviceNum')
const charts = ref(null);
// Pie数据饼图
const pieData = computed(() => [
{ name: '在线', value: deviceNum.onlineNum },
{ name: '离线', value: deviceNum.offlineNum },
{ name: '告警', value: deviceNum.warningNum }]
)
// 饼图配置对象
const getPie = () => {
return {
// 提示框
tooltip: {
trigger: "item", // 触发时机
},
// 图例
legend: {
right: "5%",
orient: 'vertical' // 设置纵向
},
// 系列
series: [
{
type: "pie", // 类型为饼图
right: 'center',
bottom: '10%',
// 饼图半径
radius: ["40%", "70%"],
avoidLabelOverlap: false, // 标签放在中心位置
padAngle: 5,
itemStyle: { // 图形的样式
borderRadius: 10, // 图形的边角
// borderColor: "#fff", // 描边的颜色
// borderWidth: 2, // 描边的宽度
},
showEmptyCircle: true,
//设置标签标题
label: {
show: true,
},
// 高亮效果
emphasis: {
label: { // 高亮显示label标签
show: false,
fontSize: 20, // 设置视觉引导线字体大小
fontWeight: "bold", // 设置字体宽度
},
},
// 视觉引导线
labelLine: {
show: true,
},
// 数据
data: pieData.value
},
],
// 标题组件
title: {
show: false,
},
// 颜色
color: ['#43ef4e', '#bfbfbf', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
}
}
const handlerPie = (params: any) => {
charts.value.setOption({
title: { // 设置标题
text: params.name, // 设置主标题
subtext: params.value // 设置副标题
}
})
}
</script>
<script lang="ts">
export default {
name: 'Pie'
}
</script>
<style scoped lang="scss">
::v-deep(.el-card__header) {
padding: 0;
}
::v-deep(.el-card__body) {
padding: 0
}
.card-wrapper {
margin-top: 10px;
border-radius: 10px;
height: 270px;
background-image: linear-gradient(to bottom, #D5E3FF, #FCFDFF);
.header-wrapper {
display: flex;
flex-direction: start;
align-items: center;
height: 40px;
.header-title {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 17px;
font-weight: 600;
margin-left: 10px;
}
.icon {
margin-left: 10px;
}
}
}
</style>
数字跳动增加
表格告警信息组件
封装的组件:TableCard.vue
vue
<template>
<el-card class="container">
<template #header>
<slot name="header"></slot>
</template>
<div class="body">
<slot name="body"></slot>
</div>
</el-card>
</template>
<script setup lang='ts'>
</script>
<style scoped lang="scss">
::v-deep(.el-card__header) {
padding: 0;
}
.container {
height: 450px;
border-radius: 10px;
margin-top: 10px;
background-image: linear-gradient(to bottom, #D5E3FF, #FCFDFF);
}
</style>
静态页面BotChart.vue
vue
<template>
<table-card>
<template #header>
<div class="header-wrapper">
<svg</svg>
<span class="header-title">自动化处理</span>
</div>
</template>
<template #body>
<el-table :data="tableData" height="400" style="width: 100%" @row-click="handleRowClick" tooltip-effect="light">
<el-table-column label="snName" min-width="420" align="left">
<template #="{ row, $index }">
<el-link type="primary">{{ row.snName }}</el-link>
</template>
</el-table-column>
<el-table-column label="trigger" min-width="500" align="left" class-name="table-trigger" label-class-name="table-label-trigger" :show-overflow-tooltip="true">
<template #="{ row, $index }">
{{ row.trigger }}
</template>
</el-table-column>
<el-table-column prop="taskInfo" label="taskInfo" align="center"/>
<el-table-column label="taskRes" align="center">
<template #="{ row, $index }">
<el-tag style="margin: 5px" type="warning">
{{ row.taskRes }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="dateTime" label="dateTime" width="160" align="center"/>
</el-table>
</template>
</table-card>
</template>
<script setup lang='ts'>
import TableCard from './TableCard/index.vue'
import { ref } from 'vue'
const tableData = ref([
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
{
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
},
])
const autoTask = {
snName: '河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581',
trigger: 'BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上',
taskInfo: '恢复正常',
dateTime: '2024-12-18 15:28:19',
taskRes: '执行成功'
}
const handleRowClick = (e: any) => {
console.log('你点击了这个', e)
}
</script>
<script lang="ts">
export default {
name: 'BotChart'
}
</script>
<style scoped lang="scss">
::v-deep(.el-card__body) {
padding: 0;
font-size: 12px;
}
::v-deep(.el-table .table-trigger){
font-size: 12px;
}
::v-deep(.el-table .table-label-trigger){
font-size: 14px;
}
.header-wrapper {
display: flex;
flex-direction: start;
align-items: center;
height: 40px;
.header-title {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 17px;
font-weight: 600;
margin-left: 10px;
}
.icon {
margin-left: 10px;
}
}
</style>
动态路由配置
路由进入前钩子
permission.ts
ts
import router from '@/router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import pinia from '@/stores'
import { useUserInfoStore } from '@/stores/userInfo'
import { ElMessage } from 'element-plus'
import getPageTitle from './utils/get-page-title'
NProgress.configure({ showSpinner: false });
const userInfoStore = useUserInfoStore(pinia)
// 不用进行token检查的白名单路径数组
const whiteList = ['/login']
// 路由加载前
router.beforeEach(async (to, from, next) => {
// 在显示进度条
NProgress.start()
// 设置整个页面的标题
document.title = getPageTitle(to.meta.title as string)
const token = userInfoStore.token
// 如果token存在(已经登陆或前面登陆过)
if (token) {
// 如果请求的是登陆路由
if (to.path === '/login') {
// 直接跳转到根路由, 并完成进度条
next({ path: '/' })
NProgress.done()
} else { // 请求的不是登陆路由
// 是否有用户信息:用户名是否存在
const hasLogin = !!userInfoStore.name
// 内存中(pinia)有用户信息=>已经登陆
if (hasLogin) {
next()
} else { // 内存中没有用户信息(没有登陆或刷新了页面)
try {
// 发送请求获取用户信息
await userInfoStore.getInfo()
next(to) // 重新跳转去目标路由, 能看到动态添加的异步路由, 且不会丢失参数
NProgress.done() // 结束进度条
} catch (error: any) { // 如果请求处理过程中出错
// 重置用户信息
await userInfoStore.reset()
// 提示错误信息
// ElMessage.error(error.message || 'Has Error') // axios拦截器中已经有提示了
// 跳转到登陆页面, 并携带原本要跳转的路由路径, 用于登陆成功后跳转
next(`/login?redirect=${to.path}`)
// 完成进度条
NProgress.done()
}
}
}
} else { // 没有token
// 如果目标路径在白名单中(是不需要token的路径)
if (whiteList.indexOf(to.path) !== -1) {
// 放行
next()
} else {
// 如果没在白名单中, 跳转到登陆路由携带原目标路径
next(`/login?redirect=${to.path}`)
// 完成进度条 当次跳转中断了, 要进行一个新的跳转了
NProgress.done()
}
}
})
// 路由加载后
router.afterEach(() => {
NProgress.done();
})
登陆页面
静态页面
vue
<template>
<div class="login-container">
<div class="login-logo">
<img style="height: 40px; width: 100%;" src="@/assets/title.png" />
</div>
<el-card class="login-card" shadow="hover">
<el-form ref="formRef" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on"
label-position="left">
<div class="title-container">
<h3 class="title">通信基站机柜高效热管理平台</h3>
</div>
<el-form-item prop="username">
<span class="svg-container">
<svg-icon name="ele-UserFilled" />
</span>
<el-input ref="username" v-model="loginForm.username" placeholder="用户名" name="username" type="text"
tabindex="1" auto-complete="on" />
</el-form-item>
<el-form-item prop="password">
<span class="svg-container">
<svg-icon name="ele-Lock" />
</span>
<el-input :key="passwordType" ref="passwordRef" v-model="loginForm.password" :type="passwordType"
placeholder="密码" name="password" tabindex="2" auto-complete="on"
@keyup.enter.native="handleLogin" />
<span class="show-pwd" @click="showPwd">
<svg-icon :name="passwordType === 'password' ? 'ele-Hide' : 'ele-View'" />
</span>
</el-form-item>
<el-form-item prop="code">
<span class="svg-container">
<svg</svg>
</span>
<el-input placeholder="验证码" name="code" v-model="loginForm.code" tabindex="2" auto-complete="on"
style="width: 55%;" />
<img :src="base64ImageUrl" style="width: 36%; position: absolute;right: 0px;" alt="验证码"
@click='handleCaptcha'>
</el-form-item>
<el-button :loading="loading" :disabled="!disabled" type="primary"
style="width:100%;margin-bottom:30px;height: 40px;" @click.native.prevent="handleLogin">登
陆</el-button>
</el-form>
</el-card>
<div class="login-copyright">
<div class="copyright">
<a ref="https://beian.miit.gov.cn" style="display: inline;">豫ICP备19020917号 </a>
<p style="display: inline;"> Copyright © 2022 - 2024 河南新网元通信技术有限公司</p>
</div>
</div>
</div>
</template>
<style lang="scss">
/* 修复input 背景不协调 和光标变色 */
/* Detail see https://github.com/PanJiaChen/vue-element-admin/pull/927 */
$bg: #E5E5E5;
$light_gray: #220808;
$cursor: #a9a5a5;
@supports (-webkit-mask: none) and (not (cater-color: $cursor)) {
.login-container .el-input input {
color: $cursor;
}
}
/* reset element-ui css */
.login-container {
background-image: url(../../assets/bg1.png);
background-color: #2d3a4b;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
.el-input {
display: inline-block;
height: 47px;
width: 85%;
.el-input__wrapper {
width: 100%;
background-color: transparent;
box-shadow: none;
input {
background: transparent;
border: 0px;
-webkit-appearance: none;
border-radius: 0px;
padding: 12px 5px 12px 15px;
color: $light_gray;
height: 47px;
caret-color: $cursor;
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px $bg inset !important;
-webkit-text-fill-color: $cursor !important;
}
}
}
}
.el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 20px;
color: #454545;
}
.el-button {
border-radius: 20px;
}
}
</style>
<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #191a1b;
$light_gray: #eee;
.login-container {
backface-visibility: hidden;
height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
.login-logo {
position: absolute;
top: 33px;
left: 50px;
height: 40px;
}
.login-card {
position: absolute;
width: 520px;
right: 10%;
top: 10%;
}
.login-form {
max-width: 100%;
padding: 10px 35px 0;
margin: 0 auto;
overflow: hidden;
}
.tips {
font-size: 14px;
color: #ece9e9;
margin-bottom: 10px;
span {
&:first-of-type {
margin-right: 16px;
}
}
}
.svg-container {
padding: 6px 5px 6px 15px;
color: $dark_gray;
vertical-align: middle;
width: 30px;
display: inline-block;
}
.title-container {
position: relative;
.title {
margin: 0 auto 0 auto;
font-family: "PingFangSC-Regular";
font-size: 22px;
text-align: center;
color: #151010;
font-weight: 400;
padding: 20px 0;
}
}
.show-pwd {
position: absolute;
right: 10px;
top: 10px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
.login-copyright {
display: inline-block;
position: absolute;
bottom: 4px;
font-size: 14px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
color: #7a7a7c;
line-height: 17px;
font-style: normal;
left: 50%;
margin-left: -244px;
}
}
</style>
点击登陆回调
ts
import { useUserInfoStore } from '@/stores/userInfo'
import type { FormInstance } from 'element-plus'
import { nextTick, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { HexGenerator, ImageUtils } from '@/utils/generator-hex'
import { Encrypt } from '@/utils/encryption'
import { reqCaptcha } from '@/api/user'
/*
点击登陆的回调
*/
const handleLogin = async () => {
await formRef.value?.validate()
loading.value = true
let { username, password, code, key } = loginForm.value
// 加密
password = Encrypt(password)
key = randomId.value
try {
await userInfoStore.login(username, password, code, key)
// 路由跳转
router.push({ path: redirect.value || '/' })
} catch (error) {
if(error){
getCaptcha(randomId.value)
}
}
finally {
loading.value = false
}
}
加密的工具类
src/utils/encryption.ts
ts
import * as CryptoJS from 'crypto-js';
/**
* AES 加密函数
* @param word 要加密的明文
* @param keyStr 密钥字符串
* @param ivStr 初始向量字符串
* @returns 加密后的 Base64 编码密文
*/
export function Encrypt(word: string): string {
let key = CryptoJS.enc.Utf8.parse('xin$wang@yuan*tp'); // 将密钥字符串转为 WordArray
let iv = CryptoJS.enc.Utf8.parse('xin$wang@yuan*tp'); // 将初始向量字符串转为 WordArray
let srcs = CryptoJS.enc.Utf8.parse(word); // 将明文字符串转为 WordArray
// 使用 AES 加密,CBC 模式,ZeroPadding 填充
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.ZeroPadding
});
// 返回加密后的 Base64 字符串
return encrypted.toString();
}
获取验证码回调
ts
const randomId = ref(HexGenerator.generateHex())
// 获取验证码回调
const handleCaptcha = () => {
getCaptcha(randomId.value)
}
// 获取验证码
const getCaptcha = async (randomId: any) => {
// 这里是从 HTTP 请求中得到的 ArrayBuffer 数据
const res: any = await reqCaptcha(randomId)
// 使用 ImageUtils 将 ArrayBuffer 转换为 Base64 编码的图片 URL
ImageUtils.arrayBufferToBase64Image(res.data, 'image/png')
.then(base64Image => {
base64ImageUrl.value = base64Image as string; // 打印出 Base64 编码的图片 URL
})
.catch(error => console.error('Error converting to Base64:', error));
}
验证码二进制转Base64工具类
ts
class HexGenerator {
/**
* 生成指定长度的随机 16 进制字符串
* @param {number} length - 要生成的字符串长度,默认为 24
* @returns {string} - 随机生成的 16 进制字符串
*/
static generateHex(length = 24) {
const chars = '0123456789abcdef';
let result = '';
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * 16)];
}
return result;
}
}
class ImageUtils {
/**
* 将二进制数据(ArrayBuffer)转换为 Base64 编码的图片 URL
* @param {ArrayBuffer} buffer - 二进制数据
* @param {string} mimeType - 图片的 MIME 类型(如 "image/png", "image/jpeg")
* @returns {Promise<string>} - 返回 Base64 图片 URL
*/
static arrayBufferToBase64Image(buffer: any, mimeType = 'image/png') {
return new Promise((resolve, reject) => {
const blob = new Blob([buffer], { type: mimeType });
const reader = new FileReader();
reader.onloadend = () => {
// 获取 Base64 编码的 Data URL
resolve(reader.result);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsDataURL(blob); // 将 Blob 转换为 Base64
});
}
}
export {
ImageUtils,
HexGenerator
}
存储用户数据
src/store/useinfo.ts
存储token和异步路由
返回的数据 token,用户信息,菜单信息,需要将菜单信息转为路由
ts
import { defineStore } from 'pinia';
import { getToken, removeToken, setToken } from '../utils/token-utils';
import type { UserInfoState } from './interface';
import { staticRoutes, allAsyncRoutes, arbitraryRoutes } from '@/router/routes'
import { reqUserInfo, reqUserLogin } from '@/api/user'
import { storage } from '@/utils/storage-utils';
//引入路由器
import router from '@/router';
//引入lodash深拷贝
import cloneDeep from "lodash/cloneDeep";
/**
* 用户信息
* @methods setUserInfos 设置用户信息
*/
// 过滤异步路由
function filterAsyncRoute(allAsyncRoutes:any,menus:any){
const menuNames = extractMenuNames(menus)
const filterAsyncRoutes = filterRoutes(allAsyncRoutes,menuNames)
return filterAsyncRoutes;
}
// 将menu菜单中的name属性全部提取出来
function extractMenuNames(menus:any){
const names = new Set<string>()
// 遍历菜单
menus.forEach((item:any) => {
names.add(item.name);
if (item.children) {
const childNames = extractMenuNames(item.children); // 递归提取子菜单
childNames.forEach((name:any) => names.add(name));
}
});
// 返回names数组
return names;
}
// 过滤异步路由
function filterRoutes(routes:any,menuNames:any){
return routes
.filter((route:any)=>{
return menuNames.has(route.name)
})
.map((route:any) => ({
...route,
children: route.children ? filterRoutes(route.children,menuNames) : undefined // 递归处理子路由
}))
}
export const useUserInfoStore = defineStore('userInfo', {
state: (): any => ({
token: getToken() as string,
id: storage.getItem('id') as string,
nickName: storage.getItem('nickName') as string,
mobile: storage.getItem('mobile') as string,
orgId: storage.getItem('orgId') as string,
organs: storage.getItem('organs') as string,
avatar: storage.getItem('avatar') as string,
roles: storage.getItem('roles') as string,
menuRoutes: [],
syncRoutes: [],
name: '',
}),
actions: {
// 登陆方法
async login(username: string, password: string, code: string, key: string) {
const data = {
username,
password,
code,
key
}
const res: any = await reqUserLogin(data)
// 获取用户token
this.token = res.userInfo.token
// 获取昵称
this.nickName = res.userInfo.user.nickName
// 获取用户id
this.id = res.userInfo.user.id
// 获取用户组织id
this.orgId = res.userInfo.user.orgId
// 获取用户角色名
this.roles = res.userInfo.user.roles
// 获取用户手机号
this.mobile = res.userInfo.user.mobile
// 获取头像
this.avatar = res.userInfo.user.avatar ? res.userInfo.user.avatar : "https://2216847528.oss-cn-beijing.aliyuncs.com/asset/avatar..png"
// 存储token
setToken(res.userInfo.token)
// 存储其他信息
storage.setItem('id', this.id as string)
storage.setItem('orgId', this.orgId as string)
storage.setItem('roles', this.roles as string)
storage.setItem('nickName', this.nickName as string)
storage.setItem('mobile', this.mobile as string)
storage.setItem('avatar', this.avatar as string)
// 把对象转为json字符串,存储菜单信息
storage.setItem('menus', JSON.stringify(res.userInfo.user.menus))
},
// 获取用户名和菜单
async getInfo() {
// 获取用户信息
const result: any = await reqUserInfo(this.token)
// 获取用户名
this.name = result.name
// 存储当前用户组织的id数组
storage.setItem('organs', JSON.stringify(result.organs))
// 获取菜单
const menus = Array.from(JSON.parse(storage.getItem('menus') as string))
// 过滤出这个用户需要展示异步路由
const userAsyncRoute = filterAsyncRoute(cloneDeep(allAsyncRoutes), menus);
this.menuRoutes = [...staticRoutes, ...userAsyncRoute, ...arbitraryRoutes]
// 添加到路由中
userAsyncRoute.forEach((route: any) => {
router.addRoute(route)
})
arbitraryRoutes.forEach((route: any) => {
router.addRoute(route)
})
},
reset() {
// 删除local中保存的token
removeToken()
// 删除菜单列表
storage.removeItem('menus')
// 删除organs
storage.removeItem('organs')
// 删除用户id
storage.removeItem('id')
// 删除组织id
storage.removeItem('orgId')
// 删除手机号
storage.removeItem('mobile')
// 删除头像
storage.removeItem('avator')
// 删除角色
storage.removeItem('roles')
// 提交重置用户信息的mutation
this.token = ''
this.name = ''
this.avatar = ''
},
},
});
存储路由
将菜单转为路由的工具类
ts
// 动态使用import
const _import = (file: string) => {
if (process.env.NODE_ENV === 'development') {
return () => import(`../views/${file}.vue`);
} else if (process.env.NODE_ENV === 'production') {
return () => import(`../views/${file}.vue`);
} else {
throw new Error('未支持的 NODE_ENV 值');
}
}
// 菜单转路由类型 方法
/**
*
* @param menus 菜单列表
* @return res 一个数组,数组中存放这路由配置对象
*/
export function ConvertToRoute(menus: any) {
const res: any = []
menus.forEach((menu: any) => {
// 解构每个路由的配置项
const { path, component, name, children, icon, hidden, redirect } = menu
// 添加路由配置对象
const route: any = {
path,
name,
meta: {
hidden: hidden === 0 ? false : true,
title: name || '', // 设置标题
icon: icon || '', // 设置图标
},
redirect: redirect ? redirect : ''
};
// 添加路由配置对象 component属性
if (component) {
if (component === 'Layout') {
route.component = () => import('@/layout/index.vue')
} else {
route.component = _import(component)
}
}
// 递归处理子路由
if (children && children.length > 0) {
route.children = ConvertToRoute(children);
}
// 添加到结果数组
res.push(route);
});
// 返回数组
return res
}
使用新的方法过滤路由
从菜单中获取 路由名字,组成一个Set数组,然后遍历 全部的异步路由,从异步路由中过滤出,Set中存在name的路由
ts
function filterRoutesByMenu(allAsyncRoutes: Array<RouteRecordRaw>, menu: Array<{ name: string; children?: Array<any> }>) {
// 递归提取 menu 中的所有 name
function extractMenuNames(menu: Array<{ name: string; children?: Array<any> }>): Set<string> {
const names = new Set<string>();
menu.forEach(item => {
names.add(item.name);
if (item.children) {
const childNames = extractMenuNames(item.children); // 递归提取子菜单
childNames.forEach(name => names.add(name));
}
});
return names;
}
const menuNames = extractMenuNames(menu);
// 递归过滤路由
function filterRoutes(routes: Array<RouteRecordRaw>): Array<RouteRecordRaw> {
return routes
.filter(route => menuNames.has(route.name)) // 筛选当前路由 has是Set数组中的方法
.map(route => ({
...route,
children: route.children ? filterRoutes(route.children) : undefined // 递归处理子路由
}));
}
return filterRoutes(allAsyncRoutes);
}
storage工具类
ts
export const storage = {
getItem(key: string): string | null {
return localStorage.getItem(key);
},
setItem(key: string, value: string): void {
localStorage.setItem(key, value);
},
removeItem(key: string): void {
localStorage.removeItem(key);
},
};
token工具类
ts
const TokenKey = 'token'
export function getToken() {
return localStorage.getItem(TokenKey)
}
export function setToken(token: string) {
return localStorage.setItem(TokenKey, token)
}
export function removeToken() {
return localStorage.removeItem(TokenKey)
}
正则工具类
ts
/**
* 判断是否是外链
* @param {string} path
* @returns {Boolean}
*/
export function isExternalFn(path: string) {
return /^(https?:|mailto:|tel:)/.test(path)
}
静态路由
src/router/routes.ts
ts
import type { RouteRecordRaw } from 'vue-router';
import DeviceList from '@/views/core/device/list.vue'
/**
* 路由meta对象参数说明
* meta: {
* title: 菜单栏及 tagsView 栏、菜单搜索名称(国际化)
* hidden: 是否隐藏此路由
* icon: 菜单、tagsView 图标,阿里:加 `iconfont xxx`,fontawesome:加 `fa xxx`
* }
*/
/**
* 静态路由(默认路由)
*/
export const staticRoutes: Array<RouteRecordRaw> = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: {
hidden: true
}
},
{
path: '/404',
name: '404',
component: () => import('@/views/error/404.vue'),
meta: {
hidden: true
}
},
{
path: '/',
component: () => import('@/layout/index.vue'),
redirect: '/home',
children: [{
path: 'home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
icon: 'ele-HomeFilled',
}
}]
},
];
/**
* 定义动态路由
*/
export const allAsyncRoutes: Array<RouteRecordRaw> = [];
// 任意路由
export const arbitraryRoutes: Array<RouteRecordRaw> = [
/* 匹配任意的路由 必须最后注册 */
{
path: '/:pathMatch(.*)',
name: 'Any',
redirect: '/404',
meta: {
hidden: true
}
}
]
路由入口
ts
import { createRouter, createWebHistory } from 'vue-router';
import { staticRoutes } from '@/router/routes';
const router = createRouter({
history: createWebHistory(),
routes: staticRoutes,
scrollBehavior() {
return { top: 0, left: 0 }
},
})
// 导出路由
export default router;
Mock假数据
修改 vite配置文件
vite.config.ts
ts
import { defineConfig, loadEnv, type ConfigEnv } from "vite";
import { resolve } from 'path'
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
// https://vitejs.dev/config/
export default defineConfig((mode: ConfigEnv) => {
const env = loadEnv(mode.mode, process.cwd());
return {
plugins: [vue(), vueJsx()],
resolve: {
alias: {
'@': resolve(__dirname, "src"),
},
extensions: [".ts", ".vue", ".js", ".jsx", ".tsx"], // 导入时想要省略的扩展名列表。
},
css: {
preprocessorOptions: {
scss: {
// 引入 var.scss 这样就可以在全局中使用 var.scss中预定义的变量了
// 给导入的路径最后加上 ;
additionalData: '@import "./src/styles/variables.scss";',
},
},
postcss: {
plugins: [
{
postcssPlugin: "internal:charset-removal",
AtRule: {
charset: (atRule) => {
if (atRule.name === "charset") {
atRule.remove();
}
},
},
},
],
},
},
// 设置代理服务器
server: {
proxy: {
'/app-dev': {
target: 'http://jdgz.xwydl.com:8310',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/app-dev/, ''),
},
'/app-mock': {
target: 'http://127.0.0.1:8081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/app-mock/, ''),
},
}
}
};
});
封装mock axios 工具类
mockrequest.ts
ts
import axios, { type AxiosResponse } from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus';
import pinia from '@/stores/index';
import { useUserInfoStore } from '../stores/userInfo';
/* 定义response对象的data接口 */
interface ResponseData<T> {
code: number;
data: T;
message: string;
}
// 配置新建一个 axios 实例
const service = axios.create({
baseURL: '/app-mock',
timeout: 50000,
});
// 添加请求拦截器
service.interceptors.request.use(
(config:any) => {
// 获取用户仓库
const userInfo = useUserInfoStore(pinia)
const token = userInfo.token
// 请求头中添加token
config.headers['X-Token'] = token
// 返回请求配置项
return config;
}
);
// 添加响应拦截器
service.interceptors.response.use(
/* 约束一下response */
async (response: AxiosResponse<ResponseData<any>>) => {
// 对响应数据简化数据
const res = response.data;
// 排除二进制响应
if(response.config.responseType== "arraybuffer"){
// 放行
return response;
}
/* 成功数据的code值为0 */
if (res.code !== 0) {
// 统一的错误提示
ElMessage({
message: (typeof res.data == 'string' && res.data) || res.message || 'Error',
type: 'error',
duration: 5 * 1000
})
// `token` 过期或者账号已在别处登录
if (response.status === 401) {
const storeUserInfo = useUserInfoStore(pinia)
await storeUserInfo.reset()
window.location.href = '/' // 去登录页
ElMessageBox.alert('你已被登出,请重新登录', '提示', {})
.then(() => { })
.catch(() => { })
}
return Promise.reject(service.interceptors.response);
} else {
return res.data; /* 返回成功响应数据中的data属性数据 */
}
},
(error) => {
// 对响应错误做点什么
if (error.message.indexOf('timeout') != -1) {
ElMessage.error('网络超时');
} else if (error.message == 'Network Error') {
ElMessage.error('网络连接错误');
} else {
if (error.response.data) ElMessage.error(error.response.statusText);
else ElMessage.error('接口路径找不到');
}
return Promise.reject(error);
}
);
export default service;
写接口
ts
enum MOCK{
// 设备在线情况
DEVICEONLINE = '/api/core/device/num'
}
// mock假数据
export const reqMockDeviceOnline = () => mockrequest.get<any,any>(MOCK.DEVICEONLINE)
NodeJs发布服务
package.json
json
{
"dependencies": {
"chalk": "^4.1.0",
"express": "^4.18.2"
},
"scripts": {
"server": "nodemon cooler.js"
}
}
ts
npm install
js
// 导入模块
const express = require("express");
const path = require('path');
const userData = require("./data/userData.json");
const deviceData = require("./data/deviceData.json");
// 创建应用
const app = express();
// 请求方式:get
// 请求地址:http://zhangpeiyue.com:8082/likeList
// 请求参数:query
// pageSize:每页显示的条数
// pageNo:页码
// 响应结果:
// {
// ok:1,
// msg:"success",
// likeList:[],// 喜欢列表
// pageSum:1// 总页数
// }
app.get("/likeList",(req,res)=>{
/*
* 1- 接收参数
* 2- 根据参数获取数据
* 3- 响应数据*/
let {pageSize=6,pageNo=1} = req.query;
pageNo = pageNo/1;
pageSize = pageSize/1;
// pageNo===>1
res.json({
ok:1,
msg:"success",
likeList:likeData.slice((pageNo-1)*pageSize,pageSize*pageNo),
pageSum:Math.ceil(likeData.length/pageSize)
})
})
// 设备数量
app.get("/api/core/device/num",(req,res)=>{
/*
* 1- 接收参数
* 2- 根据参数获取数据
* 3- 响应数据*/
res.status = 200
res.json({
code:0,
message:"成功",
data:{
deviceData:deviceData
},
})
})
// 启动服务
app.listen(8081,()=>{
console.log("express server is running on 127.0.0.1:8081");
})
./data/deviceData.json
json
{
"totalDeviceNum":999,
"onlineDeviceNum":998,
"warningDeviceNum":15,
"offlineDeviceNum":1
}
./data/userData.json
json
[
{
"id": 1,
"title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
"imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
"price": 89
},
{
"id": 2,
"title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
"imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
"price": 89
}
]
moment使用
ts
const moment = require('moment');
// 获取当前时间,具体到时分秒
const currentTime = moment().format('YYYY-MM-DD HH:mm:ss');
console.log('当前时间:', currentTime);
// 获取 5 天前的时间,具体到时分秒
const fiveDaysAgo = moment().subtract(5, 'days').format('YYYY-MM-DD HH:mm:ss');
console.log('5 天前的时间:', fiveDaysAgo);
Map方法过滤数据
ts
正则表达式
匹配字符串
ts
^(.*?),\s*(.*?)(\|.*)?
^:匹配字符串的开始。
(.*?):非贪婪匹配第一个逗号前的部分(第一部分)。
,:匹配第一个逗号。
\s*:匹配逗号后可能的空格。
(.*?):非贪婪匹配逗号后的部分(第二部分,可能包含 | 分隔符)。
(\|.*)?:匹配 | 及其后续内容(如果存在),但将其单独提取。
^(.*?),\s*(.*?)(\|.*)?$
逐部分解析:
^:
匹配字符串的开始位置。
确保匹配从字符串的开头开始。
(.*?):
括号 ():表示捕获组,用来提取匹配的内容。
.*?:匹配任意字符(. 表示任意字符,* 表示零次或多次,? 表示非贪婪模式)。
非贪婪模式意味着尽可能少地匹配字符,直到遇到满足下一部分的条件(如 ,)。
这一部分匹配逗号前的所有内容(即第一部分)。
,:
明确匹配第一个逗号,作为分隔符。
不在捕获组中,因为逗号本身不需要提取。
\s*:
匹配逗号后的零个或多个空白字符(包括空格、制表符等)。
\s:表示空白字符。
*:表示零次或多次匹配。
(.*?):
再次使用捕获组和非贪婪匹配。
匹配逗号后到管道符 | 之前的所有内容(即第二部分)。
如果没有管道符,匹配到字符串的末尾。
(\|.*)?:
括号 ():捕获管道符 | 及其后内容(即第三部分)。
\|:匹配管道符 |(需要用反斜杠转义,因为 | 是正则表达式中的特殊字符,表示“或”操作)。
.*:匹配任意字符(贪婪模式),即管道符后面的所有内容。
?:表示捕获组是可选的。如果没有管道符,则这部分不会匹配。
$:
匹配字符串的结束位置。
确保正则表达式匹配到整个字符串。
ts
const text = "河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581, 【失败重发】 - 河南省郑州市管城回族区经北二路朝凤路小基站(DF)21581, BD_DY1_O-低压开关动作-当前风速恢复至首次低压开关动作发生时刻的风速95%以上|【恢复正常】";
const regex = /^(.*?),\s*(.*?)(\|.*)?/;
const match = text.match(regex);
if (match) {
const part1 = match[1]; // 第一个逗号前的部分
const part2 = match[2]; // 第二部分(去掉'|'及之后)
const part3 = match[3] ? match[3].slice(1) : null; // '|' 后的部分(如果存在)
console.log('第一部分:', part1);
console.log('第二部分:', part2);
console.log('第三部分(|之后的内容):', part3);
}
svg图标提示
vue
<svg t="1734598917089" class="icon" viewBox="0 0 1024 1024" version="1.1"
v-if="row.status === 1" xmlns="http://www.w3.org/2000/svg" p-id="4301" width="16"
height="16">
<title>处理成功</title>
<path
d="M469.333333 640l0.384 0.384L469.333333 640z m-106.282666 0l-0.384 0.384 0.384-0.384z m48.512 106.666667a87.466667 87.466667 0 0 1-61.653334-24.874667l-179.52-173.632a67.797333 67.797333 0 0 1 0-98.24c28.032-27.157333 73.493333-27.157333 101.589334 0l139.584 134.997333 319.168-308.544c28.032-27.157333 73.493333-27.157333 101.589333 0a67.925333 67.925333 0 0 1 0 98.24L472.981333 722.069333A87.530667 87.530667 0 0 1 411.562667 746.666667z"
fill="#39a840" p-id="4302"></path>
</svg>