forked from zhouruizhe/gmTouringMiniApp
Add check-in feature and update travel planner UI
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckInBadge, CheckInBadgeDefinition, CheckInProfile, CheckInResult } from '@/domain/check-in'
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import PageState from '@/components/poi/PageState.vue'
|
||||
import PoiImage from '@/components/poi/PoiImage.vue'
|
||||
import { getCheckInTask, getCheckInTasks } from '@/data/check-in'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import {
|
||||
CHECK_IN_BADGES,
|
||||
CHECK_IN_POINTS,
|
||||
CheckInError,
|
||||
} from '@/domain/check-in'
|
||||
import { checkInAtPoi, getCheckInProfile } from '@/services/check-in'
|
||||
import { getCurrentGcj02Location, isLocationPermissionDenied } from '@/services/location'
|
||||
|
||||
const pageState = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
||||
const poi = shallowRef<PoiResolved | null>(null)
|
||||
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||
const locating = ref(false)
|
||||
const requestVersion = ref(0)
|
||||
const routePoiId = ref('')
|
||||
const privacyAuthorizationRequired = ref(false)
|
||||
|
||||
const checkedRecord = computed(() => poi.value
|
||||
? profile.value.checkIns.find(record => record.poiId === poi.value?.id) ?? null
|
||||
: null)
|
||||
const progressText = computed(() => {
|
||||
const availablePoiIds = new Set(getCheckInTasks().map(task => task.poiId))
|
||||
const completedCount = profile.value.checkIns.filter(record => availablePoiIds.has(record.poiId)).length
|
||||
return `${completedCount} / ${availablePoiIds.size}`
|
||||
})
|
||||
const checkInRadius = computed(() => poi.value ? getCheckInTask(poi.value.id)?.radiusMeters ?? 0 : 0)
|
||||
|
||||
function loadPage(poiId: string) {
|
||||
pageState.value = 'loading'
|
||||
try {
|
||||
const result = getPoiRepository().getPoiById(poiId)
|
||||
if (!result || !getCheckInTask(result.id)) {
|
||||
pageState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
poi.value = result
|
||||
profile.value = getCheckInProfile()
|
||||
pageState.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load check-in page', error)
|
||||
pageState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function returnToPoi() {
|
||||
uni.navigateBack({
|
||||
fail: () => uni.reLaunch({ url: '/pages/map/index' }),
|
||||
})
|
||||
}
|
||||
|
||||
function openDataRecovery() {
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
}
|
||||
|
||||
function openRecords() {
|
||||
cancelPendingLocation()
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
}
|
||||
|
||||
function cancelPendingLocation() {
|
||||
if (!locating.value)
|
||||
return
|
||||
requestVersion.value += 1
|
||||
locating.value = false
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '时间待确认'
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function badgeDefinition(badge: CheckInBadge): CheckInBadgeDefinition | null {
|
||||
return CHECK_IN_BADGES.find(definition => definition.code === badge.code) ?? null
|
||||
}
|
||||
|
||||
function checkInSuccessContent(result: CheckInResult): string {
|
||||
const badges = result.newBadges
|
||||
.map(badgeDefinition)
|
||||
.filter((badge): badge is CheckInBadgeDefinition => Boolean(badge))
|
||||
return badges.length > 0
|
||||
? `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分,同时解锁“${badges.map(badge => badge.name).join('、')}”。`
|
||||
: `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分。`
|
||||
}
|
||||
|
||||
function showLocationPermissionGuide() {
|
||||
uni.showModal({
|
||||
title: '需要位置权限',
|
||||
content: '打卡必须使用本次真实定位判断是否到达点位附近。授权仅在你点击打卡时触发。',
|
||||
confirmText: '去设置',
|
||||
success: result => result.confirm && uni.openSetting({}),
|
||||
})
|
||||
}
|
||||
|
||||
function privacyAuthorizationSupported(): boolean {
|
||||
try {
|
||||
return typeof uni.requirePrivacyAuthorize === 'function'
|
||||
&& (typeof uni.canIUse !== 'function' || uni.canIUse('requirePrivacyAuthorize'))
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function requirePrivacyAuthorization(): Promise<void> {
|
||||
if (!privacyAuthorizationSupported())
|
||||
return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.requirePrivacyAuthorize({
|
||||
success: () => resolve(),
|
||||
fail: error => reject(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isPrivacyAuthorizationDenied(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object')
|
||||
return false
|
||||
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
|
||||
return /privacy/i.test(message)
|
||||
}
|
||||
|
||||
function acceptPrivacyAuthorization() {
|
||||
privacyAuthorizationRequired.value = false
|
||||
requestCheckIn()
|
||||
}
|
||||
|
||||
async function requestCheckIn() {
|
||||
if (!poi.value || locating.value || checkedRecord.value)
|
||||
return
|
||||
|
||||
const currentRequest = ++requestVersion.value
|
||||
locating.value = true
|
||||
try {
|
||||
await requirePrivacyAuthorization()
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
const location = await getCurrentGcj02Location({ highAccuracyExpireTime: 10000, timeoutMs: 15000 })
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
const result = checkInAtPoi(poi.value.id, location)
|
||||
profile.value = result.profile
|
||||
uni.showModal({
|
||||
title: '打卡成功',
|
||||
content: checkInSuccessContent(result),
|
||||
showCancel: false,
|
||||
confirmText: '查看记录',
|
||||
success: modalResult => modalResult.confirm && openRecords(),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (currentRequest !== requestVersion.value)
|
||||
return
|
||||
if (isPrivacyAuthorizationDenied(error)) {
|
||||
privacyAuthorizationRequired.value = true
|
||||
return
|
||||
}
|
||||
if (isLocationPermissionDenied(error)) {
|
||||
showLocationPermissionGuide()
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '暂时无法完成打卡,请稍后重试'
|
||||
uni.showModal({
|
||||
title: error instanceof CheckInError && error.code === 'not_eligible' ? '尚不能打卡' : '打卡未完成',
|
||||
content: message,
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
if (currentRequest === requestVersion.value)
|
||||
locating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
|
||||
try {
|
||||
routePoiId.value = decodeURIComponent(rawPoiId)
|
||||
loadPage(routePoiId.value)
|
||||
}
|
||||
catch {
|
||||
pageState.value = 'invalid'
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (pageState.value === 'error' && routePoiId.value) {
|
||||
loadPage(routePoiId.value)
|
||||
return
|
||||
}
|
||||
try {
|
||||
profile.value = getCheckInProfile()
|
||||
if (poi.value && getCheckInTask(poi.value.id))
|
||||
pageState.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to refresh check-in profile', error)
|
||||
pageState.value = 'error'
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
cancelPendingLocation()
|
||||
})
|
||||
|
||||
onHide(cancelPendingLocation)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="check-in-page">
|
||||
<PageState v-if="pageState === 'loading'" state="loading" title="正在准备打卡…" />
|
||||
<PageState
|
||||
v-else-if="pageState === 'invalid'"
|
||||
state="empty"
|
||||
title="该点位暂不可打卡"
|
||||
description="点位可能已更新,请返回地图重新选择"
|
||||
action-label="返回地图"
|
||||
@action="returnToPoi"
|
||||
/>
|
||||
<PageState
|
||||
v-else-if="pageState === 'error'"
|
||||
state="error"
|
||||
title="打卡数据暂不可用"
|
||||
description="请前往“我的打卡”检查并按提示处理本机数据"
|
||||
action-label="去处理"
|
||||
@action="openDataRecovery"
|
||||
/>
|
||||
|
||||
<template v-else-if="poi">
|
||||
<view class="check-in-hero">
|
||||
<text class="check-in-hero__eyebrow">ON-SITE CHECK-IN</text>
|
||||
<text class="check-in-hero__title">抵达光明,留下足迹</text>
|
||||
<text class="check-in-hero__description">每次打卡都使用本次真实定位核验;位置只用于当场判断,不写入本机记录。</text>
|
||||
<button class="check-in-summary" @click="openRecords">
|
||||
<view>
|
||||
<text class="check-in-summary__label">本机积分</text>
|
||||
<text class="check-in-summary__value">{{ profile.points }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="check-in-summary__label">已打卡</text>
|
||||
<text class="check-in-summary__value">{{ progressText }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="check-in-summary__label">徽章</text>
|
||||
<text class="check-in-summary__value">{{ profile.badges.length }}</text>
|
||||
</view>
|
||||
<text class="check-in-summary__arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="check-in-content">
|
||||
<view class="poi-check-card">
|
||||
<PoiImage
|
||||
:src="poi.coverImage.url"
|
||||
:alt="poi.coverImage.alt"
|
||||
width="152rpx"
|
||||
height="152rpx"
|
||||
radius="18rpx"
|
||||
/>
|
||||
<view class="poi-check-card__copy">
|
||||
<text class="poi-check-card__category">{{ poi.category.name }}</text>
|
||||
<text class="poi-check-card__name">{{ poi.name }}</text>
|
||||
<text class="poi-check-card__address">{{ poi.address }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="checkedRecord" class="check-in-status check-in-status--done">
|
||||
<view class="check-in-status__mark">✓</view>
|
||||
<view class="check-in-status__copy">
|
||||
<text class="check-in-status__title">该点位已完成打卡</text>
|
||||
<text>{{ formatTime(checkedRecord.checkedInAt) }} · 获得 {{ checkedRecord.pointsAwarded }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="check-in-status">
|
||||
<view class="check-in-status__mark">◎</view>
|
||||
<view class="check-in-status__copy">
|
||||
<text class="check-in-status__title">请在点位附近发起打卡</text>
|
||||
<text>需在 {{ checkInRadius }} 米范围内,且定位精度满足要求</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="check-in-action"
|
||||
:class="{ 'check-in-action--done': checkedRecord }"
|
||||
:disabled="locating || Boolean(checkedRecord)"
|
||||
@click="requestCheckIn"
|
||||
>
|
||||
{{ checkedRecord ? '已完成打卡' : locating ? '正在获取本次位置…' : `验证位置并打卡 +${CHECK_IN_POINTS}` }}
|
||||
</button>
|
||||
|
||||
<view v-if="privacyAuthorizationRequired" class="check-in-privacy-consent">
|
||||
<text class="check-in-privacy-consent__title">打卡需要位置隐私授权</text>
|
||||
<text>同意后才会请求本次位置;位置仅用于到点判断,不保存精确经纬度。</text>
|
||||
<button
|
||||
id="check-in-privacy-agree"
|
||||
open-type="agreePrivacyAuthorization"
|
||||
@agreeprivacyauthorization="acceptPrivacyAuthorization"
|
||||
>
|
||||
同意并继续打卡
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="check-in-rules">
|
||||
<text class="check-in-rules__title">打卡规则</text>
|
||||
<view><text>01</text><text>每个开放打卡点位在本机仅可打卡一次</text></view>
|
||||
<view><text>02</text><text>每次打卡重新请求 GCJ-02 位置,不复用历史定位</text></view>
|
||||
<view><text>03</text><text>成功后增加积分,并按累计点位数解锁徽章</text></view>
|
||||
<view><text>04</text><text>系统级虚拟定位无法由本 POC 完全识别,正式版需增加风控</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: 点位打卡
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f4f8f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.check-in-page {
|
||||
min-height: 100vh;
|
||||
color: #18201d;
|
||||
background: #f4f8f6;
|
||||
}
|
||||
|
||||
.check-in-hero {
|
||||
padding: 46rpx 32rpx 34rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #123d35, #08724d 62%, #07573d);
|
||||
}
|
||||
|
||||
.check-in-hero__eyebrow {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #9fe0bd;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.check-in-hero__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
line-height: 58rpx;
|
||||
}
|
||||
|
||||
.check-in-hero__description {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 38rpx;
|
||||
color: rgb(239 255 246 / 78%);
|
||||
}
|
||||
|
||||
.check-in-summary {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 22rpx 24rpx;
|
||||
margin: 30rpx 0 0;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
background: rgb(255 255 255 / 11%);
|
||||
border: 1rpx solid rgb(255 255 255 / 22%);
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.check-in-summary::after,
|
||||
.check-in-action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.check-in-summary view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.check-in-summary__label {
|
||||
font-size: 18rpx;
|
||||
color: rgb(239 255 246 / 68%);
|
||||
}
|
||||
|
||||
.check-in-summary__value {
|
||||
margin-top: 5rpx;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.check-in-summary__arrow {
|
||||
font-size: 40rpx;
|
||||
color: #9fe0bd;
|
||||
}
|
||||
|
||||
.check-in-content {
|
||||
padding: 28rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.poi-check-card,
|
||||
.check-in-status,
|
||||
.check-in-rules,
|
||||
.check-in-privacy-consent {
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 22rpx;
|
||||
box-shadow: 0 10rpx 28rpx rgb(20 78 53 / 5%);
|
||||
}
|
||||
|
||||
.check-in-privacy-consent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 34rpx;
|
||||
color: #5e6b65;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent__title {
|
||||
margin-bottom: 6rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent button {
|
||||
width: 100%;
|
||||
height: 70rpx;
|
||||
margin: 18rpx 0 0;
|
||||
font-size: 23rpx;
|
||||
line-height: 70rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.check-in-privacy-consent button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poi-check-card {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.poi-check-card__copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poi-check-card__category {
|
||||
font-size: 20rpx;
|
||||
color: #16805c;
|
||||
}
|
||||
|
||||
.poi-check-card__name {
|
||||
margin-top: 5rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.poi-check-card__address {
|
||||
display: -webkit-box;
|
||||
margin-top: 7rpx;
|
||||
overflow: hidden;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
color: #718078;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.check-in-status {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
color: #5e6b65;
|
||||
}
|
||||
|
||||
.check-in-status--done {
|
||||
color: #315d4a;
|
||||
background: #e8f4ee;
|
||||
border-color: #cfe4d9;
|
||||
}
|
||||
|
||||
.check-in-status__mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.check-in-status__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 21rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.check-in-status__title {
|
||||
margin-bottom: 4rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 750;
|
||||
line-height: 38rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.check-in-action {
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
margin: 24rpx 0 0;
|
||||
font-size: 27rpx;
|
||||
font-weight: 750;
|
||||
line-height: 92rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 14rpx 30rpx rgb(14 98 71 / 16%);
|
||||
}
|
||||
|
||||
.check-in-action--done,
|
||||
.check-in-action[disabled] {
|
||||
color: #718078;
|
||||
background: #e3e9e6;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.check-in-rules {
|
||||
padding: 26rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.check-in-rules__title {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.check-in-rules view {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 12rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 34rpx;
|
||||
color: #5e6b65;
|
||||
border-bottom: 1rpx solid #edf1ef;
|
||||
}
|
||||
|
||||
.check-in-rules view:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.check-in-rules view text:first-child {
|
||||
flex: none;
|
||||
font-family: Georgia, serif;
|
||||
color: #16805c;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user