Skip to content

降温物联网项目

首页

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>