feat: 同步地大功能至2026年10月20日

This commit is contained in:
2026-10-21 09:26:25 +08:00
parent 6b5d27c68d
commit 88d624172c
198 changed files with 19375 additions and 2128 deletions
@@ -1,12 +1,14 @@
<template>
<div>
<van-action-sheet
v-model="visible"
<van-popup
:value="visible"
class="activity-schedule-calendar"
:style="{ height: '78%' }"
:close-on-click-overlay="true"
title="活动日历"
closeable>
position="right"
:style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="false"
:close-on-popstate="false">
<van-nav-bar title="课程日历" left-text="返回" left-arrow
@click-left="closeSignPopup('calendar')"></van-nav-bar>
<div class="activity-schedule-calendar__body">
<div class="activity-schedule-calendar__month-bar">
<button type="button" class="activity-schedule-calendar__month-btn" @click="changeMonth(-1)">
@@ -30,7 +32,10 @@
:class="{
'is-other-month': !day.currentMonth,
'is-selected': day.key === selectedDate,
'is-today': day.isToday
'is-today': day.isToday,
'is-signed': day.status === 'signed',
'is-available': day.status === 'available',
'is-pending': day.status === 'pending'
}"
@click="selectDay(day)">
<span>
@@ -42,13 +47,11 @@
</div>
<div class="activity-schedule-calendar__legend">
<span><i class="is-solid"></i>已签到</span>
<span><i class="is-outline"></i>可签到</span>
<span><i class="is-muted"></i>未开始</span>
<span><i class="is-signed"></i>已签到</span>
<span><i class="is-available"></i>可签到</span>
<span><i class="is-pending"></i>未开始</span>
</div>
<div v-if="showMap" :id="mapContainerId" class="activity-schedule-calendar__map"></div>
<section class="activity-schedule-calendar__schedule-card">
<div class="activity-schedule-calendar__schedule-title">
{{ selectedDate ? selectedDate + ' 的活动时段' : '活动时段' }}
@@ -72,6 +75,7 @@
v-else-if="canSignItem(item)"
type="button"
class="activity-schedule-calendar__sign-btn"
:disabled="formLoading"
@click.stop="onSign(item)">签到</button>
<span v-else class="activity-schedule-calendar__status" :class="{
'is-ended': getScheduleStatus(item) === '已结束',
@@ -88,16 +92,53 @@
</div>
</section>
</div>
</van-action-sheet>
</van-popup>
<van-popup
v-model="signVisible"
:value="positionConfirmVisible"
class="activity-schedule-position-popup"
position="right"
:style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="false"
:close-on-popstate="false"
@opened="initMap"
@closed="onPositionPopupClosed">
<div class="activity-schedule-position">
<van-nav-bar title="定位签到" left-text="返回" left-arrow
@click-left="closeSignPopup('position-confirm')"></van-nav-bar>
<div :id="mapContainerId" class="activity-schedule-calendar__map"></div>
<div class="activity-schedule-position__details">
<div class="activity-schedule-position__location-tools">
<span class="activity-schedule-position__location-status" role="status">{{ locationMessage }}</span>
<van-button size="small" plain type="info" icon="aim" :loading="locationLoading"
:disabled="formLoading || !!closingPopupKey" @click="refreshLocation">重新定位</van-button>
</div>
<div class="activity-schedule-position__title">{{ row ? row.courseName : '' }}</div>
<div v-if="selectCourseTime" class="activity-schedule-position__text">
{{ dateKey(selectCourseTime) }} {{ formatTime(selectCourseTime.courseStartTime) }} - {{ formatTime(selectCourseTime.courseEndTime) }}
</div>
<div v-if="row && row.courseLocation" class="activity-schedule-position__text">
<van-icon name="location-o"></van-icon>{{ row.courseLocation }}
</div>
<div class="activity-schedule-position__text">签到范围:{{ row ? (row.radius || 100) : 100 }} 米</div>
</div>
<div class="activity-schedule-position__footer">
<van-button type="info" block round :loading="formLoading" :disabled="formLoading || locationLoading || !!closingPopupKey"
loading-text="签到中..." @click="onConfirmPositionSign">确认签到</van-button>
</div>
</div>
</van-popup>
<van-popup
:value="signVisible"
round
closeable
:safe-area-inset-bottom="true"
:close-on-click-overlay="false"
:close-on-popstate="false"
:style="{ width: '80%', height: '66%' }"
get-container="#app"
@click-close-icon="closeSignPopup('scan')"
@close="onSignClose">
<scan-code ref="scanCodeRef"></scan-code>
</van-popup>
@@ -126,7 +167,17 @@ module.exports = {
weekdays: ["日", "一", "二", "三", "四", "五", "六"],
selectCourseTime: null,
signVisible: false,
signCodePreview: null,
positionConfirmVisible: false,
formLoading: false,
popupHistoryToken: "",
calendarHistoryOpen: false,
closingPopupKey: "",
markerLayer: null,
userMarkerLayer: null,
locationLoading: false,
locationMessage: "",
locationRequestId: 0,
map: null,
circle: null,
mapContainerId: "activityScheduleMap_" + Math.random().toString(36).slice(2)
@@ -181,9 +232,11 @@ module.exports = {
this.currentMonth = date.month() + 1
this.selectedDate = initialDate
this.visible = true
this.$nextTick(() => {
if (this.showMap) this.initMap()
})
// 所有签到模式的日历均入栈;上层扫码、二维码或定位弹框单独入栈,返回时逐层关闭。
if (!this.calendarHistoryOpen) {
this.calendarHistoryOpen = true
window.h5PopupHistory.open(this.popupHistoryToken, "calendar")
}
},
dateKey(item) {
const value = item && (item.courseDate || item.courseStartTime)
@@ -230,18 +283,43 @@ module.exports = {
}
},
onSign(courseTime) {
// 确认框关闭或定位提交期间不再切换时段,避免重复打卡和签到对象错位。
if (this.formLoading || this.positionConfirmVisible || this.signVisible || this.signCodePreview || this.closingPopupKey) return
this.selectCourseTime = courseTime
if (this.row.signType === 1) {
this.signVisible = true
this.$nextTick(() => this.$refs.scanCodeRef.init())
window.h5PopupHistory.open(this.popupHistoryToken, "scan")
this.$nextTick(() => {
if (this.signVisible) this.$refs.scanCodeRef.init()
})
} else if (this.row.signType === 2) {
this.makeCode()
} else if (this.row.signType === 3 && this.moduleType === "brand") {
this.positionSign()
this.positionConfirmVisible = true
window.h5PopupHistory.open(this.popupHistoryToken, "position-confirm")
} else {
this.$toast("此签到模式正在升级中")
}
},
/**
* popupKey 为 calendar(日历)、scan(扫码)、sign-code(二维码)或 position-confirm(定位确认);无返回值。
* 仅请求关闭栈顶弹层,由全局历史回调同步显示状态,防止连续点击造成多次返回。
*/
closeSignPopup(popupKey) {
if (this.closingPopupKey) return
if (window.h5PopupHistory.close(this.popupHistoryToken, popupKey)) {
this.closingPopupKey = popupKey
}
},
// 预览定位不提交签到;点击确认时重新获取位置,失败时保留弹框和当前时段供重试。
onConfirmPositionSign() {
if (this.formLoading || this.locationLoading || this.closingPopupKey || !this.positionConfirmVisible) return
this.positionSign()
},
// 退场动画完成后释放地图;快速重新打开时保留新弹框正在使用的实例。
onPositionPopupClosed() {
if (!this.positionConfirmVisible) this.destroyMap()
},
onSignClose() {
if (this.$refs.scanCodeRef) this.$refs.scanCodeRef.closeScan()
},
@@ -250,90 +328,214 @@ module.exports = {
? "/platform/family/mine/passiveScan"
: "/platform/trainSignUp/mine/passiveScan"
const content = jrQrcode.getQrBase64(baseUrl + "?id=" + this.selectCourseTime.id)
vant.ImagePreview([content])
// 二维码预览关闭时同步回退历史;历史回调先释放引用,避免再次触发返回。
const preview = vant.ImagePreview({
images: [content],
closeOnPopstate: false,
onClose: () => {
if (this.signCodePreview === preview) this.closeSignPopup("sign-code")
}
})
this.signCodePreview = preview
window.h5PopupHistory.open(this.popupHistoryToken, "sign-code")
},
/**
* 确认后提交所选时段的点位签到,无方法参数、无返回值。
* 接口参数:timeId 为报名时段 ID,courseId 为课程 ID,lat/lng 为定位经纬度。
* 返回对象的 code 为 0 表示成功,msg 为失败提示;定位或请求结束后释放提交状态。
*/
positionSign() {
this.getLocation(coords => {
if (!coords) return
if (this.formLoading || this.locationLoading) return
const courseTime = this.selectCourseTime
const course = this.row
const requestId = ++this.locationRequestId
this.formLoading = true
this.locationMessage = "正在获取当前位置..."
this.getLocation((coords, errorMessage) => {
// 定位失败或等待定位时已退出地图弹框,不再发起签到请求。
if (!this.visible || !this.positionConfirmVisible || requestId !== this.locationRequestId || this.row !== course || this.selectCourseTime !== courseTime) {
this.formLoading = false
return
}
this.showCurrentLocation(coords, errorMessage)
if (!coords) {
this.formLoading = false
return
}
// 地图展示使用转换后的坐标,签到请求仍传手机返回的原始 WGS-84 经纬度。
this.$axios.post("/platform/trainSignUp/mine/positionSign", {
timeId: this.selectCourseTime.id,
courseId: this.row.id,
timeId: courseTime.id,
courseId: course.id,
lat: coords.lat,
lng: coords.lng
}).then(res => {
if (res.code === 0) {
this.selectCourseTime.isAttend = true
this.$set(courseTime, "isAttend", true)
this.$toast("签到成功")
// 签到完成后清理本组件的弹框历史,返回时不再出现已完成的确认框或日历。
return window.h5PopupHistory.clear(this.popupHistoryToken)
} else {
this.$toast(res.msg)
}
}).finally(() => {
this.formLoading = false
})
})
},
/**
* callback 接收浏览器 WGS-84 定位对象 {lat, lng, accuracy} 和可选失败提示;无返回值。
* lat/lng 为纬度/经度,accuracy 为精度半径(米);失败时坐标为 null。
* 预览和确认签到均重新定位,不使用上一次定位缓存,是否提交由调用方决定。
*/
getLocation(callback) {
if (!navigator.geolocation) {
this.$toast("当前浏览器不支持定位功能")
callback(null)
callback(null, "当前浏览器不支持定位功能")
return
}
const loading = vant.Toast.loading({ message: "获取定位中...", forbidClick: false, duration: 0 })
navigator.geolocation.getCurrentPosition(position => {
loading.close()
callback({ lat: position.coords.latitude, lng: position.coords.longitude })
callback({ lat: position.coords.latitude, lng: position.coords.longitude, accuracy: position.coords.accuracy })
}, error => {
loading.close()
const messageMap = {
1: "请允许浏览器获取位置信息",
2: "无法获取当前位置",
3: "定位超时,请重试"
}
this.$toast(messageMap[error.code] || "定位失败,请稍后重试")
callback(null)
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 })
callback(null, messageMap[error.code] || "定位失败,请稍后重试")
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 })
},
// 打开地图和点击重新定位只刷新预览;请求编号隔离已关闭或已重新打开的弹框。
refreshLocation() {
if (!this.map || !this.positionConfirmVisible || this.locationLoading || this.formLoading || this.closingPopupKey) return
const requestId = ++this.locationRequestId
this.locationLoading = true
this.locationMessage = "正在获取当前位置..."
this.getLocation((coords, errorMessage) => {
if (requestId !== this.locationRequestId || !this.map || !this.positionConfirmVisible) return
this.locationLoading = false
this.showCurrentLocation(coords, errorMessage)
})
},
/**
* coords 为手机原始定位对象,errorMessage 为失败提示;无返回值,不提交签到。
* 国内坐标复用项目工具转换为腾讯地图使用的 GCJ-02,境外保留原坐标。
* 定位失败时移除旧位置点,避免将过期点位误认为当前位置。
*/
showCurrentLocation(coords, errorMessage) {
if (!coords) {
if (this.userMarkerLayer) this.userMarkerLayer.setGeometries([])
this.locationMessage = errorMessage || "暂未获取到当前位置"
this.$toast(this.locationMessage)
return
}
this.locationMessage = Number.isFinite(coords.accuracy)
? "定位精度约 " + Math.ceil(coords.accuracy) + " 米"
: "已显示我的位置"
if (!this.map || !this.userMarkerLayer) return
const converted = this.$coordinateUtil.outOfChina(coords.lng, coords.lat)
? [coords.lat, coords.lng]
: this.$coordinateUtil.wgs84ToGcj02(coords.lng, coords.lat)
const userPosition = new TMap.LatLng(converted[0], converted[1])
this.userMarkerLayer.setGeometries([{ id: "current-user", styleId: "user", position: userPosition }])
const activityPosition = this.markerLayer.getGeometries()[0].position
// 同时显示签到点和我的位置,并留出边距;两点重合时仍保留可辨识的地图范围。
const latPadding = Math.max(Math.abs(activityPosition.getLat() - converted[0]) * 0.3, 0.0003)
const lngPadding = Math.max(Math.abs(activityPosition.getLng() - converted[1]) * 0.3, 0.0003)
const bounds = new TMap.LatLngBounds(
new TMap.LatLng(Math.min(activityPosition.getLat(), converted[0]) - latPadding, Math.min(activityPosition.getLng(), converted[1]) - lngPadding),
new TMap.LatLng(Math.max(activityPosition.getLat(), converted[0]) + latPadding, Math.max(activityPosition.getLng(), converted[1]) + lngPadding)
)
this.map.fitBounds(bounds)
},
// 仅在侧滑弹框完全展开后创建地图,保证容器已有可用尺寸,避免动画期间地图空白。
initMap() {
if (!this.row.courseLocationCoordinates || typeof TMap === "undefined") return
if (!this.positionConfirmVisible || !this.showMap || !this.row.courseLocationCoordinates || typeof TMap === "undefined") return
const position = JSON.parse(this.row.courseLocationCoordinates)
const center = new TMap.LatLng(position[0], position[1])
if (this.map) this.map.destroy()
this.destroyMap()
this.map = new TMap.Map(this.mapContainerId, { zoom: 16, center })
this.markerLayer = new TMap.MultiMarker({
map: this.map,
geometries: [{ id: "activity", position: center }]
})
// 蓝点与“我的位置”标签独立于红色签到点;内嵌 SVG 不依赖外部图片请求。
const userIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="48" viewBox="0 0 80 48">'
+ '<rect x="2" y="1" width="76" height="22" rx="11" fill="#1989fa"/>'
+ '<text x="40" y="16" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">我的位置</text>'
+ '<circle cx="40" cy="36" r="11" fill="#1989fa" fill-opacity="0.2"/>'
+ '<circle cx="40" cy="36" r="6" fill="#1989fa" stroke="white" stroke-width="2"/></svg>'
this.userMarkerLayer = new TMap.MultiMarker({
map: this.map,
zIndex: 2,
styles: { user: new TMap.MarkerStyle({ width: 80, height: 48, anchor: { x: 40, y: 36 }, src: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(userIcon) }) },
geometries: []
})
this.circle = new TMap.MultiCircle({
map: this.map,
geometries: [{ center, radius: this.row.radius || 100 }]
})
this.refreshLocation()
},
// 关闭弹框或销毁组件时释放地图及覆盖物引用,避免重复打开后残留地图实例。
destroyMap() {
// 使尚未返回的定位请求失效,避免旧结果写入下一次打开的地图。
this.locationRequestId += 1
this.locationLoading = false
this.locationMessage = ""
if (this.map) this.map.destroy()
this.map = null
this.markerLayer = null
this.userMarkerLayer = null
this.circle = null
}
},
created() {
// 复用布局已加载的全局历史管理器,不在组件内注册 popstate 事件。
this.popupHistoryToken = window.h5PopupHistory.register((popupKey) => {
this.closingPopupKey = ""
if (popupKey === "calendar") {
this.calendarHistoryOpen = false
this.visible = false
} else if (popupKey === "scan") {
this.signVisible = false
} else if (popupKey === "sign-code") {
const preview = this.signCodePreview
this.signCodePreview = null
if (preview) preview.close()
} else if (popupKey === "position-confirm") {
this.positionConfirmVisible = false
this.locationRequestId += 1
this.locationLoading = false
}
})
},
beforeDestroy() {
// 页面离开时停止扫码并释放地图,再清理历史并注销回调,避免摄像头继续占用。
if (this.signVisible) this.onSignClose()
this.positionConfirmVisible = false
this.destroyMap()
window.h5PopupHistory.clear(this.popupHistoryToken).then(() => {
window.h5PopupHistory.unregister(this.popupHistoryToken)
})
}
}
</script>
<style scoped>
.activity-schedule-calendar {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fc;
}
.activity-schedule-calendar /deep/ .van-action-sheet__header {
height: 50px;
color: #1f2f43;
background: #fff;
font-size: 16px;
line-height: 50px;
}
.activity-schedule-calendar /deep/ .van-action-sheet__close {
top: 14px;
color: #7e899a;
font-size: 21px;
.activity-schedule-calendar > .van-nav-bar {
flex-shrink: 0;
}
.activity-schedule-calendar__body {
height: 100%;
padding: 14px 14px 24px;
flex: 1;
min-height: 0;
padding: 18px 14px calc(24px + env(safe-area-inset-bottom));
overflow-y: auto;
overflow-anchor: none;
box-sizing: border-box;
@@ -383,8 +585,9 @@ module.exports = {
.activity-schedule-calendar__day {
position: relative;
display: flex;
width: 38px;
height: 48px;
width: 100%;
max-width: 44px;
height: 52px;
margin: 1px auto;
padding: 0;
align-items: center;
@@ -400,13 +603,13 @@ module.exports = {
.activity-schedule-calendar__day > span {
position: absolute;
top: 4px;
top: 50%;
left: 50%;
transform: translateX(-50%);
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
width: 40px;
height: 40px;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 50%;
@@ -436,15 +639,27 @@ module.exports = {
color: #bdc5d0;
}
.activity-schedule-calendar__day.is-signed > span {
color: #148a52;
}
.activity-schedule-calendar__day.is-available > span {
color: #1675d1;
}
.activity-schedule-calendar__day.is-pending > span {
color: #66758a;
}
/* 选中蓝底与外层状态环独立:切换日期时仍能分辨已签到、可签到和未开始。 */
.activity-schedule-calendar__day.is-selected > span {
flex: 0 0 40px;
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
color: #fff;
background: #1989fa;
box-shadow: 0 5px 12px rgba(25, 137, 250, .22);
box-shadow: 0 0 0 2px #fff;
}
.activity-schedule-calendar__day.is-today .activity-schedule-calendar__day-number {
margin-top: 4px;
}
.activity-schedule-calendar__day.is-selected .activity-schedule-calendar__today-label {
@@ -454,60 +669,150 @@ module.exports = {
.activity-schedule-calendar__day-status {
position: absolute;
bottom: -3px;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
transform: translateX(-50%);
width: 42px;
height: 42px;
transform: translate(-50%, -50%);
border-radius: 50%;
box-sizing: border-box;
pointer-events: none;
}
.activity-schedule-calendar__day-status.is-signed {
background: #1989fa;
.activity-schedule-calendar__day-status.is-signed,
.activity-schedule-calendar__legend .is-signed {
border: 2px solid #19a965;
background: #eaf8f1;
}
.activity-schedule-calendar__day-status.is-available {
border: 1px solid #1989fa;
.activity-schedule-calendar__day-status.is-available,
.activity-schedule-calendar__legend .is-available {
border: 2px solid #1989fa;
background: #fff;
}
.activity-schedule-calendar__day-status.is-pending {
background: #d8dde5;
.activity-schedule-calendar__day-status.is-pending,
.activity-schedule-calendar__legend .is-pending {
border: 2px dashed #98a4b5;
}
.activity-schedule-calendar__legend {
display: flex;
margin: 12px 4px 16px;
margin: 16px 0 20px;
align-items: center;
justify-content: space-around;
color: #78869a;
font-size: 11px;
justify-content: space-between;
gap: 6px;
color: #58677b;
font-size: 12px;
}
.activity-schedule-calendar__legend span {
display: inline-flex;
padding: 8px;
align-items: center;
gap: 5px;
gap: 6px;
border-radius: 18px;
background: #fff;
white-space: nowrap;
}
.activity-schedule-calendar__legend i {
width: 9px;
height: 9px;
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 50%;
box-sizing: border-box;
}
.activity-schedule-calendar__legend .is-solid { background: #1989fa; }
.activity-schedule-calendar__legend .is-outline { border: 1px solid #1989fa; }
.activity-schedule-calendar__legend .is-muted { background: #d8dde5; }
/* 小屏幕每列不足 42px 时缩小圆环,保留日期间距和选中态的白色间隔。 */
@media (max-width: 350px) {
.activity-schedule-calendar__day-status {
width: 38px;
height: 38px;
}
.activity-schedule-calendar__day > span {
width: 30px;
height: 30px;
}
.activity-schedule-calendar__today-label,
.activity-schedule-calendar__day.is-selected .activity-schedule-calendar__today-label {
top: 0;
}
}
.activity-schedule-position-popup {
background: #f4f7fc;
}
.activity-schedule-position {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow-y: auto;
}
.activity-schedule-position > .van-nav-bar {
flex-shrink: 0;
}
.activity-schedule-calendar__map {
flex: 1 0 240px;
width: 100%;
height: 150px;
margin-bottom: 12px;
min-height: 240px;
overflow: hidden;
border-radius: 12px;
}
.activity-schedule-position__details {
flex-shrink: 0;
padding: 16px;
background: #fff;
}
.activity-schedule-position__title {
color: #263548;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.activity-schedule-position__location-tools {
display: flex;
margin-bottom: 12px;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.activity-schedule-position__location-tools .van-button {
flex-shrink: 0;
}
.activity-schedule-position__location-status {
color: #78869a;
font-size: 12px;
line-height: 18px;
}
.activity-schedule-position__text {
margin-top: 6px;
color: #78869a;
font-size: 13px;
line-height: 20px;
overflow-wrap: anywhere;
}
.activity-schedule-position__text .van-icon {
margin-right: 4px;
vertical-align: middle;
}
.activity-schedule-position__footer {
flex-shrink: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff;
}
.activity-schedule-calendar__schedule-card {
@@ -8,9 +8,12 @@
placeholder="请输入关键词查询地址"
@select="handleSelect"
:trigger-on-focus="false"
:disabled="!map"
/>
</div>
<div id="mapContainer"></div>
<el-alert v-if="mapError" :title="mapError" type="error" :closable="false" show-icon></el-alert>
<div :id="mapContainerId" class="tmap-container" v-loading="mapLoading"></div>
<slot></slot>
</div>
</template>
@@ -33,9 +36,12 @@ module.exports = {
data() {
return {
map: null,
// Vue 实例编号区分同页多个地图,避免地图挂到其他弹窗的容器中。
mapContainerId: 'tmap-container-' + this._uid,
mapLoading: false,
mapError: '',
mapDisposed: false,
poi: this.position,
appMapCenterPointX: 0,
appMapCenterPointY: 0,
markerLayer: null,
addressValue: '',
circle: null,
@@ -43,7 +49,7 @@ module.exports = {
},
methods: {
handleSelect(item) {
if (item.location) {
if (this.map && item.location) {
// 定位到中心点
const center = new TMap.LatLng(item.location.lat, item.location.lng)
this.map.setCenter(center)
@@ -52,41 +58,84 @@ module.exports = {
this.poi = [item.location.lat, item.location.lng]
}
},
async querySearchAsync(queryString, cb) {
// queryString 为用户输入的地址关键词;接口 data.data 是地点数组,cb 接收带 value 的候选项数组。
querySearchAsync(queryString, cb) {
if (!queryString) {
cb([])
return
}
const res = await this.$axios.post("/platform/TMap/suggestion", {
return this.$axios.post("/platform/TMap/suggestion", {
keyword: queryString,
}).then((res) => {
if (this.mapDisposed) return
if (res.code === 0 && res.data.status === 0) {
res.data.data.forEach((item) => {
item.value = item.title + "【详细地址:" + item.address + "】"
})
cb(res.data.data)
} else {
this.$message.error((res.data && res.data.message) || res.msg || '地点搜索失败')
cb([])
}
}).catch(() => {
if (!this.mapDisposed) cb([])
})
if (res.code === 0 && res.data.status === 0) {
res.data.data.forEach((item) => {
item.value = item.title + "【详细地址:" + item.address + "】"
})
cb(res.data.data)
} else {
this.$message.error(res.msg)
cb([])
}
},
initMap() {
const center = new TMap.LatLng(this.appMapCenterPointY, this.appMapCenterPointX)
this.map = new TMap.Map('mapContainer', {
resizeEnable: true,
// 接收 [纬度, 经度],返回数值坐标或 null;拒绝空配置,防止空字符串被当成 0。
normalizePosition(position) {
if (!Array.isArray(position) || position.length !== 2) return null
if (position.some(value => !['number', 'string'].includes(typeof value) || String(value).trim() === '')) return null
const point = position.map(value => Number(value))
if (!point.every(value => Number.isFinite(value)) || Math.abs(point[0]) > 90 || Math.abs(point[1]) > 180) return null
return point
},
// 已设置的签到点直接用于回显;仅无有效点位时读取默认中心,避免配置请求阻断已有点位。
loadMap() {
if (this.mapDisposed || this.map || this.mapLoading) return
this.mapLoading = true
this.mapError = ''
return Promise.resolve().then(() => {
if (this.mapDisposed) return null
if (typeof TMap === 'undefined' || !TMap.Map || !TMap.LatLng) {
throw new Error('腾讯地图脚本未加载成功,请刷新页面后重试')
}
const point = this.normalizePosition(this.poi)
if (point) return point
return Promise.all([
this.getConfigKey('AppMapCenterPointY'),
this.getConfigKey('AppMapCenterPointX')
]).then(values => {
const center = this.normalizePosition(values)
if (!center) throw new Error('地图默认中心未配置或无效,请检查系统配置 AppMapCenterPointX(经度)、AppMapCenterPointY(纬度)')
return center
})
}).then(point => {
// 弹窗关闭后可能仍收到配置响应,禁止在已销毁的容器中创建地图。
if (!this.mapDisposed && point) this.initMap(point)
}).catch(error => {
if (this.mapDisposed) return
this.mapError = (error && (error.message || error.msg)) || '地图初始化失败,请刷新页面后重试'
this.destroyMap()
}).finally(() => {
if (!this.mapDisposed) this.mapLoading = false
})
},
// point 为经过校验的 [纬度, 经度];只设置显示中心,不将默认中心自动保存为签到点。
initMap(point) {
const center = new TMap.LatLng(point[0], point[1])
this.map = new TMap.Map(this.mapContainerId, {
zoom: 16,
center: center
})
// 初始化
// 标记和签到半径共用同一个中心,点击选点后向表单回传腾讯地图坐标。
this.markerLayer = new TMap.MultiMarker({
map: this.map,
geometries: []
});
if (this.poi && this.poi.length > 0) {
const posi = new TMap.LatLng(this.poi[0], this.poi[1])
this.createMarker(posi)
this.map.setCenter(posi)
} else {
this.createMarker(center)
// 默认中心仅用于浏览地图;只有已配置的有效坐标才回显签到标记和范围圈。
const selectedPoint = this.normalizePosition(this.poi)
if (selectedPoint) {
this.createMarker(new TMap.LatLng(selectedPoint[0], selectedPoint[1]))
}
this.map.on("click", (event) => {
if (!this.view) {
@@ -113,17 +162,24 @@ module.exports = {
position: position
}
]);
this.circle = new TMap.MultiCircle({
map: this.map,
geometries: [{
center: position,
radius: this.radius,
}],
});
const geometries = [{center: position, radius: this.radius}]
// 重复选点复用半径图层,避免每次点击遗留一个空图层。
if (this.circle) {
this.circle.setGeometries(geometries)
} else {
this.circle = new TMap.MultiCircle({map: this.map, geometries})
}
},
async getConfigKey(key) {
const resp = await this.$axios.post("/open/common/getConfigKey", { key })
return resp.data
// key 传系统配置名 AppMapCenterPointX/Y;返回 Promise,值为 Result.data 中的经纬度配置字符串。
getConfigKey(key) {
return this.$axios.post("/open/common/getConfigKey", { key }).then(resp => resp.data)
},
// 弹窗关闭或初始化失败时释放地图及其图层、事件和 WebGL 资源。
destroyMap() {
if (this.map) this.map.destroy()
this.map = null
this.markerLayer = null
this.circle = null
}
},
watch: {
@@ -134,19 +190,19 @@ module.exports = {
this.$emit("update:position", newVal)
}
},
async mounted() {
// DOM初始化完成进行地图初始化
this.$nextTick(async () => {
this.appMapCenterPointX = await this.getConfigKey("AppMapCenterPointX")
this.appMapCenterPointY = await this.getConfigKey("AppMapCenterPointY")
this.initMap()
})
mounted() {
// 父弹窗显示完成后挂载组件,再等待当前容器完成渲染。
this.$nextTick(() => this.loadMap())
},
beforeDestroy() {
this.mapDisposed = true
this.destroyMap()
}
}
</script>
<style scoped>
#mapContainer {
.tmap-container {
padding: 0;
margin: 0;
width: 100%;