移动端:使用腾讯地图显示区域统计与放大显示资产点位
整合步骤总览
| 步骤 | 内容 | 涉及文件 |
|---|---|---|
| 步骤一 | 配置腾讯地图能力并注册页面 | src/manifest.json(file:///e:/project/mobile-ui/src/manifest.json)、src/pages.json(file:///e:/project/mobile-ui/src/pages.json) |
| 步骤二 | 准备资产接口与字典 | src/services/asset.js(file:///e:/project/mobile-ui/src/services/asset.js)、src/utils/useDict.js(file:///e:/project/mobile-ui/src/utils/useDict.js) |
| 步骤三 | 实现地图页面(聚合 / 详情双模式) | src/pages/index/index-map.vue(file:///e:/project/mobile-ui/src/pages/index/index-map.vue) |
整体逻辑:地图缩小时(缩放级别小于 15,聚合模式)请求「按村委统计资产数量」接口,在村委中心点显示数量气泡;地图放大时(缩放级别大于等于 15,详情模式)请求「资产列表」接口,按资产坐标显示具体点位。点击聚合气泡可放大进入详情模式,点击资产点弹出信息气泡。
步骤一:配置腾讯地图能力并注册页面
- 在 manifest.json 中配置腾讯地图 key:uni-app 的
<map>组件在 H5 端依赖腾讯地图,key 位于 sdkConfigs.maps.tencent.key。(本文档已对该 key 做脱敏处理,实际使用时请填写自己的腾讯地图 key) - 在 pages.json 中注册地图页面:路径为 pages/index/index-map,标题为「地图找地」。
完整文件:src/manifest.json
json
{
"name" : "智汇资产管理系统",
"appid" : "__UNI__21D1B47",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App特有相关 */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* 模块配置 */
"modules" : {},
/* 应用发布信息 */
"distribute" : {
/* android打包配置 */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios打包配置 */
"ios" : {},
/* SDK配置 */
"sdkConfigs" : {}
}
},
/* 快应用特有相关 */
"quickapp" : {},
/* 小程序特有相关 */
"mp-weixin" : {
"appid" : "wx9ae1187d0b4dfxxx",
"setting" : {
"urlCheck" : false,
"minified" : true,
"postcss" : false
},
"styleIsolation" : "shared",
"usingComponents" : true,
"requiredPrivateInfos" : [
"getLocation",
"chooseAddress",
"chooseLocation",
"onLocationChange",
"startLocationUpdate",
"startLocationUpdateBackground",
"choosePoi"
],
"permission" : {
"scope.userLocation" : {
"desc" : "获取资产位置"
}
},
"lazyCodeLoading" : "requiredComponents"
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"h5" : {
"title" : "智汇资产管理系统",
"template" : "index.html",
"router" : {
"mode" : "history",
"base" : "/fsgdzc-h5/"
},
"devServer" : {
"https" : false
},
"sdkConfigs" : {
"maps" : {
"tencent" : {
"key" : "I6ABZ-*****-*****-*****-*****-C2BWD"
}
}
}
}
}
(pages.json 为全局路由配置,体量较大且大部分与地图无关,此处不再整篇附上;其与该页面相关的注册为:path 取 pages/index/index-map,style.navigationBarTitleText 取「地图找地」。完整文件见 pages.json(file:///e:/project/mobile-ui/src/pages.json)。)
步骤二:准备资产接口与字典
- services/asset.js:详情模式使用 listInfo(资产列表,支持按村委、类型、租金、面积、中心点与距离筛选);聚合模式使用 assetCountByVillage(按村委统计资产数量)。
- utils/useDict.js:加载字典数据(资产类型 asset_type、村委 village_committee 等),用于筛选下拉选项与资产类型名称展示。
相关接口代码:src/services/asset.js(仅保留用到的接口)
javascript
import { http } from '@/utils/http'
// 查询资产列表(详情模式使用:按中心点、距离及筛选项拉取资产点位)
export function listInfo(data) {
return http({
url: 'mpwx/asset/list',
method: 'post',
data
})
}
// 按村委统计资产数量(聚合模式使用:缩小地图时显示各村委资产总数)
export function assetCountByVillage(data) {
return http({
url: 'mpwx/asset/assetCountByVillage',
method: 'post',
data
})
}
完整文件:src/utils/useDict.js
javascript
import { ref, onMounted } from 'vue';
import { getDicts } from "@/services/dict";
export function useDict(dictTypes) {
const dictsData = ref({});
const loadDicts = async () => {
try {
for (const type of dictTypes) {
const res = await getDicts(type);
dictsData.value[type] = res.data || []
}
} catch (e) {
console.error('字典加载失败', e);
}
};
const getDictLabel = (type, value) => {
const item = dictsData.value[type]?.find(item => item.value === value);
return item?.label || '';
};
const getDictClass = (type, value) => {
const item = dictsData.value[type]?.find(item => item.value === value);
return item?.listClass || 'default';
};
onMounted(loadDicts);
return {
dictsData,
getDictLabel,
getDictClass,
loadDicts // 提供手动刷新方法
};
}
步骤三:实现地图页面(index-map.vue)
页面核心机制:
- 地图渲染 :使用 uni-app 的
<map>组件,绑定 markers、中心经纬度、scale,并监听 markertap(点击标记)、tap(点击空白处)、regionchange(视野变化)。 - 两种模式与阈值:以常量 DETAIL_ZOOM_LEVEL(默认 15)为界。缩放级别小于 15 为聚合模式,大于等于 15 为详情模式。
- 聚合模式(缩小,显示区域统计) :
- 调用 assetCountByVillage 拉取各村委的资产数量;
- 标记位置取「村委中心点」(页面内置各村委中心点表,用村委名称前两个字匹配),标记背景使用不同颜色的图片;
- 通过 callout 气泡常显「村委名(数量)」,形成区域资产统计展示。
- 详情模式(放大,显示资产点位) :
- 调用 listInfo,参数包含中心点经纬度与距离(默认 1000 米),以及村委、类型、租金、面积等筛选;
- 按资产的 locationCoordinates(格式为「经度,纬度」)生成 marker;
- 点击资产点弹出气泡,展示资产名称、类型、面积、地址,点击名称跳转资产详情页。
- 缩放级别检测与模式切换 :
- 小程序端通过 mapContext.getScale 定时检查缩放级别(startScaleCheck),H5 端通过右上角加/减按钮(zoomIn / zoomOut)配合 watch(scale) 触发;
- 级别跨越阈值时切换模式:进入详情模式清空聚合标记并拉取资产点;进入聚合模式清空资产点并拉取统计。
- 视野变化处理:regionchange 的 end 事件做约 600 毫秒防抖;详情模式下若中心点移动超过阈值则重新拉取资产点。
- 筛选与初始化:顶部提供类型、区域、租金、面积筛选,变更后按当前模式刷新数据;页面挂载后初始化地图上下文、获取定位,并延迟加载数据等待地图渲染完成。
- 加载与提示:加载中显示 loading,数据加载完成用 toast 提示数量。
完整文件:src/pages/index/index-map.vue
vue
<template>
<view class="page home">
<view class="filter-head blueModel">
<view class="filter-item" style="flex: 1">
<wd-picker :columns="assetsColumns" v-model="assetsValue" @confirm="assetsValueConfirm" use-default-slot :z-index="99" root-portal>
<view class="flex">
<wd-text color="#1570FD" size="28rpx" :text="assetsText" :lines="1" />
<wd-icon name="fill-arrow-down" size="20px"></wd-icon>
</view>
</wd-picker>
</view>
<view class="filter-item" style="flex: 1">
<wd-picker :columns="villageColumns" v-model="villageValue" @confirm="villageValueConfirm" use-default-slot :z-index="99" root-portal>
<view class="flex">
<wd-text color="#1570FD" size="28rpx" :text="villageText" :lines="1" />
<wd-icon name="fill-arrow-down" size="20px"></wd-icon>
</view>
</wd-picker>
</view>
<view class="filter-item" style="flex: 1">
<view class="flex">
<wd-text color="#1570FD" size="28rpx" :text="rentText" :lines="1" @click="(rentShow = true), closeBubblePopup()" />
<wd-icon name="fill-arrow-down" size="20px"></wd-icon>
</view>
</view>
<!-- 租金筛选使用uni-app原生弹窗 -->
<view v-if="rentShow" class="custom-overlay">
<view class="wrapper popboxs" @click.stop>
<view class="block" style="width: 100%">
<!-- 添加关闭按钮 -->
<view class="close-btn" @click="rentShow = false">
<wd-icon name="close" size="20px" color="#999"></wd-icon>
</view>
<view>
租金
<wd-text color="#1570FD" size="28rpx" :text="'¥' + rentValue[0] + '-'" />
<wd-text color="#1570FD" size="28rpx" :text="rentValue[1]" />
</view>
<wd-slider hide-label v-model="rentValue" type="range" :min="0" :max="20000" />
<view class="tag-container">
<wd-tag
type="primary"
plain
v-for="(item, index) in rentColumns"
:key="index"
:mark="isTagSelected(item.value)"
@click="rentClick(item.value, index)"
custom-class="rent-tag"
>
{{ item.name }}
</wd-tag>
</view>
<view
class="sticky-bottom-bar"
style="box-sizing: border-box; padding-left: 42rpx; padding-right: 42rpx; padding-top: 42rpx; display: flex; gap: 20rpx; background: rgb(255, 255, 255)"
>
<view style="flex: 1; padding-bottom: 10px">
<wd-button block type="info" :round="false" @tap="reset">重置</wd-button>
</view>
<view style="flex: 1; padding-bottom: 10px">
<wd-button block custom-class="blue-btn" :round="false" @tap="confirmRent">确定</wd-button>
</view>
</view>
</view>
</view>
</view>
<view class="filter-item" style="flex: 1">
<view class="flex">
<wd-text color="#1570FD" size="28rpx" :text="areaText" :lines="1" @click="(areaShow = true), closeBubblePopup()" />
<wd-icon name="fill-arrow-down" size="20px"></wd-icon>
</view>
</view>
<!-- 面积筛选使用wot-design-uni弹窗 -->
<view v-if="areaShow" class="custom-overlay">
<view class="wrapper popboxs" @click.stop>
<view class="block" style="width: 100%">
<!-- 添加关闭按钮 -->
<view class="close-btn" @click="areaShow = false">
<wd-icon name="close" size="20px" color="#999"></wd-icon>
</view>
<view>
面积
<wd-text color="#1570FD" size="28rpx" :text="areaValue[0] + '-'" />
<wd-text color="#1570FD" size="28rpx" :text="areaValue[1]" />
</view>
<wd-slider hide-label v-model="areaValue" type="range" :min="0" :max="200" />
<view class="tag-container">
<wd-tag
type="primary"
plain
v-for="(item, index) in areaColumns"
:key="index"
:mark="isTagSelectedArea(item.value)"
@click="areaClick(item.value, index)"
custom-class="rent-tag"
>
{{ item.name }}
</wd-tag>
</view>
<view
class="sticky-bottom-bar"
style="box-sizing: border-box; padding-left: 42rpx; padding-right: 42rpx; padding-top: 42rpx; display: flex; gap: 20rpx; background: rgb(255, 255, 255)"
>
<view style="flex: 1; padding-bottom: 10px">
<wd-button block type="info" :round="false" @tap="resetArea">重置</wd-button>
</view>
<view style="flex: 1; padding-bottom: 10px">
<wd-button block custom-class="blue-btn" :round="false" @tap="confirmArea">确定</wd-button>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="map-container">
<map
id="myMap"
:markers="displayMarkers"
@markertap="onMarkerTap"
@tap="onMapTap"
@regionchange="onRegionChange"
:longitude="centerLongitude"
:latitude="centerLatitude"
:scale="scale"
:show-location="true"
style="width: 100%; height: 100%"
:enable-zoom="true"
:enable-scroll="true"
:show-scale="true"
></map>
<!-- 缩放控制按钮 -->
<!-- #ifdef H5 -->
<view class="zoom-controls">
<view class="zoom-btn" @click="zoomIn">
<text>+</text>
</view>
<view class="zoom-btn" @click="zoomOut">
<text>-</text>
</view>
</view>
<!-- #endif -->
<!-- 地图缩放级别显示 -->
<view class="scale-info" v-if="showScaleInfo">级别: {{ currentScale }} {{ isDetailMode ? '(详情)' : '(聚合)' }}</view>
<!-- 手动刷新按钮 -->
<view class="refresh-btn" @click="manualRefresh">
<wd-icon name="refresh" size="20px" color="#1890ff"></wd-icon>
</view>
<!-- 加载状态 -->
<view class="loading-indicator" v-if="isLoading">加载中...</view>
</view>
<!-- 气泡式弹窗 -->
<view v-if="showBubblePopup" class="bubble-popup">
<view class="bubble-content">
<!-- 气泡箭头(指向下方) -->
<view class="bubble-arrow"></view>
<!-- 资产名称,可点击跳转 -->
<view class="asset-name-line">
<text class="info-label">资产名称:</text>
<text class="asset-name-link" @tap="gotoAssetDetail(currentAsset.assetCode)">
{{ currentAsset.assetName }}
</text>
</view>
<view class="asset-info">
<text class="info-label">资产类型:</text>
<text class="info-value">{{ currentAsset.assetType }}</text>
</view>
<view class="asset-info">
<text class="info-label">资产面积:</text>
<text class="info-value">{{ currentAsset.area }}{{ currentAsset.unit }}</text>
</view>
<view class="asset-info">
<text class="info-label">资产地址:</text>
<text class="info-value">{{ currentAsset.address }}</text>
</view>
</view>
</view>
<wd-toast />
<wd-message-box />
</view>
</template>
<script setup>
import { ref, watch, onMounted, nextTick, computed, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { useToast, useMessage } from 'wot-design-uni';
import { assetCountByVillage, listInfo } from '@/services/asset';
import { useDict } from '@/utils/useDict';
// 下拉框值
// 资产类型
const assetType = ref('');
const assetsText = ref('类型');
const assetsValue = ref('');
const assetsColumns = ref([]);
const assetsValueConfirm = (e) => {
assetType.value = e.value;
assetsText.value = e.selectedItems.label;
refreshMapData();
};
const villageText = ref('区域');
const villageValue = ref('');
const villageColumns = ref([]);
const villageValueConfirm = (e) => {
villageText.value = e.selectedItems.label;
refreshMapData();
};
// 租金下拉框开始
const rentShow = ref(false);
const rentText = ref('租金');
const rentColumns = ref([
{
name: '¥1000以下',
value: [0, 1000]
},
{
name: '¥1000-1500',
value: [1000, 1500]
},
{
name: '¥1500-2000',
value: [1500, 2000]
},
{
name: '¥2000-3000',
value: [2000, 3000]
},
{
name: '¥3000-5000',
value: [3000, 5000]
},
{
name: '¥5000-8000',
value: [5000, 8000]
},
{
name: '¥8000-13000',
value: [8000, 13000]
},
{
name: '¥13000-20000',
value: [13000, 20000]
},
{
name: '¥20000以上',
value: [20000, 200000]
}
]);
const rentChooseValue = ref(); //选中自定义的租金范围
const rentValue = ref([0, 20000]);
const confirmRent = () => {
rentShow.value = false;
refreshMapData();
};
const isTagSelected = (value) => {
if (!rentChooseValue.value) return false;
return JSON.stringify(value) === JSON.stringify(rentChooseValue.value);
};
const rentClick = (value, index) => {
// 如果再次点击已选中的标签,则取消选择
if (JSON.stringify(rentChooseValue.value) === JSON.stringify(value)) {
rentChooseValue.value = null;
// 同时更新slider值为默认范围
rentValue.value = [0, 20000];
} else {
rentChooseValue.value = value;
// 更新slider值为标签对应的范围
rentValue.value = value;
}
};
const reset = () => {
rentValue.value = [0, 20000];
rentChooseValue.value = null;
refreshMapData();
};
// 租金下拉框结束
// 面积下拉框开始
const areaShow = ref(false);
const areaText = ref('面积');
const areaColumns = ref([
{
name: '≤20m²/亩',
value: [0, 20]
},
{
name: '20-40m²/亩',
value: [20, 40]
},
{
name: '40-60m²/亩',
value: [40, 60]
},
{
name: '60-90m²/亩',
value: [60, 90]
},
{
name: '90-120m²/亩',
value: [90, 120]
},
{
name: '120-150m²/亩',
value: [120, 150]
},
{
name: '150-180m²/亩',
value: [150, 180]
},
{
name: '≥180m²/亩',
value: [180, 200]
}
]);
const areaChooseValue = ref(); //选中自定义的面积范围
const areaValue = ref([0, 200]);
const confirmArea = () => {
areaShow.value = false;
refreshMapData();
};
const isTagSelectedArea = (value) => {
if (!areaChooseValue.value) return false;
return JSON.stringify(value) === JSON.stringify(areaChooseValue.value);
};
const areaClick = (value, index) => {
// 如果再次点击已选中的标签,则取消选择
if (JSON.stringify(areaChooseValue.value) === JSON.stringify(value)) {
areaChooseValue.value = null;
// 同时更新slider值为默认范围
areaValue.value = [0, 200];
} else {
areaChooseValue.value = value;
// 更新slider值为标签对应的范围
areaValue.value = value;
}
};
const resetArea = () => {
rentValue.value = [0, 20000];
areaChooseValue.value = null;
refreshMapData();
};
// 面积下拉框结束
// 地图相关变量
const showBubblePopup = ref(false);
const currentAsset = ref({});
const currentMarker = ref(null);
const isLoading = ref(false);
const toast = useToast();
const message = useMessage();
const centerLatitude = ref(23.260015);
const centerLongitude = ref(113.018526);
const scale = ref(13); // 初始缩放级别
const currentScale = ref(13); // 当前缩放级别
const showScaleInfo = ref(false);
const staticUrl = import.meta.env.VITE_APP_STATIC_URL || '/static';
// 模式相关
const isDetailMode = ref(false); // 是否为详情模式
const DETAIL_ZOOM_LEVEL = 15; // 进入详情模式的缩放级别阈值
// 标记数据
const mapMarkers = ref([]); // 详细资产点位标记
const clusterMarkers = ref([]); // 聚合统计标记(包含图片标记和数字标记)
const textMarkers = ref([]); // 单独的数字标记
// 计算要显示的标记
const displayMarkers = computed(() => {
if (isDetailMode.value) {
return mapMarkers.value;
} else {
// 聚合模式下同时显示图片标记和数字标记
return [...clusterMarkers.value, ...textMarkers.value];
}
});
// 地图上下文实例
let mapContext = null;
// 定时器相关
let scaleCheckTimer = null;
let lastScaleCheckTime = 0;
const SCALE_CHECK_INTERVAL = 2000; // 1000ms检查一次
let isCheckingScale = false;
// 初始化地图上下文
const initMapContext = () => {
// #ifdef MP-WEIXIN
try {
mapContext = uni.createMapContext('myMap');
console.log('地图上下文初始化成功');
} catch (error) {
console.error('初始化地图上下文失败:', error);
}
// #endif
// #ifdef H5
// H5环境下可能需要使用其他方式获取地图实例
console.log('H5环境地图初始化');
// #endif
};
// 获取地图缩放级别
const getMapScale = () => {
return new Promise((resolve) => {
if (!mapContext) {
resolve(currentScale.value);
return;
}
// #ifdef MP-WEIXIN
mapContext.getScale({
success: (res) => {
console.log('获取缩放级别成功:', res.scale);
resolve(res.scale);
},
fail: (err) => {
console.warn('获取缩放级别失败:', err);
resolve(currentScale.value);
}
});
// #endif
// #ifdef H5
// H5环境直接返回当前scale值
resolve(currentScale.value);
// #endif
});
};
// 定期检查缩放级别
const startScaleCheck = () => {
if (scaleCheckTimer) {
clearInterval(scaleCheckTimer);
}
scaleCheckTimer = setInterval(async () => {
if (isCheckingScale) return;
const now = Date.now();
// 限制检查频率
if (now - lastScaleCheckTime < SCALE_CHECK_INTERVAL) {
return;
}
isCheckingScale = true;
lastScaleCheckTime = now;
try {
const newScale = await getMapScale();
// 只有当缩放级别确实发生变化时才处理
if (Math.abs(newScale - currentScale.value) >= 0.5) {
console.log(`缩放级别变化: ${currentScale.value} -> ${newScale}`);
currentScale.value = newScale;
// scale.value = newScale;
// 触发缩放级别变化处理
handleScaleChange(newScale);
}
} catch (error) {
console.error('检查缩放级别失败:', error);
} finally {
isCheckingScale = false;
}
}, SCALE_CHECK_INTERVAL);
};
// 处理缩放级别变化
const handleScaleChange = (newScale) => {
// 判断是否进入详情模式
const shouldBeDetailMode = newScale >= DETAIL_ZOOM_LEVEL;
if (shouldBeDetailMode !== isDetailMode.value) {
console.log(`模式切换: ${isDetailMode.value ? '聚合' : '详情'} -> ${shouldBeDetailMode ? '详情' : '聚合'}`);
isDetailMode.value = shouldBeDetailMode;
if (shouldBeDetailMode) {
// 进入详情模式,清除聚合标记,加载详细资产
clusterMarkers.value = [];
textMarkers.value = [];
getAssetList();
} else {
// 进入聚合模式,清除详细标记,加载聚合统计
mapMarkers.value = [];
getClusterData();
}
}
// 显示缩放信息
showScaleInfo.value = true;
setTimeout(() => {
showScaleInfo.value = false;
}, 2000);
};
// 地图区域变化事件
let regionChangeTimer = null;
let isRequesting = false;
const onRegionChange = (e) => {
if (e.type === 'end') {
// 清除之前的定时器
if (regionChangeTimer) clearTimeout(regionChangeTimer);
// 设置新的定时器,防抖处理
regionChangeTimer = setTimeout(async () => {
if (isRequesting) return;
try {
const newScale = await getMapScale();
const scaleChanged = Math.abs(newScale - currentScale.value) >= 0.5;
if (scaleChanged) {
currentScale.value = newScale;
// scale.value = newScale;
await handleScaleChange(newScale);
closeBubblePopup();
}
// 只有在缩放级别没有变化,且是详情模式时才更新中心点并重新获取数据
if (!scaleChanged && isDetailMode.value) {
if (mapContext) {
mapContext.getCenterLocation({
success: (res) => {
// 检查中心点变化是否超过阈值
const latDiff = Math.abs(res.latitude - centerLatitude.value);
const lngDiff = Math.abs(res.longitude - centerLongitude.value);
if (latDiff > 0.001 || lngDiff > 0.001) {
centerLongitude.value = res.longitude;
centerLatitude.value = res.latitude;
getAssetList();
}
}
});
}
}
} catch (error) {
console.error('处理区域变化失败:', error);
}
}, 600); // 600ms防抖时间
}
};
// 手动刷新
const manualRefresh = () => {
console.log('手动刷新地图数据');
getMapScale().then((newScale) => {
currentScale.value = newScale;
scale.value = newScale;
handleScaleChange(newScale);
});
};
// 放大
const zoomIn = () => {
if (scale.value < 20) {
scale.value += 1;
currentScale.value = scale.value;
handleScaleChange(scale.value);
}
};
// 缩小
const zoomOut = () => {
if (scale.value > 3) {
scale.value -= 1;
currentScale.value = scale.value;
handleScaleChange(scale.value);
}
};
// 监听 scale 变化(通过按钮控制)
watch(
() => scale.value,
(newScale) => {
if (Math.abs(newScale - currentScale.value) >= 1) {
currentScale.value = newScale;
handleScaleChange(newScale);
}
}
);
// 刷新地图数据
const refreshMapData = () => {
if (isDetailMode.value) {
getAssetList();
} else {
getClusterData();
}
};
// 初始化标记
const initMarker = () => {
mapMarkers.value = [];
if (!details.value || details.value.length === 0) {
console.log('没有资产数据');
return;
}
details.value.forEach((item, index) => {
let latlng = item.locationCoordinates;
if (!latlng) return;
let targetLatitude = Number(latlng.split(',')[1]);
let targetLongitude = Number(latlng.split(',')[0]);
// 构建标记对象
let marker = {
id: index + 1,
latitude: targetLatitude,
longitude: targetLongitude,
customData: {
assetId: item.id || item.assetId,
assetName: item.assetName,
assetCode: item.assetCode,
assetType: getDictLabel('asset_type', item.assetType),
area: item.area,
unit: item.unit,
address: item.assetAddress,
fullData: item,
markerId: index + 1,
latitude: targetLatitude,
longitude: targetLongitude
}
};
// 判断平台,为不同平台设置不同的标记
// #ifdef H5
marker.iconPath = staticUrl + '/images/location-marker.png';
marker.width = 28;
marker.height = 57;
marker.callout = { display: 'BYCLICK' };
// #endif
// #ifdef MP-WEIXIN
marker.callout = { display: 'BYCLICK' };
// #endif
mapMarkers.value.push(marker);
});
console.log('初始化了', mapMarkers.value.length, '个详细资产标记');
};
// 生成聚合点图标(使用预留图片)
const generateClusterIcon = (village) => {
// 根据数量范围选择不同的图片
let imageName = 'asset-blue.png'; // 默认小于10的图片
//需要根据资产数量设置颜色的话可以修改这里
if (village % 3 === 0) {
imageName = 'asset-blue.png';
} else if (village % 3 === 1) {
imageName = 'asset-green.png';
} else if (village % 3 === 2) {
imageName = 'asset-orange.png';
} else {
imageName = 'asset-red.png'; // 30以上
}
// 返回图片路径
return `${staticUrl}/images/${imageName}`;
};
// 初始化聚合标记
const initClusterMarkers = (clusters) => {
clusterMarkers.value = [];
textMarkers.value = [];
if (!clusters || clusters.length === 0) {
console.log('没有聚合数据');
return;
}
const mapData = ref([
{
name: '三江村委',
center: [23.231835,113.026861]
},{
name: '三溪村委',
center: [23.231187,112.987291]
},{
name: '华㘵村委',
center: [23.267152,113.040755]
},{
name: '乐平村委',
center: [23.260015,113.018526]
},{
name: '源潭村委',
center: [23.26953,112.979652]
},{
name: '新旗村委',
center: [23.281252,113.019258]
},{
name: '范湖村委',
center: [23.299372,112.998074]
},{
name: '南联村委',
center: [23.330682,113.000471]
},{
name: '大岗村委',
center: [23.304003,112.938363]
},{
name: '竹山村委',
center: [23.283047,112.95597]
},{
name: '念德村委',
center: [23.272531,112.932292]
},{
name: '南丰劳教所',
center: [23.291387,112.906008]
},{
name: '保安村委',
center: [23.276728,112.893139]
},{
name: '湖岗村委',
center: [23.258563,112.869062]
},{
name: '黄塘村委',
center: [23.309776,112.883705]
}
]);
// 为每个聚合点创建两个标记:图片标记和数字标记
clusters.forEach((cluster, index) => {
//村委的坐标使用上面mapData的中心点
const village = mapData.value.find(item => cluster.name.indexOf(item.name.substring(0, 2)) !== -1);
// 获取聚合点图片路径和大小
const iconSize = getClusterSize(cluster.count);
const iconPath = generateClusterIcon(cluster.village);
// 1. 创建图片标记(背景)
// const imageMarkerId = `cluster_image_${index + 1}`;
const imageMarkerId = index + 1;
let imageMarker = {
id: imageMarkerId,
latitude: village?village.center[0]:cluster.longitude,
longitude: village?village.center[1]:cluster.longitude,
// title: cluster.name + '(' + cluster.count + ')',
iconPath: iconPath,
width: iconSize,
height: iconSize,
callout: {
content: cluster.name + '(' + cluster.count + ')',
// color: '#333',
// fontSize: 14,
// bgColor: '#fff',
// padding: 8,
display: 'ALWAYS'
},
// zIndex: 99, // 图片标记的层级较低
customData: {
...cluster,
isCluster: true,
markerId: imageMarkerId,
isImageMarker: true // 标记这是图片标记
}
};
//原本是打算数字直接显示在图片上面的,存在位置问题并且显示不到村委名称,代码先注释掉
// 2. 创建数字标记(文字,显示在图片上面)
// const textMarkerId = `cluster_text_${index + 1}`;
// let offset = 0.02; // 默认值
//
// // #ifdef H5
// offset = 0.002;
// // #endif
//
// // #ifdef MP-WEIXIN
// offset = -0.002;
// // #endif
//
// let textMarker = {
// id: textMarkerId,
// latitude: cluster.latitude-offset,
// longitude: cluster.longitude,
// iconPath: iconPath,
// width: 0.1,
// height: 0.1,
// zIndex: 100,
// callout: {
// content: cluster.count.toString(),
// color: '#FFFFFF', // 文字颜色
// fontSize: getFontSize(cluster.count),
//
// // 关键:完全透明的气泡背景
// bgColor: 'rgba(0,0,0,0)', // 完全透明背景
// borderWidth: 0, // 无边框
// borderColor: 'rgba(0,0,0,0)', // 透明边框
// borderRadius: 0, // 无圆角
//
// // 位置调整 - 让文字显示在图标正上方
// anchorX: 0.5, // X轴居中 (0=左, 0.5=中, 1=右)
// // anchorY: 0, // Y轴在图标顶部 (0=上, 1=下)
//
// // 偏移量调整 - 微调文字位置
// offset: [0, 0], // 向上偏移图标高度的30%
//
// // 其他样式设置
// padding: 0, // 无内边距
// textAlign: 'center', // 文字居中
// display: 'ALWAYS', // 始终显示
//
// // 注意:某些版本可能需要添加以下属性
// boxShadow: 'none', // 无阴影
// borderStyle: 'solid'
// },
// customData: {
// ...cluster,
// isCluster: true,
// markerId: textMarkerId
// }
// }
// textMarkers.value.push(textMarker);
clusterMarkers.value.push(imageMarker);
});
console.log('初始化了', clusterMarkers.value.length, '个图片聚合标记和', textMarkers.value.length, '个文字聚合标记');
};
// 根据数量获取字体大小(无论村委有多少个资产,都显示一样的大小先,有这样的需求再修改这里)
const getFontSize = (count) => {
// if (count < 10) return 14;
// if (count < 20) return 16;
// if (count < 30) return 18;
return 14; // 30以上
};
// 根据数量获取聚合点大小
const getClusterSize = (count) => {
// if (count < 10) return 30;
// if (count < 20) return 40;
// if (count < 30) return 50;
return 30; // 30以上
};
// marker点击事件
let lastTapTime = 0;
const onMarkerTap = async (e) => {
const now = Date.now();
if (now - lastTapTime < 300) return;
lastTapTime = now;
const markerId = e.detail.markerId;
// 如果是聚合模式,需要处理图片标记和文字标记
if (!isDetailMode.value) {
// 查找点击的标记
let marker = null;
// 先查找图片标记
marker = clusterMarkers.value.find((m) => m.id === markerId);
if (!marker) {
// 如果不是图片标记,查找文字标记
marker = textMarkers.value.find((m) => m.id === markerId);
}
if (marker && marker.customData) {
showBubblePopup.value = false;
await nextTick();
centerLongitude.value = marker.customData.longitude;
centerLatitude.value = marker.customData.latitude;
if (marker.customData.isCluster) {
console.log('点击聚合点,放大地图');
// 放大到详情模式级别
scale.value = DETAIL_ZOOM_LEVEL;
currentScale.value = DETAIL_ZOOM_LEVEL;
isDetailMode.value = true;
clusterMarkers.value = [];
textMarkers.value = [];
getAssetList();
}
}
} else {
// 详情模式下的处理
const marker = mapMarkers.value.find((m) => m.id === markerId);
if (marker && marker.customData) {
showBubblePopup.value = false;
await nextTick();
centerLongitude.value = marker.customData.longitude;
centerLatitude.value = marker.customData.latitude;
setTimeout(() => {
currentAsset.value = marker.customData;
currentMarker.value = marker;
showBubblePopup.value = true;
}, 150);
}
}
};
// 地图空白处点击事件
const onMapTap = (e) => {
if (!e.detail.markerId) {
closeBubblePopup();
}
};
// 跳转到资产详情
const gotoAssetDetail = (assetCode) => {
if (!assetCode) return;
uni.navigateTo({
url: `/pages/index/detail?assetCode=${assetCode}`,
success: () => {
showBubblePopup.value = false;
}
});
};
// 关闭气泡弹窗
const closeBubblePopup = () => {
showBubblePopup.value = false;
currentMarker.value = null;
};
// 获取资产列表
const details = ref({});
const getAssetList = async () => {
// 如果正在请求,则跳过
if (isRequesting) {
console.log('已有请求在进行中,跳过');
return;
}
isRequesting = true;
isLoading.value = true;
const params = {
pageNum: 1,
pageSize: 10000,
villageCommittee: villageValue.value ? [String(villageValue.value)] : [],
assetType: assetType.value ? [String(assetType.value)] : [],
unitPriceStart: rentValue.value[0]===0?null:rentValue.value[0],
unitPriceEnd: rentValue.value[1]===20000?null:rentValue.value[1],
areaStart: areaValue.value[0]===0?null:areaValue.value[0],
areaEnd: areaValue.value[1]===200?null:areaValue.value[1],
centerLat: centerLatitude.value,
centerLng: centerLongitude.value,
// distance: currentScale.value>=15&¤tScale.value<16?1000:currentScale.value>=16&¤tScale.value<17?500:100,
distance: 1000
};
console.log('获取详细资产数据,参数:', params);
try {
// 尝试调用真实接口
const res = await listInfo(params);
if (res.code === 200) {
details.value = res.rows;
console.log('获取到', details.value.length, '条资产数据');
initMarker();
toast.success(`加载了${details.value.length}个资产点`);
} else {
toast.error(res.msg || '获取数据失败');
// 使用模拟数据
// mockAssetData();
}
} catch (error) {
console.error('获取资产数据失败:', error);
// 使用模拟数据
// mockAssetData();
} finally {
isLoading.value = false;
// 请求完成后释放锁
setTimeout(() => {
isRequesting = false;
}, 300);
}
};
// 获取聚合统计数据
const getClusterData = async () => {
isLoading.value = true;
console.log('获取聚合统计数据');
const params = {
pageNum: 1,
pageSize: 10000,
villageCommittee: villageValue.value ? [String(villageValue.value)] : [],
assetType: assetType.value ? [String(assetType.value)] : [],
unitPriceStart: rentValue.value[0]===0?null:rentValue.value[0],
unitPriceEnd: rentValue.value[1]===20000?null:rentValue.value[1],
areaStart: areaValue.value[0]===0?null:areaValue.value[0],
areaEnd: areaValue.value[1]===200?null:areaValue.value[1],
};
console.log('获取聚合统计数据,参数:', params);
try {
// 尝试调用真实接口
const res = await assetCountByVillage(params);
if (res.code === 200) {
const data = res.data;
console.log('获取到', data.length, '条村委数据');
const clusters = [];
for (let i = 0; i < data.length; i++) {
if (data[i].location) {
data[i].location = data[i].location.split(',').map(Number);
clusters.push({
id: i + 1,
latitude: data[i].location[1],
longitude: data[i].location[0],
count: data[i].countValue,
village: data[i].villageCommittee,
name: data[i].villageName || '-'
});
}
}
initClusterMarkers(clusters);
toast.success(`加载了${data.length}个村委的资产点`);
} else {
toast.error(res.msg || '获取数据失败');
}
} catch (error) {
console.error('获取资产数据失败:', error);
} finally {
isLoading.value = false;
}
};
// 获取位置
const getLocation = () => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
console.log('获取位置成功:', res);
toast.success('获取当前位置成功');
centerLatitude.value = res.latitude;
centerLongitude.value = res.longitude;
},
fail: (err) => {
console.log('获取位置失败:', err);
if (err.errMsg.includes('auth deny')) {
message
.alert({
title: '授权提示',
msg: '需要获取您的位置权限才能使用地图功能,请在设置中开启',
zIndex: 9999,
closeOnClickModal: false
})
.then(() => {
uni.openSetting({
success: (settingRes) => {
console.log('权限设置结果:', settingRes.authSetting);
}
});
});
} else {
toast.error('获取位置失败');
}
}
});
};
onLoad((options) => {
console.log('页面参数:', options);
});
onMounted(() => {
// 初始化地图上下文
initMapContext();
// 根据初始缩放级别确定模式
isDetailMode.value = currentScale.value >= DETAIL_ZOOM_LEVEL;
// 获取位置
getLocation();
// 延迟加载数据,确保地图已经渲染
setTimeout(() => {
if (isDetailMode.value) {
getAssetList();
} else {
getClusterData();
}
// 开始定期检查缩放级别
startScaleCheck();
// 初始显示缩放信息
showScaleInfo.value = true;
setTimeout(() => {
showScaleInfo.value = false;
}, 3000);
}, 1000);
});
// 页面显示时重新开始检查
onShow(() => {
if (!scaleCheckTimer) {
startScaleCheck();
}
});
// 页面卸载时清理
onUnmounted(() => {
if (scaleCheckTimer) {
clearInterval(scaleCheckTimer);
scaleCheckTimer = null;
}
});
// 获取字典数据
const { dictsData, getDictLabel, getDictClass } = useDict(['asset_type', 'village_committee', 'contract_status']);
// 监听dictsData变化
watch(
() => dictsData.value,
(newVal) => {
// 确保有目标字典数据(避免初始空对象触发)
if (newVal.asset_type) {
let lists = newVal.asset_type;
assetsColumns.value = lists.map((item) => ({
label: item.label,
value: item.value
}));
}
if (newVal.village_committee) {
villageColumns.value = newVal.village_committee.map((item) => ({
label: item.label,
value: item.value
}));
}
},
{ deep: true }
);
</script>
<style lang="scss">
@use '~@/assets/styles/common.scss';
@use '~@/assets/styles/fixedAssets.scss';
.page.home {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.map-container {
width: 100%;
height: 100%;
position: relative;
}
</style>
<style lang="scss" scoped>
// 缩放控制按钮
.zoom-controls {
position: absolute;
right: 20rpx;
bottom: 150rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
z-index: 100;
.zoom-btn {
width: 80rpx;
height: 80rpx;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2);
font-size: 36rpx;
font-weight: bold;
color: #333;
&:active {
background: #f5f5f5;
transform: scale(0.95);
}
}
}
// 手动刷新按钮
.refresh-btn {
position: absolute;
right: 20rpx;
bottom: 250rpx;
width: 80rpx;
height: 80rpx;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2);
z-index: 100;
&:active {
background: #f5f5f5;
transform: scale(0.95);
}
}
// 地图缩放级别信息样式
.scale-info {
position: absolute;
top: 20rpx;
right: 20rpx;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
z-index: 100;
pointer-events: none;
backdrop-filter: blur(4rpx);
}
// 加载状态指示器
.loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 20rpx 40rpx;
border-radius: 10rpx;
font-size: 28rpx;
z-index: 100;
pointer-events: none;
}
// 气泡式弹窗样式 - 固定在屏幕上方居中
/* #ifdef H5 */
.bubble-popup {
position: fixed;
z-index: 9999;
width: 280px;
left: 50%;
top: 50%; // H5 环境下使用 50%
transform: translateX(-50%);
pointer-events: auto;
animation: fadeIn 0.3s ease;
}
/* #endif */
/* #ifdef MP-WEIXIN */
.bubble-popup {
position: fixed;
z-index: 9999;
width: 280px;
left: 50%;
top: 32%; // 小程序环境下使用 32%
transform: translateX(-50%);
pointer-events: auto;
animation: fadeIn 0.3s ease;
}
/* #endif */
.bubble-content {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 16px;
position: relative;
border: 1px solid #e4e7ed;
}
// 气泡箭头 - 指向下方(指向地图中心的图标)
.bubble-arrow {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #fff;
&::before {
content: '';
position: absolute;
top: -9px;
left: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px transparent;
border-top: 8px solid #e4e7ed;
z-index: -1;
}
}
// 资产名称行样式
.asset-name-line {
margin-bottom: 12px;
display: flex;
align-items: flex-start;
font-size: 14px;
line-height: 1.4;
}
.asset-name-link {
color: #1890ff;
font-weight: bold;
flex: 1;
text-decoration: underline;
cursor: pointer;
word-break: break-all;
&:active {
opacity: 0.8;
}
}
.asset-info {
margin-bottom: 8px;
display: flex;
align-items: flex-start;
font-size: 14px;
line-height: 1.4;
}
.info-label {
color: #606266;
min-width: 60px;
flex-shrink: 0;
}
.info-value {
color: #303133;
flex: 1;
word-break: break-all;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
// 适配不同屏幕尺寸
@media (max-width: 768px) {
/* #ifdef H5 */
.bubble-popup {
width: 250px;
top: 35%;
}
/* #endif */
/* #ifdef MP-WEIXIN */
.bubble-popup {
width: 250px;
top: 32%;
}
/* #endif */
.asset-info {
font-size: 13px;
}
.info-label {
min-width: 55px;
}
.scale-info {
font-size: 20rpx;
padding: 8rpx 16rpx;
}
.zoom-controls {
right: 10rpx;
bottom: 120rpx;
.zoom-btn {
width: 70rpx;
height: 70rpx;
font-size: 30rpx;
}
}
.refresh-btn {
right: 10rpx;
bottom: 210rpx;
width: 70rpx;
height: 70rpx;
}
.loading-indicator {
font-size: 24rpx;
padding: 15rpx 30rpx;
}
}
// 适配小屏幕手机
@media (max-width: 375px) {
.bubble-popup {
width: 90%;
max-width: 250px;
}
}
.custom-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.tag-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12rpx;
padding: 20rpx 10rpx;
width: 100%;
}
.rent-tag {
margin: 0 !important;
flex: 0 0 auto;
// 设置固定宽度,使所有标签大小一致
min-width: 170rpx;
text-align: center;
// 限制最大宽度,防止文本过长撑开标签
max-width: 200rpx;
// 字体大小统一
font-size: 24rpx;
// 圆角统一
border-radius: 8rpx;
// 内边距统一
padding: 0 12rpx;
}
// 自定义mark状态下的样式
:deep(.wd-tag--primary.wd-tag--mark) {
background-color: #1570fd !important;
border-color: #1570fd !important;
color: white !important;
}
:deep(.wd-tag.is-primary.is-mark) {
border-radius: 6px !important;
padding: 0 4px !important;
background: #1570fd !important;
color: white !important;
}
.close-btn {
float: right;
&:active {
opacity: 0.7;
}
}
/* 聚合标记数字样式 */
::v-deep .map-marker-label {
text-align: center;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
pointer-events: none; /* 确保文字标记不会干扰点击事件 */
}
/* 隐藏腾讯地图的定位控件 */
:deep(.refresh-btn) {
display: none !important;
}
</style>
常见调整位置速查
| 需求 | 调整位置 |
|---|---|
| 聚合 / 详情切换阈值 | index-map.vue 的 DETAIL_ZOOM_LEVEL(默认 15) |
| 初始中心点与缩放级别 | index-map.vue 的 centerLatitude、centerLongitude、scale(默认 13) |
| 村委中心点坐标 | index-map.vue 的 initClusterMarkers 内置村委中心点表 |
| 聚合点颜色与大小 | index-map.vue 的 generateClusterIcon、getClusterSize |
| 详情模式拉取范围 | index-map.vue 的 getAssetList 中 distance(默认 1000 米) |
| 地图 key | src/manifest.json 的 sdkConfigs.maps.tencent.key |
| 标记图片地址 | 环境变量 VITE_APP_STATIC_URL 拼接 /images/ 下的图片 |