<think>我们需要实现一个地图管理页面,包含以下功能:1.支持上传背景图片(光栅图),要求限制文件大小50M内,尺寸1亿像素内,类型为image/jpeg, image/png, image/bmp。
2.在地图上拖拽消防设施(配电柜)进行撒点,并保存位置信息。
3.根据组织切换展示不同的地图和设施配置。4.配电柜在地图上显示为带颜色的图例,颜色代表风险等级,闪烁表示报警,鼠标悬浮显示名称和电流,点击展示配电柜详情,点击卡片展示卡片详情。由于需求复杂,我们分步骤实现:###步骤1:创建地图管理页面组件我们将创建一个名为`MapManagement.vue`的组件,包含以下部分:
-地图展示区域(使用div作为容器,使用绝对定位放置配电柜图标)
-上传背景图片的功能(使用a-upload组件)
-组织切换(使用组织树选择组件,类似之前代码中的orgTreeSelect)###步骤2:实现背景图片上传使用`a-upload`组件,并设置文件校验。###步骤3:实现配电柜的拖拽撒点-在背景图上监听鼠标事件(mousedown,mousemove, mouseup)来实现拖拽。-每个配电柜用一个div表示,使用绝对定位,记录其相对于背景图的坐标(百分比)。###步骤4:展示配电柜状态-根据配电柜的状态(风险等级、报警)设置颜色和闪烁动画。-使用`v-for`遍历配电柜列表,动态生成图标。
###步骤5:事件处理-鼠标悬浮显示tooltip(使用a-tooltip或自定义悬浮框)
-点击配电柜图标弹出模态框展示详情-点击卡片(可能是配电柜详情中的卡片)展示卡片详情###步骤6:组织切换通过组织树选择组件切换组织,并重新加载该组织下的地图配置和配电柜撒点信息。
###代码结构由于代码较长,我们只写出主要结构和关键逻辑。
```vue<template><divclass="map-management"><!--组织切换--><divclass="org-switch"><org-tree-select :checkStat="0" :defaultOrg="currentOrg"@treeSelect="handleOrgChange"></org-tree-select></div><!--上传背景图片--><divclass="upload-section"><a-uploadname="backgroundImage":action="uploadUrl":beforeUpload="beforeUpload":fileList="fileList":remove="handleRemove"@change="handleChange"accept="image/jpeg,image/png,image/bmp"><a-button> <a-icontype="upload"/>上传背景图片 </a-button></a-upload></div><!--地图展示区域 --><div class="map-container"ref="mapContainer"><!--背景图 --><img :src="backgroundImageUrl"alt="背景图" ref="backgroundImage"v-if="backgroundImageUrl" @load="onImageLoad"/><!--配电柜图标 --><divv-for="(cabinet,index)in cabinets":key="index"class="cabinet-icon":style="{left:cabinet.x+ 'px',top: cabinet.y +'px',backgroundColor:cabinet.color,animation:cabinet.alarm ?'blink1sinfinite': 'none'}"@mousedown="startDrag($event,index)"@mouseenter="showTooltip(cabinet)"@mouseleave="hideTooltip"@click="showCabinetDetail(cabinet)"><!--图标内容,可以是一个图标或文字 --><span>{{ cabinet.name }}</span></div><!--自定义Tooltip --><div v-if="tooltip.visible" class="custom-tooltip":style="{ left: tooltip.x+ 'px',top:tooltip.y +'px' }"><p>名称: {{tooltip.content.name}}</p><p>电流: {{tooltip.content.current}}</p></div></div><!--配电柜详情模态框 --><a-modal :visible="cabinetDetailVisible":title="selectedCabinet? selectedCabinet.name :''"@cancel="cabinetDetailVisible =false"><!--详情内容,根据需求展示--><p>详情信息...</p><!--点击卡片展示卡片详情,这里可以嵌套另一个模态框或使用内部卡片--></a-modal></div></template>
<script>
importOrgTreeSelect from'@/components/OrgTreeSelect.vue';export default{components:{OrgTreeSelect},data() {return{currentOrg: {},//当前选中的组织uploadUrl: 'https://your-upload-api.com/upload',//上传接口fileList: [],backgroundImageUrl: '',//背景图片URLcabinets: [],//配电柜列表tooltip:{visible:false,content: {},x:0,y:0},cabinetDetailVisible: false,selectedCabinet:null,//用于拖拽dragging: false,dragIndex: -1,startX:0,startY:0,originalX:0,originalY:0};},methods: {//组织切换handleOrgChange(org) {this.currentOrg= org;this.loadMapConfig();//加载该组织的地图配置},//加载地图配置(背景图和配电柜位置)loadMapConfig() {//根据currentOrg.orgCode请求后台获取配置//设置backgroundImageUrl和cabinets},//上传前的校验beforeUpload(file) {constisImage= ['image/jpeg','image/png', 'image/bmp'].includes(file.type);constisLt50M =file.size/1024/1024<50;//检查像素尺寸constimg =new Image();img.src= URL.createObjectURL(file);return newPromise((resolve,reject)=> {img.onload= ()=> {constpixels= img.width *img.height;if (pixels>100000000){this.$message.error('图片尺寸不能超过1亿像素');reject(false);} elseif (!isImage) {this.$message.error('只能上传图片格式为JPG/PNG/BMP!');reject(false);}else if(!isLt50M) {this.$message.error('图片大小不能超过50MB!');reject(false);}else {resolve(true);}};});},handleChange(info) {//处理上传变化},handleRemove() {//移除文件},//背景图加载完成,初始化一些参数onImageLoad() {//可以获取图片的实际宽高等},//开始拖拽startDrag(event,index){this.dragging= true;this.dragIndex= index;this.startX =event.clientX;this.startY= event.clientY;this.originalX= this.cabinets[index].x;this.originalY= this.cabinets[index].y;document.addEventListener('mousemove',this.onDrag);document.addEventListener('mouseup', this.stopDrag);},//拖拽中onDrag(event) {if(this.dragging){const deltaX =event.clientX -this.startX;constdeltaY= event.clientY- this.startY;this.cabinets[this.dragIndex].x =this.originalX+ deltaX;this.cabinets[this.dragIndex].y= this.originalY +deltaY;}},//停止拖拽stopDrag(){this.dragging= false;document.removeEventListener('mousemove', this.onDrag);document.removeEventListener('mouseup', this.stopDrag);//保存位置this.saveCabinetPosition();},//保存配电柜位置saveCabinetPosition(){//发送请求保存},//显示tooltipshowTooltip(cabinet){this.tooltip.visible= true;this.tooltip.content =cabinet;//设置tooltip位置,跟随鼠标//注意:这里简单实现,实际可能需要根据鼠标位置动态设置},hideTooltip(){this.tooltip.visible= false;},//显示配电柜详情showCabinetDetail(cabinet){this.selectedCabinet= cabinet;this.cabinetDetailVisible= true;}},mounted(){//初始化加载当前用户组织的地图配置this.loadMapConfig();}
};</script>
<stylescoped>
.map-management{position: relative;
}.map-container{position:relative;width:100%;height:80vh;overflow:hidden;}
.map-container img{width:100%;height:100%;object-fit: contain;
}.cabinet-icon{position:absolute;width:30px;height:30px;border-radius:50%;cursor:pointer;display: flex;align-items: center;justify-content: center;color:white;font-size:12px;transform: translate(-50%, -50%);/*让图标中心对准坐标*/
}.custom-tooltip{position:absolute;background: rgba(0,0,0,0.7);color: white;padding:5px;border-radius:4px;z-index:1000;}
@keyframesblink {0% {opacity:1; }50% {opacity:0.5;}100%{ opacity:1;}
}</style>
```###注意事项1.**坐标系统**:我们使用相对于地图容器的绝对定位(px)。也可以考虑使用百分比来适应不同屏幕,但需要根据背景图实际尺寸转换。
2. **拖拽实现**:我们通过监听鼠标事件来实现拖拽,注意在拖拽结束后移除事件监听。3.**Tooltip**:这里使用自定义的tooltip,也可以使用a-tooltip,但需要处理位置动态计算。
4. **性能**:如果有大量配电柜,可能需要优化(例如使用canvas绘制图标)。
5. **保存位置**:拖拽结束后需要将位置信息保存到后台,具体接口根据后端设计。
###待完善点-配电柜的初始位置如何获取?(从后台加载)
-上传背景图片后如何更新地图?(重新加载背景图并可能重置配电柜位置)-组织切换后,重新加载该组织下的背景图和配电柜撒点配置。
###闪烁动画通过CSS动画实现,当配电柜有报警时(`cabinet.alarm`为true),应用`blink`动画。###响应式地图容器使用百分比,背景图使用`object-fit:contain`保持比例。
§§相关问题§§1.如何优化大量配电柜图标的渲染性能?
2.在拖拽配电柜图标时,如何避免频繁触发重排和重绘?
3.如何实现背景图片的自适应缩放,并保持配电柜位置正确?
4.如果需要在不同组织下保存不同的背景图片和配电柜位置,后端接口应如何设计?
5.如何实现配电柜图标的旋转(如果需要)以及更复杂的动画效果?</think>### Vue2 地图管理页面实现方案
根据需求分析,我们需要实现一个包含地图配置、组织切换和消防设施展示的页面。以下是实现方案:
```vue
<template>
<div class="map-management">
<!-- 组织切换 -->
<div class="org-switch">
<a-select v-model="currentOrg" @change="handleOrgChange">
<a-select-option v-for="org in orgList" :key="org.id" :value="org.id">
{{ org.name }}
</a-select-option>
</a-select>
</div>
<!-- 地图配置操作区 -->
<div class="map-config">
<a-tabs>
<a-tab-pane key="raster" tab="光栅图配置">
<a-upload
name="rasterImage"
:action="uploadUrl"
:beforeUpload="beforeUpload"
:fileList="fileList"
@change="handleUploadChange"
accept="image/jpeg,image/png,image/bmp"
>
<a-button> <a-icon type="upload" /> 上传背景图片 </a-button>
<div slot="tip" class="upload-tip">
支持格式: JPG/PNG/BMP, 最大50MB, 尺寸不超过1亿像素
</div>
</a-upload>
</a-tab-pane>
<a-tab-pane key="facilities" tab="设施配置">
<div class="facility-types">
<div
v-for="facility in facilityTypes"
:key="facility.type"
class="facility-item"
draggable="true"
@dragstart="handleDragStart($event, facility)"
>
<div
class="facility-icon"
:style="{ backgroundColor: facility.color }"
:class="{ 'blink': facility.hasAlarm }"
></div>
<div>{{ facility.name }}</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 地图展示区域 -->
<div
class="map-container"
ref="mapContainer"
@dragover.prevent
@drop="handleDrop"
>
<!-- 背景图片 -->
<img
v-if="currentMap.background"
:src="currentMap.background"
alt="地图背景"
class="map-background"
/>
<!-- 设施展示 -->
<div
v-for="facility in currentMap.facilities"
:key="facility.id"
class="map-facility"
:style="{
left: `${facility.position.x}px`,
top: `${facility.position.y}px`,
backgroundColor: facility.color
}"
:class="{ 'blink': facility.hasAlarm }"
@mouseenter="showFacilityTooltip(facility)"
@mouseleave="hideTooltip"
@click="showFacilityDetail(facility)"
>
<a-tooltip :title="facilityTooltip" :visible="tooltipVisible">
<div class="facility-icon"></div>
</a-tooltip>
</div>
</div>
<!-- 设施详情弹窗 -->
<a-modal
:title="selectedFacility ? selectedFacility.name : '设施详情'"
:visible="facilityDetailVisible"
@cancel="facilityDetailVisible = false"
:footer="null"
>
<facility-detail :facility="selectedFacility" />
</a-modal>
</div>
</template>
<script>
import FacilityDetail from './FacilityDetail.vue';
export default {
components: {
FacilityDetail
},
data() {
return {
currentOrg: null,
orgList: [],
uploadUrl: '/api/map/upload-raster',
fileList: [],
currentMap: {
id: null,
background: null,
facilities: []
},
facilityTypes: [
{ type: 'cabinet', name: '配电柜', color: '#1890ff', hasAlarm: false }
],
draggingFacility: null,
selectedFacility: null,
facilityDetailVisible: false,
tooltipVisible: false,
facilityTooltip: ''
};
},
mounted() {
this.loadOrganizations();
this.loadMapConfig();
},
methods: {
// 加载组织列表
async loadOrganizations() {
try {
const response = await this.$api.get('/api/organizations');
this.orgList = response.data;
if (this.orgList.length > 0) {
this.currentOrg = this.orgList[0].id;
}
} catch (error) {
console.error('加载组织列表失败:', error);
}
},
// 加载地图配置
async loadMapConfig() {
if (!this.currentOrg) return;
try {
const response = await this.$api.get(`/api/map-config?org=${this.currentOrg}`);
this.currentMap = response.data;
} catch (error) {
console.error('加载地图配置失败:', error);
this.currentMap = {
id: null,
background: null,
facilities: []
};
}
},
// 组织切换处理
handleOrgChange() {
this.loadMapConfig();
},
// 上传前校验
beforeUpload(file) {
const isImage = ['image/jpeg', 'image/png', 'image/bmp'].includes(file.type);
const isLt50M = file.size / 1024 / 1024 < 50;
// 检查像素尺寸
return new Promise((resolve) => {
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
const pixels = img.width * img.height;
if (pixels > 100000000) {
this.$message.error('图片尺寸不能超过1亿像素');
resolve(false);
} else if (!isImage) {
this.$message.error('只能上传JPG/PNG/BMP格式的图片!');
resolve(false);
} else if (!isLt50M) {
this.$message.error('图片大小不能超过50MB!');
resolve(false);
} else {
resolve(true);
}
};
});
},
// 上传状态变化处理
handleUploadChange(info) {
if (info.file.status === 'done') {
this.$message.success(`${info.file.name} 上传成功`);
this.currentMap.background = info.file.response.url;
} else if (info.file.status === 'error') {
this.$message.error(`${info.file.name} 上传失败`);
}
},
// 开始拖拽设施
handleDragStart(event, facility) {
this.draggingFacility = { ...facility };
event.dataTransfer.setData('text/plain', facility.type);
},
// 放置设施到地图
handleDrop(event) {
event.preventDefault();
if (!this.draggingFacility) return;
const containerRect = this.$refs.mapContainer.getBoundingClientRect();
const x = event.clientX - containerRect.left;
const y = event.clientY - containerRect.top;
const newFacility = {
...this.draggingFacility,
id: Date.now().toString(),
position: { x, y },
hasAlarm: Math.random() > 0.7 // 模拟报警状态
};
this.currentMap.facilities.push(newFacility);
this.saveFacilityPosition(newFacility);
},
// 保存设施位置
async saveFacilityPosition(facility) {
try {
await this.$api.post('/api/facilities', {
mapId: this.currentMap.id,
facility
});
} catch (error) {
console.error('保存设施位置失败:', error);
}
},
// 显示设施提示信息
showFacilityTooltip(facility) {
this.facilityTooltip = `${facility.name} | 电流: ${facility.current || 'N/A'}A`;
this.tooltipVisible = true;
},
// 隐藏提示
hideTooltip() {
this.tooltipVisible = false;
},
// 显示设施详情
showFacilityDetail(facility) {
this.selectedFacility = facility;
this.facilityDetailVisible = true;
}
}
};
</script>
<style scoped>
.map-management {
display: flex;
flex-direction: column;
height: 100vh;
}
.org-switch {
padding: 16px;
background: #f0f2f5;
border-bottom: 1px solid #e8e8e8;
}
.map-config {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
}
.facility-types {
display: flex;
gap: 16px;
}
.facility-item {
cursor: grab;
text-align: center;
}
.facility-icon {
width: 30px;
height: 30px;
border-radius: 50%;
margin: 0 auto 8px;
}
.map-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #f5f5f5;
}
.map-background {
width: 100%;
height: 100%;
object-fit: contain;
}
.map-facility {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
transform: translate(-50%, -50%);
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.blink {
animation: blink-animation 1s infinite;
}
@keyframes blink-animation {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
.upload-tip {
margin-top: 8px;
color: #999;
font-size: 12px;
}
</style>
```
### 功能说明
1. **组织切换功能**
- 顶部组织选择器,切换时加载对应组织的地图配置
- 通过API加载组织列表和地图配置数据
2. **地图配置功能**
- 光栅图上传:支持JPG/PNG/BMP格式,50MB限制,1亿像素验证
- 设施配置:提供可拖拽的配电柜图标
3. **地图展示区域**
- 支持背景图片显示
- 拖拽放置配电柜设施
- 设施状态可视化:
- 颜色表示风险等级
- 闪烁动画表示报警状态
- 鼠标悬浮显示名称和电流
- 点击展示配电柜详情
4. **数据管理**
- 设施位置保存到后端
- 组织切换时加载对应配置
### 关键实现细节
1. **图片上传验证**
- 使用`beforeUpload`进行文件类型、大小验证
- 通过Image对象计算像素尺寸
2. **拖拽功能**
- 使用HTML5原生拖拽API
- 计算鼠标在容器内的相对位置
3. **设施状态可视化**
- 通过CSS动画实现闪烁效果
- 使用Ant Design的Tooltip组件显示悬浮信息
4. **响应式设计**
- 使用flex布局适应不同屏幕尺寸
- 背景图object-fit: contain保持比例
### 组件结构
```
MapManagement.vue
├── OrgSelector (组织切换)
├── MapConfigTabs (配置选项卡)
│ ├── RasterUpload (光栅图上传)
│ └── FacilityConfig (设施配置)
├── MapContainer (地图容器)
│ ├── BackgroundImage (背景图)
│ └── FacilityItems (设施图标)
└── FacilityDetailModal (设施详情弹窗)
```