| | |
| | | <template> |
| | | <div class="center-panel"> |
| | | <div class="map-container" ref="mapContainer"></div> |
| | | <div id="map-container" class="map-container"></div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script> |
| | | import AMapLoader from '@amap/amap-jsapi-loader' |
| | | import { mapState } from 'vuex' |
| | | import { getHouseMapDistribution } from './service' |
| | | |
| | | export default { |
| | | name: 'MapPanel', |
| | |
| | | return { |
| | | map: null, |
| | | markers: [], |
| | | infoWindow: null |
| | | currentMakers: [], |
| | | infoWindow: null, |
| | | updateTimer: null, |
| | | markerObjects: {} // Store marker objects by ID |
| | | } |
| | | }, |
| | | watch: { |
| | | mapMarkerStatus(newVal) { |
| | | if (newVal === 'all') { |
| | | this.markers = this.currentMakers |
| | | this.$nextTick(() => { |
| | | this.map.clearMap() |
| | | this.addMapMarkers() |
| | | }) |
| | | } else { |
| | | let arr = this.currentMakers.filter(item => item.info.statusText == newVal) |
| | | this.markers = arr |
| | | this.$nextTick(() => { |
| | | this.map.clearMap() |
| | | this.addMapMarkers() |
| | | }) |
| | | } |
| | | |
| | | } |
| | | }, |
| | | computed: { |
| | | ...mapState([ |
| | | 'mapMarkerStatus' |
| | | ]), |
| | | }, |
| | | mounted() { |
| | | this.$nextTick(() => { |
| | | this.initMap() |
| | | this.fetchMapData() |
| | | this.startUpdateTimer() |
| | | }, |
| | | beforeDestroy() { |
| | | window.removeEventListener('resize', () => { |
| | | this.map && this.map.resize() |
| | | }) |
| | | if (this.updateTimer) { |
| | | clearInterval(this.updateTimer) |
| | | } |
| | | }, |
| | | methods: { |
| | | async initMap() { |
| | | try { |
| | | const AMap = await AMapLoader.load({ |
| | | key: '526e04b30ceba8f217c5def5a92392f9', |
| | | version: '2.0', |
| | | plugins: ['AMap.ToolBar', 'AMap.Scale'] |
| | | }) |
| | | |
| | | this.map = new AMap.Map(this.$refs.mapContainer, { |
| | | zoom: 16, |
| | | center: [91.1172, 29.6487], |
| | | viewMode: '3D', |
| | | pitch: 35, |
| | | mapStyle: 'amap://styles/normal', |
| | | features: ['bg', 'road', 'building', 'point'], |
| | | buildingAnimation: true |
| | | }) |
| | | |
| | | this.map.addControl(new AMap.ToolBar({ |
| | | position: 'RB' |
| | | })) |
| | | this.map.addControl(new AMap.Scale({ |
| | | position: 'RB' |
| | | })) |
| | | |
| | | this.infoWindow = new AMap.InfoWindow({ |
| | | offset: new AMap.Pixel(0, -30), |
| | | closeWhenClickMap: true, |
| | | autoMove: true, |
| | | anchor: 'bottom-center' |
| | | }) |
| | | |
| | | this.map.on('click', () => { |
| | | if (this.infoWindow) { |
| | | this.infoWindow.close() |
| | | } |
| | | }) |
| | | |
| | | this.addMarkers() |
| | | } catch (error) { |
| | | console.error('地图加载失败:', error) |
| | | } |
| | | startUpdateTimer() { |
| | | this.updateTimer = setInterval(() => { |
| | | this.fetchMapData() |
| | | }, 3000) |
| | | }, |
| | | addMarkers() { |
| | | const houses = [ |
| | | { |
| | | position: [91.1172, 29.6487], |
| | | title: '新城大道房源', |
| | | status: 'waiting', |
| | | content: ` |
| | | <div class="info-window" data-status="waiting"> |
| | | <div class="info-title">房屋状态:待租出</div> |
| | | <div class="info-content"> |
| | | <p>房屋编号:新城大道</p> |
| | | <p>房屋状态:待租出</p> |
| | | <p>租赁面积:1000/2000㎡</p> |
| | | <p>本季租金:500/2000元/月</p> |
| | | </div> |
| | | </div> |
| | | ` |
| | | }, |
| | | { |
| | | position: [91.1272, 29.6587], |
| | | title: '拉萨路房源', |
| | | status: 'rented', |
| | | content: ` |
| | | <div class="info-window" data-status="rented"> |
| | | <div class="info-title">房屋状态:已租出</div> |
| | | <div class="info-content"> |
| | | <p>房屋编号:拉萨路</p> |
| | | <p>房屋状态:已租出</p> |
| | | <p>租赁面积:800/1500㎡</p> |
| | | <p>本季租金:400/1800元/月</p> |
| | | </div> |
| | | </div> |
| | | ` |
| | | } |
| | | ] |
| | | |
| | | houses.forEach(house => { |
| | | const circles = [] |
| | | const baseRadius = 15 |
| | | const numCircles = 3 |
| | | |
| | | const centerCircle = new AMap.Circle({ |
| | | center: house.position, |
| | | radius: baseRadius / 2, |
| | | fillColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | fillOpacity: 0.8, |
| | | strokeWeight: 0, |
| | | zIndex: numCircles + 2, |
| | | bubble: true, |
| | | cursor: 'pointer' |
| | | }) |
| | | |
| | | for (let i = 0; i < numCircles; i++) { |
| | | circles.push(new AMap.Circle({ |
| | | center: house.position, |
| | | radius: baseRadius * (i + 1), |
| | | fillColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | fillOpacity: 0.3 - (i * 0.05), |
| | | strokeColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | strokeWeight: 2, |
| | | strokeOpacity: 0.5 - (i * 0.08), |
| | | zIndex: numCircles - i, |
| | | bubble: true, |
| | | cursor: 'pointer' |
| | | })) |
| | | } |
| | | |
| | | const clickHandler = () => { |
| | | if (this.infoWindow) { |
| | | this.infoWindow.setContent(house.content) |
| | | this.infoWindow.open(this.map, house.position) |
| | | } |
| | | } |
| | | |
| | | centerCircle.on('click', clickHandler) |
| | | circles.forEach(circle => circle.on('click', clickHandler)) |
| | | |
| | | this.map.add([centerCircle, ...circles]) |
| | | this.markers.push({ center: centerCircle, rings: circles }) |
| | | |
| | | let phase = 0 |
| | | const animate = () => { |
| | | phase += 0.15 |
| | | const scale = 1 + 0.6 * Math.sin(phase) |
| | | |
| | | circles.forEach((circle, index) => { |
| | | const currentRadius = baseRadius * (index + 1) |
| | | circle.setRadius(currentRadius * scale) |
| | | |
| | | const baseOpacity = 0.3 - (index * 0.05) |
| | | const opacityScale = 1 + 0.5 * Math.sin(phase) |
| | | circle.setOptions({ |
| | | fillOpacity: baseOpacity * opacityScale, |
| | | strokeOpacity: (0.5 - (index * 0.08)) * opacityScale |
| | | }) |
| | | fetchMapData() { |
| | | getHouseMapDistribution().then(res => { |
| | | if (res.code === 200) { |
| | | const newData = res.data.map(item => { |
| | | item.position = [item.longitude, item.latitude] |
| | | item.info = { |
| | | address: item.houseAddress, |
| | | name: item.houseName, |
| | | status: item.houseStatus, |
| | | rentStatus: item.rentStatus, |
| | | statusText: item.houseStatus == 1 ? '待出租' : item.houseStatus == 2 ? '已出租' : item.houseStatus == 3 ? '维修中' : '欠费', |
| | | tenant: item.tenant, |
| | | rent: item.rent, |
| | | color: item.houseStatus == 1 ? '#618CE9' : item.houseStatus == 2 ? '#FDAE03' : item.houseStatus == 3 ? '#F64E4F' : '#0FBE6B' |
| | | } |
| | | return item |
| | | }) |
| | | |
| | | requestAnimationFrame(animate) |
| | | if (!this.map) { |
| | | this.currentMakers = JSON.parse(JSON.stringify(newData)) |
| | | this.markers = newData |
| | | this.$nextTick(() => { |
| | | this.initMap() |
| | | window.addEventListener('resize', () => { |
| | | this.map && this.map.resize() |
| | | }) |
| | | }) |
| | | } else { |
| | | this.updateMarkers(newData) |
| | | } |
| | | } |
| | | animate() |
| | | }) |
| | | } |
| | | }, |
| | | updateMarkers(newData) { |
| | | // Update existing markers and add new ones |
| | | newData.forEach(newMarker => { |
| | | const markerId = `${newMarker.longitude}-${newMarker.latitude}` |
| | | const existingMarker = this.markerObjects[markerId] |
| | | |
| | | if (existingMarker) { |
| | | // Update existing marker content |
| | | const content = this.generateMarkerContent(newMarker) |
| | | existingMarker.setContent(content) |
| | | } else { |
| | | // Create new marker |
| | | const content = this.generateMarkerContent(newMarker) |
| | | const markerObj = new AMap.Marker({ |
| | | position: newMarker.position, |
| | | content: content, |
| | | anchor: 'center', |
| | | offset: new AMap.Pixel(0, 0), |
| | | zIndex: 100 |
| | | }) |
| | | |
| | | markerObj.on('click', () => { |
| | | this.showInfoWindow(markerObj, newMarker.info) |
| | | }) |
| | | |
| | | markerObj.setMap(this.map) |
| | | this.markerObjects[markerId] = markerObj |
| | | } |
| | | }) |
| | | |
| | | // Remove markers that no longer exist |
| | | Object.keys(this.markerObjects).forEach(markerId => { |
| | | const [longitude, latitude] = markerId.split('-') |
| | | const markerExists = newData.some(marker => |
| | | marker.longitude === parseFloat(longitude) && |
| | | marker.latitude === parseFloat(latitude) |
| | | ) |
| | | |
| | | if (!markerExists) { |
| | | this.markerObjects[markerId].setMap(null) |
| | | delete this.markerObjects[markerId] |
| | | } |
| | | }) |
| | | |
| | | this.currentMakers = JSON.parse(JSON.stringify(newData)) |
| | | this.markers = newData |
| | | }, |
| | | generateMarkerContent(marker) { |
| | | return ` |
| | | <div class="marker-container"> |
| | | <svg width="120" height="120" viewBox="0 0 120 120"> |
| | | <defs> |
| | | <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
| | | <feGaussianBlur stdDeviation="3" result="coloredBlur"/> |
| | | <feMerge> |
| | | <feMergeNode in="coloredBlur"/> |
| | | <feMergeNode in="SourceGraphic"/> |
| | | </feMerge> |
| | | </filter> |
| | | </defs> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="marker" cx="60" cy="60" r="8" fill="${marker.info.color}" filter="url(#glow)"> |
| | | <animate attributeName="r" values="8;10;8" dur="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="fill-opacity" values="1;0.8;1" dur="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | </svg> |
| | | </div> |
| | | ` |
| | | }, |
| | | async initMap() { |
| | | const map = await AMapLoader.load({ |
| | | key: '526e04b30ceba8f217c5def5a92392f9', |
| | | version: '2.0', |
| | | plugins: ['AMap.MarkerClusterer'] |
| | | }) |
| | | |
| | | this.map = new map.Map('map-container', { |
| | | zoom: 14, |
| | | center: [91.172119, 29.652941], |
| | | mapStyle: 'amap://styles/normal' |
| | | }) |
| | | |
| | | this.map.on('click', () => { |
| | | this.markers = this.currentMakers |
| | | this.$nextTick(() => { |
| | | this.map.clearMap() |
| | | this.addMapMarkers() |
| | | }) |
| | | }) |
| | | |
| | | this.addMapMarkers() |
| | | }, |
| | | addMapMarkers() { |
| | | this.markers.forEach(marker => { |
| | | const content = ` |
| | | <div class="marker-container"> |
| | | <svg width="120" height="120" viewBox="0 0 120 120"> |
| | | <defs> |
| | | <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
| | | <feGaussianBlur stdDeviation="3" result="coloredBlur"/> |
| | | <feMerge> |
| | | <feMergeNode in="coloredBlur"/> |
| | | <feMergeNode in="SourceGraphic"/> |
| | | </feMerge> |
| | | </filter> |
| | | </defs> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="marker" cx="60" cy="60" r="8" fill="${marker.info.color}" filter="url(#glow)"> |
| | | <animate attributeName="r" values="8;10;8" dur="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="fill-opacity" values="1;0.8;1" dur="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | </svg> |
| | | </div> |
| | | ` |
| | | |
| | | const markerObj = new AMap.Marker({ |
| | | position: marker.position, |
| | | content: content, |
| | | anchor: 'center', |
| | | offset: new AMap.Pixel(0, 0), |
| | | zIndex: 100 |
| | | }) |
| | | |
| | | markerObj.on('click', () => { |
| | | this.showInfoWindow(markerObj, marker.info) |
| | | }) |
| | | |
| | | markerObj.setMap(this.map) |
| | | }) |
| | | }, |
| | | showInfoWindow(marker, info) { |
| | | const content = ` |
| | | <div class="map-info-card" style="min-width:300px !important;color:#fff;background-color: rgba(146, 146, 146, 0.7) !important;padding: 10px;border-radius: 12px;box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);border-radius:0px 35px 35px 0px;"> |
| | | <div class="info-body"> |
| | | <div class="info-item">房屋地址:${info.address}</div> |
| | | <div class="info-item">房屋名称:${info.name}</div> |
| | | <div class="info-item">房屋状态:<span class="status-tag" style="background: rgba(${info.color.replace(/[^\d,]/g, '')}, 0.1); color: ${info.color};">${info.statusText}</span></div> |
| | | <div class="info-item">租户:${info.tenant}</div> |
| | | <div class="info-item"> |
| | | 租金状态:${info.rentStatus} |
| | | </div> |
| | | <div class="info-item"> |
| | | 本季租金:${info.rent} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ` |
| | | |
| | | const infoWindow = new AMap.InfoWindow({ |
| | | content: content, |
| | | offset: new AMap.Pixel(0, -20), |
| | | closeWhenClickMap: true, |
| | | anchor: 'bottom-center', |
| | | isCustom: true |
| | | }) |
| | | |
| | | infoWindow.open(this.map, marker.getPosition()) |
| | | }, |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | <style scoped lang="scss"> |
| | | .center-panel { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | position: relative; |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .map-container { |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | background: rgba(255, 255, 255, 0.05); |
| | | width: 100%; |
| | | height: 100vh; |
| | | height: 100%; |
| | | flex: 1; |
| | | position: relative; |
| | | } |
| | | |
| | | .info-window { |
| | | padding: 10px; |
| | | background: rgba(146, 146, 146, 0.5); |
| | | background: rgba(146, 146, 146, 0.7); |
| | | border-radius: 8px; |
| | | z-index: 10003; |
| | | min-width: 200px; |
| | |
| | | .info-title { |
| | | font-size: 16px; |
| | | font-weight: bold; |
| | | color: #fff; |
| | | color: #fff !important; |
| | | margin-bottom: 12px; |
| | | padding-bottom: 8px; |
| | | border-bottom: 1px solid #eee; |
| | |
| | | } |
| | | |
| | | .info-window[data-status="waiting"] .info-title { |
| | | color: #fff; |
| | | color: #2196F3; |
| | | } |
| | | |
| | | .info-window[data-status="rented"] .info-title { |
| | | color: #fff; |
| | | color: #FF9800; |
| | | } |
| | | |
| | | .info-window[data-status="repairing"] .info-title { |
| | | color: #F44336; |
| | | } |
| | | |
| | | .info-window[data-status="overdue"] .info-title { |
| | | color: #39C5BB; |
| | | } |
| | | |
| | | .amap-info { |
| | |
| | | } |
| | | |
| | | .amap-info-content { |
| | | background: rgba(146, 146, 146, 0.5); |
| | | background: rgba(146, 146, 146, 0.7); |
| | | padding: 0 !important; |
| | | } |
| | | |
| | | .amap-info-sharp { |
| | | display: none !important; |
| | | } |
| | | </style> |
| | | |
| | | .marker-container { |
| | | width: 120px; |
| | | height: 120px; |
| | | transform: translate(-50%, -50%); |
| | | pointer-events: all; |
| | | filter: drop-shadow(0 4px 12px rgba(79, 217, 255, 0.4)); |
| | | |
| | | } |
| | | |
| | | svg { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .ripple { |
| | | transform-origin: center; |
| | | stroke-width: 3; |
| | | } |
| | | |
| | | .marker { |
| | | filter: drop-shadow(0 0 8px #4fd9ff); |
| | | } |
| | | |
| | | .map-info-card { |
| | | border: 1px solid rgba(79, 217, 255, 0.2); |
| | | overflow: hidden; |
| | | width: 300px !important; |
| | | backdrop-filter: blur(12px); |
| | | |
| | | .info-header { |
| | | padding: 12px 15px; |
| | | border-bottom: 1px solid rgba(79, 217, 255, 0.15); |
| | | |
| | | .info-title { |
| | | color: #fff; |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | position: relative; |
| | | padding-left: 12px; |
| | | |
| | | &::before { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | width: 3px; |
| | | height: 14px; |
| | | background: linear-gradient(to bottom, #4fd9ff, #568aea); |
| | | border-radius: 2px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .info-body { |
| | | padding: 15px; |
| | | background: rgba(15, 19, 37, 0.95); |
| | | |
| | | .info-item { |
| | | font-size: 14px; |
| | | color: rgba(255, 255, 255, 0.9); |
| | | margin-bottom: 12px; |
| | | line-height: 1.5; |
| | | |
| | | &:last-child { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .status-tag { |
| | | display: inline-block; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .rent-status { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-top: 4px; |
| | | width: 100%; |
| | | |
| | | .progress-bar { |
| | | flex: 1; |
| | | height: 4px; |
| | | background: rgba(255, 255, 255, 0.1); |
| | | border-radius: 2px; |
| | | overflow: hidden; |
| | | position: relative; |
| | | |
| | | .progress { |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | height: 100%; |
| | | border-radius: 2px; |
| | | transition: width 0.3s ease; |
| | | } |
| | | } |
| | | |
| | | .rent-text { |
| | | font-size: 13px; |
| | | color: rgba(255, 255, 255, 0.9); |
| | | white-space: nowrap; |
| | | min-width: 80px; |
| | | text-align: right; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |