Add check-in feature and update travel planner UI

This commit is contained in:
周瑞哲
2026-07-31 12:50:14 +08:00
parent ac4179086c
commit 3ef7266591
44 changed files with 2919 additions and 144 deletions
+593
View File
@@ -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>