940 lines
32 KiB
Vue
940 lines
32 KiB
Vue
<template>
|
||
<div>
|
||
<van-popup
|
||
:value="visible"
|
||
class="activity-schedule-calendar"
|
||
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)">
|
||
<van-icon name="arrow-left"></van-icon>
|
||
</button>
|
||
<div class="activity-schedule-calendar__month-title">{{ currentYear }}年{{ currentMonth }}月</div>
|
||
<button type="button" class="activity-schedule-calendar__month-btn" @click="changeMonth(1)">
|
||
<van-icon name="arrow"></van-icon>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="activity-schedule-calendar__weekdays">
|
||
<span v-for="item in weekdays" :key="item">{{ item }}</span>
|
||
</div>
|
||
<div class="activity-schedule-calendar__days">
|
||
<button
|
||
v-for="day in calendarDays"
|
||
:key="day.key"
|
||
type="button"
|
||
class="activity-schedule-calendar__day"
|
||
:class="{
|
||
'is-other-month': !day.currentMonth,
|
||
'is-selected': day.key === selectedDate,
|
||
'is-today': day.isToday,
|
||
'is-signed': day.status === 'signed',
|
||
'is-available': day.status === 'available',
|
||
'is-pending': day.status === 'pending'
|
||
}"
|
||
@click="selectDay(day)">
|
||
<span>
|
||
<span class="activity-schedule-calendar__day-number">{{ day.day }}</span>
|
||
<small v-if="day.isToday" class="activity-schedule-calendar__today-label">今</small>
|
||
</span>
|
||
<i v-if="day.status" class="activity-schedule-calendar__day-status" :class="'is-' + day.status"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="activity-schedule-calendar__legend">
|
||
<span><i class="is-signed"></i>已签到</span>
|
||
<span><i class="is-available"></i>可签到</span>
|
||
<span><i class="is-pending"></i>未开始</span>
|
||
</div>
|
||
|
||
<section class="activity-schedule-calendar__schedule-card">
|
||
<div class="activity-schedule-calendar__schedule-title">
|
||
{{ selectedDate ? selectedDate + ' 的活动时段' : '活动时段' }}
|
||
</div>
|
||
<div v-if="selectedTimes.length" class="activity-schedule-calendar__schedule-list">
|
||
<div v-for="item in selectedTimes" :key="item.id" class="activity-schedule-calendar__schedule-item">
|
||
<div class="activity-schedule-calendar__schedule-main">
|
||
<van-icon :name="item.isAttend === true ? 'checked' : 'clock-o'"></van-icon>
|
||
<div class="activity-schedule-calendar__schedule-copy">
|
||
<div class="activity-schedule-calendar__schedule-time">
|
||
{{ formatTime(item.courseStartTime) }} - {{ formatTime(item.courseEndTime) }}
|
||
</div>
|
||
<div v-if="row && row.courseLocation" class="activity-schedule-calendar__schedule-location">
|
||
<van-icon name="location-o"></van-icon>
|
||
<span>{{ row.courseLocation }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span v-if="item.isAttend === true" class="activity-schedule-calendar__status is-success">已签到</span>
|
||
<button
|
||
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) === '已结束',
|
||
'is-progressing': getScheduleStatus(item) === '进行中'
|
||
}">
|
||
{{ getScheduleStatus(item) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="activity-schedule-calendar__empty">
|
||
<img src="/assets/mobile/img/activity/schedule-empty.png?v=20260821-transparent" alt="">
|
||
<div class="activity-schedule-calendar__empty-title">当天暂无签到时段</div>
|
||
<div class="activity-schedule-calendar__empty-desc">当前日期没有需要签到的活动安排</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</van-popup>
|
||
|
||
<van-popup
|
||
: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>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
module.exports = {
|
||
name: "ActivityScheduleCalendar",
|
||
props: {
|
||
moduleType: {
|
||
type: String,
|
||
default: "brand"
|
||
}
|
||
},
|
||
components: {
|
||
"scan-code": httpVueLoader("/components/plugins/sysScanCode/index.vue?v=" + new Date().getTime())
|
||
},
|
||
data() {
|
||
return {
|
||
visible: false,
|
||
row: null,
|
||
currentYear: new Date().getFullYear(),
|
||
currentMonth: new Date().getMonth() + 1,
|
||
selectedDate: "",
|
||
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)
|
||
}
|
||
},
|
||
computed: {
|
||
courseTimes() {
|
||
return this.row && Array.isArray(this.row.courseTimes) ? this.row.courseTimes : []
|
||
},
|
||
scheduleDateMap() {
|
||
const map = {}
|
||
this.courseTimes.forEach(item => {
|
||
const key = this.dateKey(item)
|
||
if (!key) return
|
||
if (!map[key]) map[key] = []
|
||
map[key].push(item)
|
||
})
|
||
return map
|
||
},
|
||
selectedTimes() {
|
||
return this.scheduleDateMap[this.selectedDate] || []
|
||
},
|
||
canSign() {
|
||
return !!(this.row && this.row.isSign === true && this.row.isMobileSign === true)
|
||
},
|
||
showMap() {
|
||
return !!(this.moduleType === "brand" && this.row && this.row.isMobileSign === true && this.row.signType === 3)
|
||
},
|
||
calendarDays() {
|
||
const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1)
|
||
const startDate = new Date(this.currentYear, this.currentMonth - 1, 1 - firstDay.getDay())
|
||
const todayKey = this.formatDateKey(new Date())
|
||
return Array.from({ length: 42 }, (_, index) => {
|
||
const date = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + index)
|
||
const key = this.formatDateKey(date)
|
||
return {
|
||
key,
|
||
day: date.getDate(),
|
||
currentMonth: date.getFullYear() === this.currentYear && date.getMonth() + 1 === this.currentMonth,
|
||
isToday: key === todayKey,
|
||
status: this.getDayStatus(this.scheduleDateMap[key] || [])
|
||
}
|
||
})
|
||
}
|
||
},
|
||
methods: {
|
||
onOpen(row) {
|
||
this.row = row
|
||
const initialDate = this.formatDateKey(new Date())
|
||
const date = this.$moment(initialDate)
|
||
this.currentYear = date.year()
|
||
this.currentMonth = date.month() + 1
|
||
this.selectedDate = initialDate
|
||
this.visible = true
|
||
// 所有签到模式的日历均入栈;上层扫码、二维码或定位弹框单独入栈,返回时逐层关闭。
|
||
if (!this.calendarHistoryOpen) {
|
||
this.calendarHistoryOpen = true
|
||
window.h5PopupHistory.open(this.popupHistoryToken, "calendar")
|
||
}
|
||
},
|
||
dateKey(item) {
|
||
const value = item && (item.courseDate || item.courseStartTime)
|
||
return value ? this.$moment(value).format("YYYY-MM-DD") : ""
|
||
},
|
||
formatDateKey(date) {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||
const day = String(date.getDate()).padStart(2, "0")
|
||
return `${year}-${month}-${day}`
|
||
},
|
||
formatTime(value) {
|
||
return value ? this.$moment(value).format("HH:mm") : "--"
|
||
},
|
||
canSignItem(item) {
|
||
if (!this.canSign || !item || !item.courseStartTime || !item.courseEndTime) return false
|
||
const now = this.$moment()
|
||
return !now.isBefore(this.$moment(item.courseStartTime)) && !now.isAfter(this.$moment(item.courseEndTime))
|
||
},
|
||
getScheduleStatus(item) {
|
||
if (!item || !item.courseStartTime || !item.courseEndTime) return "待开始"
|
||
const now = this.$moment()
|
||
if (now.isBefore(this.$moment(item.courseStartTime))) return "待开始"
|
||
if (now.isAfter(this.$moment(item.courseEndTime))) return "已结束"
|
||
return "进行中"
|
||
},
|
||
getDayStatus(items) {
|
||
if (items.some(item => item.isAttend === true)) return "signed"
|
||
if (items.some(item => this.canSignItem(item))) return "available"
|
||
if (items.some(item => item.courseStartTime && this.$moment().isBefore(this.$moment(item.courseStartTime)))) return "pending"
|
||
return ""
|
||
},
|
||
changeMonth(offset) {
|
||
const date = new Date(this.currentYear, this.currentMonth - 1 + offset, 1)
|
||
this.currentYear = date.getFullYear()
|
||
this.currentMonth = date.getMonth() + 1
|
||
},
|
||
selectDay(day) {
|
||
this.selectedDate = day.key
|
||
if (!day.currentMonth) {
|
||
const date = this.$moment(day.key)
|
||
this.currentYear = date.year()
|
||
this.currentMonth = date.month() + 1
|
||
}
|
||
},
|
||
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
|
||
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.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()
|
||
},
|
||
makeCode() {
|
||
const baseUrl = this.moduleType === "family"
|
||
? "/platform/family/mine/passiveScan"
|
||
: "/platform/trainSignUp/mine/passiveScan"
|
||
const content = jrQrcode.getQrBase64(baseUrl + "?id=" + this.selectCourseTime.id)
|
||
// 二维码预览关闭时同步回退历史;历史回调先释放引用,避免再次触发返回。
|
||
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() {
|
||
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: courseTime.id,
|
||
courseId: course.id,
|
||
lat: coords.lat,
|
||
lng: coords.lng
|
||
}).then(res => {
|
||
if (res.code === 0) {
|
||
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) {
|
||
callback(null, "当前浏览器不支持定位功能")
|
||
return
|
||
}
|
||
navigator.geolocation.getCurrentPosition(position => {
|
||
callback({ lat: position.coords.latitude, lng: position.coords.longitude, accuracy: position.coords.accuracy })
|
||
}, error => {
|
||
const messageMap = {
|
||
1: "请允许浏览器获取位置信息",
|
||
2: "无法获取当前位置",
|
||
3: "定位超时,请重试"
|
||
}
|
||
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.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])
|
||
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;
|
||
background: #f4f7fc;
|
||
}
|
||
|
||
.activity-schedule-calendar > .van-nav-bar {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-schedule-calendar__body {
|
||
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;
|
||
}
|
||
|
||
.activity-schedule-calendar__month-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.activity-schedule-calendar__month-btn {
|
||
display: flex;
|
||
width: 36px;
|
||
height: 36px;
|
||
padding: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid #edf1f6;
|
||
border-radius: 50%;
|
||
color: #263548;
|
||
background: #fff;
|
||
box-shadow: 0 5px 14px rgba(55, 85, 126, .08);
|
||
}
|
||
|
||
.activity-schedule-calendar__month-title {
|
||
color: #1f2f43;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.activity-schedule-calendar__weekdays,
|
||
.activity-schedule-calendar__days {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
text-align: center;
|
||
}
|
||
|
||
.activity-schedule-calendar__weekdays {
|
||
margin-bottom: 5px;
|
||
color: #58677b;
|
||
font-size: 12px;
|
||
line-height: 28px;
|
||
}
|
||
|
||
.activity-schedule-calendar__day {
|
||
position: relative;
|
||
display: flex;
|
||
width: 100%;
|
||
max-width: 44px;
|
||
height: 52px;
|
||
margin: 1px auto;
|
||
padding: 0;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
border: 0;
|
||
color: #263548;
|
||
background: transparent;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.activity-schedule-calendar__day > span {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 1;
|
||
display: flex;
|
||
width: 34px;
|
||
height: 34px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.activity-schedule-calendar__day-number {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
line-height: 14px;
|
||
}
|
||
|
||
.activity-schedule-calendar__today-label {
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 0;
|
||
left: 0;
|
||
color: #1989fa;
|
||
font-size: 10px;
|
||
line-height: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.activity-schedule-calendar__day.is-other-month > span {
|
||
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 {
|
||
color: #fff;
|
||
background: #1989fa;
|
||
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 {
|
||
top: 2px;
|
||
color: #fff;
|
||
}
|
||
|
||
.activity-schedule-calendar__day-status {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 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,
|
||
.activity-schedule-calendar__legend .is-signed {
|
||
border: 2px solid #19a965;
|
||
background: #eaf8f1;
|
||
}
|
||
|
||
.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,
|
||
.activity-schedule-calendar__legend .is-pending {
|
||
border: 2px dashed #98a4b5;
|
||
}
|
||
|
||
.activity-schedule-calendar__legend {
|
||
display: flex;
|
||
margin: 16px 0 20px;
|
||
align-items: center;
|
||
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: 6px;
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.activity-schedule-calendar__legend i {
|
||
flex-shrink: 0;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 小屏幕每列不足 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%;
|
||
min-height: 240px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.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 {
|
||
overflow: hidden;
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
box-shadow: 0 7px 20px rgba(55, 85, 126, .07);
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-title {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid #edf1f6;
|
||
color: #263548;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-item {
|
||
display: flex;
|
||
min-height: 70px;
|
||
padding: 12px 14px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid #edf1f6;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-item:last-child { border-bottom: 0; }
|
||
|
||
.activity-schedule-calendar__schedule-main {
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: flex-start;
|
||
gap: 9px;
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-main > .van-icon {
|
||
margin-top: 2px;
|
||
color: #1989fa;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-time {
|
||
color: #263548;
|
||
font-size: 15px;
|
||
line-height: 22px;
|
||
}
|
||
|
||
.activity-schedule-calendar__schedule-location {
|
||
display: flex;
|
||
margin-top: 5px;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: #7d899a;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
}
|
||
|
||
.activity-schedule-calendar__status {
|
||
flex: 0 0 auto;
|
||
padding: 4px 8px;
|
||
border-radius: 5px;
|
||
color: #8b96a7;
|
||
background: #f1f4f8;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.activity-schedule-calendar__status.is-success {
|
||
color: #19a965;
|
||
background: #eaf8f1;
|
||
}
|
||
|
||
.activity-schedule-calendar__status.is-progressing {
|
||
color: #ff8a00;
|
||
background: #fff4e6;
|
||
}
|
||
|
||
.activity-schedule-calendar__status.is-ended {
|
||
color: #98a2b2;
|
||
background: #eef1f5;
|
||
}
|
||
|
||
.activity-schedule-calendar__sign-btn {
|
||
display: inline-flex;
|
||
min-width: 42px !important;
|
||
height: 24px !important;
|
||
padding: 0 8px !important;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid #1989fa !important;
|
||
border-radius: 5px !important;
|
||
color: #fff !important;
|
||
background-color: #1989fa !important;
|
||
box-shadow: none !important;
|
||
font-size: 11px !important;
|
||
}
|
||
|
||
.activity-schedule-calendar__empty {
|
||
padding: 18px 14px 22px;
|
||
color: #9aa5b5;
|
||
text-align: center;
|
||
}
|
||
|
||
.activity-schedule-calendar__empty img {
|
||
display: block;
|
||
width: 142px;
|
||
height: 94px;
|
||
margin: 0 auto 4px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.activity-schedule-calendar__empty-title {
|
||
color: #58677b;
|
||
font-size: 13px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.activity-schedule-calendar__empty-desc {
|
||
margin-top: 2px;
|
||
color: #a2adbc;
|
||
font-size: 11px;
|
||
line-height: 18px;
|
||
}
|
||
</style>
|